mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-13 15:32:24 -06:00
Compare commits
54 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 273d547f4e | |||
| bbb404c363 | |||
| 072113f7ca | |||
| 7f1b0acf7a | |||
| 6904bd8f39 | |||
| 4d677d1ebf | |||
| 35f462a46d | |||
| ec74334e74 | |||
| 2fd0c29a92 | |||
| 1207d27363 | |||
| 733c9818d4 | |||
| 28a2779c10 | |||
| 56364b0b5b | |||
| afb5804a7c | |||
| 4b508a1319 | |||
| 9c2cb185e1 | |||
| 0519b847bd | |||
| bbc8b99a9f | |||
| bd9f780b21 | |||
| 5d14b5f675 | |||
| 4693fa95f1 | |||
| c3423d6606 | |||
| 53f1222c22 | |||
| 802d87a57f | |||
| 8349d9994d | |||
| ac1fd67137 | |||
| 1b40ae79f9 | |||
| b078ddccf0 | |||
| 4b6c93a0e9 | |||
| 9d283e951f | |||
| 4e407e7d4f | |||
| 7ab24e500b | |||
| 5bcbcb73b9 | |||
| af6749421a | |||
| 4d6cb77075 | |||
| 5c225ef39b | |||
| f5a843f44a | |||
| cf44841624 | |||
| 7ffab6a272 | |||
| ba3bc9d989 | |||
| dbe023b4dd | |||
| 3d3a8b7367 | |||
| 99eff73a97 | |||
| 99fcd30299 | |||
| 423c2e80b7 | |||
| a0eb77360d | |||
| 3dd0e196fe | |||
| 19c3db5329 | |||
| 0bea72019e | |||
| b9ff52d582 | |||
| 961f999c93 | |||
| 8bdb916064 | |||
| 25fe4e728a | |||
| 0d1a32ff65 |
@@ -47,9 +47,9 @@ jobs:
|
||||
# explicit setup, that suite silently skips if the runner
|
||||
# image happens not to ship Node, masking regressions in
|
||||
# the browser-side renderer.
|
||||
- uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
|
||||
with:
|
||||
node-version: "20"
|
||||
node-version: "24"
|
||||
- run: pip install -e ".[test]"
|
||||
- run: pytest tests/ -m "not live" --cov=turnstone --cov-report=term-missing --cov-report=xml -q
|
||||
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
@@ -79,9 +79,9 @@ jobs:
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
|
||||
with:
|
||||
python-version: "3.14"
|
||||
- uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
|
||||
with:
|
||||
node-version: "20"
|
||||
node-version: "24"
|
||||
- run: pip install -e ".[test,postgres]"
|
||||
- run: pytest tests/ -m "not live" --storage-backend=postgresql -q
|
||||
env:
|
||||
|
||||
+6
-3
@@ -8,14 +8,17 @@ FROM python:3.14-slim
|
||||
LABEL org.opencontainers.image.title="turnstone" \
|
||||
org.opencontainers.image.description="Multi-node AI orchestration platform"
|
||||
|
||||
COPY --from=ghcr.io/astral-sh/uv:0.11.7 /uv /usr/local/bin/uv
|
||||
COPY --from=ghcr.io/astral-sh/uv:0.11.8 /uv /usr/local/bin/uv
|
||||
|
||||
# Remove the slim image's man page exclusion so man-db has actual content
|
||||
RUN rm -f /etc/dpkg/dpkg.cfg.d/docker
|
||||
|
||||
# System dependencies: psycopg (libpq5), developer tooling for agent workflows
|
||||
# System dependencies: psycopg (libpq5), developer tooling for agent workflows.
|
||||
# ripgrep is the preferred backend for the search tool — natively bounds
|
||||
# per-line, per-file, and per-filesize so pathological inputs (minified
|
||||
# bundles, training-data JSONL with multi-MB single records) can't OOM us.
|
||||
RUN apt-get update && apt-get upgrade -y && apt-get install -y --no-install-recommends \
|
||||
libpq5 git curl jq man-db manpages procps file \
|
||||
libpq5 git curl jq man-db manpages procps file ripgrep \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Node.js LTS (for npx-based MCP servers like @modelcontextprotocol/server-github)
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
---
|
||||
name: import-conversation-history
|
||||
description: Use this skill when the user wants to import or migrate conversation history from another LLM chat or coding tool (e.g. ChatGPT, Claude.ai, Cursor, Copilot Chat, Aider, Gemini, a custom JSON export) into Turnstone. The skill teaches Turnstone's destination contracts — workstream identity, the OpenAI-shaped message rows, tool-call/result pairing, provider-fidelity blobs, attachments, and archive-vs-resumable choice — so the agent can map any source format onto them. Trigger phrases: "import my chats", "migrate this transcript into Turnstone", "bring my Claude.ai history over", "load this export as a workstream".
|
||||
version: 1.0.0
|
||||
---
|
||||
|
||||
# Importing Conversation History into Turnstone
|
||||
|
||||
## Overview
|
||||
|
||||
Source formats vary; the destination does not. Your job is to translate whatever the user hands you (JSON dump, ZIP export, scraped HTML, screenshot OCR, raw transcript) into Turnstone's internal shape: **one workstream row** plus an ordered sequence of **conversation rows** in OpenAI message format. This skill documents the destination so you can write a correct mapper for any source.
|
||||
|
||||
Two questions to settle with the user before writing anything:
|
||||
|
||||
1. **Archive or resumable?** An archive ("saved" workstream — `state="closed"`) is read-only history. A resumable workstream (`state="idle"`) lets the user continue the conversation; this only works cleanly when the source LLM matches a Turnstone-supported provider/model and tool definitions still resolve.
|
||||
2. **One workstream per source thread, or merge?** Default to one-to-one unless the user explicitly asks to merge.
|
||||
|
||||
Default to **archive** when in doubt — resuming a foreign transcript with mismatched tool schemas or stale provider signatures will fail at the next turn.
|
||||
|
||||
## Turnstone Data Model (the destination)
|
||||
|
||||
Two tables carry the conversation:
|
||||
|
||||
### `workstreams` (one row per imported thread)
|
||||
|
||||
| Column | Required | Notes |
|
||||
|---|---|---|
|
||||
| `ws_id` | yes | 32-char lowercase hex. Auto-generate with `secrets.token_hex(16)` if you don't already have one. **First 4 hex chars are the routing bucket** — see "Identity & Routing" below. |
|
||||
| `name` | yes | Short title. Pull from source thread title; fall back to first ~60 chars of first user message. |
|
||||
| `state` | yes | `"closed"` for archive, `"idle"` for resumable. Never set `"running"` on import. |
|
||||
| `kind` | yes | `"interactive"` for normal threads. Do NOT use `"coordinator"` for imports — that's reserved for cluster-spawned coordinator workstreams. |
|
||||
| `parent_ws_id` | no | Leave NULL. Only set if you're importing a coordinator-spawned subtree and re-parenting it; rare. |
|
||||
| `user_id` | yes | Owner. Must exist in `users`; importer must know which Turnstone user owns the imported history. |
|
||||
| `node_id` | yes (multi-node) | Denormalized cache of the node that owns this `ws_id`'s bucket. Single-node deployments can leave it NULL or set it to the only node. |
|
||||
| `alias` | no | Human-typeable short name. Optional; must be unique cluster-wide if set. |
|
||||
| `title` | no | Auto-titled later by the LLM; safe to leave NULL on import. |
|
||||
| `skill_id`, `skill_version` | yes | Default `""` and `0` unless the source thread was scoped to a Turnstone skill. |
|
||||
| `created`, `updated` | yes | ISO8601 strings. Use the source's first/last message timestamps when available. |
|
||||
|
||||
### `conversations` (many rows per thread, ordered by `id`/`timestamp`)
|
||||
|
||||
| Column | Notes |
|
||||
|---|---|
|
||||
| `ws_id` | The workstream this row belongs to. |
|
||||
| `timestamp` | ISO8601 string. Preserve source timestamps; fall back to monotonically increasing values if unknown. **Order is canonical via `id` (autoincrement), not `timestamp`** — but always insert in conversational order so both agree. |
|
||||
| `role` | One of `system`, `user`, `assistant`, `tool`, `developer`. See role mapping below. |
|
||||
| `content` | Text. May be NULL for assistant rows that are *only* tool calls. |
|
||||
| `tool_name` | Set on `role="tool"` rows (the tool whose result this is). NULL otherwise. |
|
||||
| `tool_call_id` | Set on `role="tool"` rows (matches the assistant row's `tool_calls[].id`). NULL otherwise. |
|
||||
| `tool_calls` | JSON-encoded list, on `role="assistant"` rows that issued tool calls. OpenAI shape — see "Tool Calls" below. |
|
||||
| `provider_data` | JSON blob preserving provider-native content blocks (Anthropic `signature`, Gemini `thought_signature`, etc.). Optional; only matters for **resumable** imports against the same provider. Skip for archives. |
|
||||
|
||||
The internal format is **OpenAI-shaped**, even when the source was Anthropic or Gemini. Providers translate at their own API boundary; storage stays uniform.
|
||||
|
||||
## Identity & Routing (`ws_id`)
|
||||
|
||||
- `ws_id` is **32-char lowercase hex** (i.e. `secrets.token_hex(16)`).
|
||||
- The **routing bucket** is `int(ws_id[:4], 16)` — the first 4 hex chars place this workstream on a specific node via the consistent hash ring.
|
||||
- For multi-node imports: either insert through the console's routing proxy (which forwards to the owning node), or generate `ws_id`s and write directly to each node's database in batches grouped by bucket.
|
||||
- For single-node imports: bucket math is irrelevant; any `ws_id` works.
|
||||
- **Do not reuse the source platform's IDs as `ws_id`** unless they happen to be 32-char hex. Generate fresh; if you need the old ID for traceability, store it in `workstream_config` under a key like `import.source_id`.
|
||||
|
||||
## Recommended Import Path
|
||||
|
||||
Three options, in order of preference:
|
||||
|
||||
### 1. Storage protocol (recommended for full history)
|
||||
|
||||
Use `turnstone.core.storage.Storage.save_messages_bulk(rows)`. This is the canonical bulk-insert primitive and bypasses the LLM round-trip entirely.
|
||||
|
||||
```python
|
||||
from turnstone.core.storage import get_storage # construct via the same path the server uses
|
||||
|
||||
storage = get_storage(...) # see turnstone.core.storage.__init__ for the project's wiring
|
||||
|
||||
storage.create_workstream( # or whatever the project's exposed creator is — check turnstone/core/storage/_protocol.py
|
||||
ws_id=ws_id,
|
||||
user_id=user_id,
|
||||
name=name,
|
||||
state="closed",
|
||||
kind="interactive",
|
||||
...
|
||||
)
|
||||
|
||||
storage.save_messages_bulk([
|
||||
{"ws_id": ws_id, "role": "user", "content": "Hello"},
|
||||
{"ws_id": ws_id, "role": "assistant", "content": "Hi! What can I help with?"},
|
||||
{"ws_id": ws_id, "role": "assistant", "content": None,
|
||||
"tool_calls": json.dumps([{"id": "call_1", "type": "function",
|
||||
"function": {"name": "search", "arguments": "{\"q\":\"x\"}"}}])},
|
||||
{"ws_id": ws_id, "role": "tool", "tool_name": "search", "tool_call_id": "call_1",
|
||||
"content": "result text"},
|
||||
# ...
|
||||
])
|
||||
```
|
||||
|
||||
`save_messages_bulk` handles `timestamp` and the workstream's `updated` column internally, so you don't need to compute them per row. **Verify the exact creator signature** by reading `turnstone/core/storage/_protocol.py` — table layout has shifted across migrations and the Storage protocol is the source of truth.
|
||||
|
||||
### 2. SDK `create_workstream(resume_ws=...)` (when the source is already a Turnstone workstream)
|
||||
|
||||
Only useful for *Turnstone → Turnstone* re-parenting. Not relevant for foreign sources.
|
||||
|
||||
### 3. SDK `create_workstream(initial_message=...)` + `send()` per turn (last resort)
|
||||
|
||||
Only fits archives where the source had **no tool calls** and you don't care about preserving assistant turns verbatim. Each `send()` triggers a real LLM round-trip, which is expensive and rewrites assistant content. Don't use this for full history.
|
||||
|
||||
## Role Mapping
|
||||
|
||||
Common source-role conventions and how they map to Turnstone:
|
||||
|
||||
| Source role | Turnstone `role` | Notes |
|
||||
|---|---|---|
|
||||
| `user`, `human` | `user` | Direct map. |
|
||||
| `assistant`, `ai`, `model`, `bot` | `assistant` | Direct map. |
|
||||
| `system` | `system` | Preserve only if it's content the user wrote (custom instructions). Drop boilerplate provider preambles — Turnstone composes its own system message. |
|
||||
| `developer` (OpenAI o-series) | `developer` | Preserve. |
|
||||
| `tool`, `function`, `tool_result` | `tool` | Must carry `tool_name` and `tool_call_id` matching the prior assistant row's `tool_calls[].id`. |
|
||||
| `tool_use` (Anthropic) | `assistant` with `tool_calls` | Anthropic emits tool calls *inside* an assistant message; flatten to OpenAI shape. |
|
||||
| `human_feedback`, `revision` | `user` | Treat as a follow-up user turn. |
|
||||
|
||||
## Tool Calls (the most error-prone part)
|
||||
|
||||
Turnstone stores tool calls in OpenAI's nested-function shape on the assistant row, and matches them with `role="tool"` result rows by `tool_call_id`.
|
||||
|
||||
### Assistant row with tool calls
|
||||
|
||||
```json
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": null,
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "call_abc123",
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "search_web",
|
||||
"arguments": "{\"query\":\"turnstone import\"}"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
`tool_calls[].function.arguments` is **a JSON-encoded string**, not an object. Source formats commonly get this wrong — Anthropic stores arguments as a parsed object, Gemini as a struct. Always re-serialize to a string.
|
||||
|
||||
### Tool result row
|
||||
|
||||
```json
|
||||
{
|
||||
"role": "tool",
|
||||
"tool_name": "search_web",
|
||||
"tool_call_id": "call_abc123",
|
||||
"content": "..."
|
||||
}
|
||||
```
|
||||
|
||||
Pairing rules:
|
||||
- Every assistant `tool_calls[].id` MUST be followed by exactly one `role="tool"` row with the matching `tool_call_id`, before the next user/assistant turn.
|
||||
- If the source dropped the tool result (cut-off transcript), insert a synthetic `role="tool"` row with `content="[tool result missing in source]"` to keep the chain valid. An assistant row with an unanswered `tool_calls[].id` will break replay and any LLM round-trip.
|
||||
- Multi-tool assistant turns: one `role="tool"` row per call, in any order, all before the next non-tool row.
|
||||
|
||||
### Tool ID generation
|
||||
|
||||
If the source used opaque tool IDs that aren't unique within a thread (some platforms reuse them), regenerate with a stable scheme like `f"call_{i}"` where `i` is a per-thread counter. Update both the assistant and tool rows together.
|
||||
|
||||
## Provider Fidelity (`provider_data`)
|
||||
|
||||
Skip this entirely for **archive** imports.
|
||||
|
||||
For **resumable** imports against the same provider, populate `provider_data` to preserve provider-specific tool-call metadata that the next API round-trip will require:
|
||||
|
||||
- **Anthropic**: `signature` field on thinking blocks; required for round-tripping extended-thinking responses.
|
||||
- **Gemini**: `thought_signature` on tool calls; required for fidelity.
|
||||
- **OpenAI**: typically nothing to preserve.
|
||||
|
||||
The runtime-side dict key is `_provider_content` (a list of provider-native blocks); the persisted column is `provider_data` (the same list, JSON-encoded). If you don't have provider-native blocks from the source — and you usually won't, because a foreign export won't include them — leave `provider_data` NULL. The first new turn will succeed without it, but the previous assistant turn's reasoning won't replay back to the model.
|
||||
|
||||
## Attachments
|
||||
|
||||
If the source thread had image or file attachments:
|
||||
|
||||
- **Size limits**: images ≤ 4 MiB, text documents ≤ 512 KiB. Reject or downsample anything bigger.
|
||||
- **Allowed types**: server validates magic bytes for images and UTF-8-decodes for text. Binary blobs that aren't images won't pass.
|
||||
- **Lifecycle**: pending → reserved → consumed. For imports, the cleanest path is to upload as pending and immediately consume by attaching to the relevant `conversations.id`.
|
||||
|
||||
Two import paths:
|
||||
|
||||
1. **Bulk-insert + post-attach**: insert messages first, get back the assistant/user `conversations.id`, then write `workstream_attachments` rows linking the file to `message_id`.
|
||||
2. **SDK multipart create**: `create_workstream(attachments=[...], initial_message=...)` for the *first* turn only — the server reserves and consumes them onto that turn. Doesn't help for mid-thread attachments.
|
||||
|
||||
For full-history imports with multiple attachments at different turns, path (1) is the only option.
|
||||
|
||||
## Validation Checklist
|
||||
|
||||
Before declaring success, verify:
|
||||
|
||||
- [ ] `ws_id` is 32-char lowercase hex.
|
||||
- [ ] `workstreams` row exists with the right `user_id`, `state`, `kind`.
|
||||
- [ ] Conversation rows are inserted **in order** (autoincrement `id` will reflect insert order).
|
||||
- [ ] Every assistant `tool_calls[].id` has a matching `role="tool"` row with the same `tool_call_id`.
|
||||
- [ ] `tool_calls[].function.arguments` is a JSON-encoded **string**, not a parsed object.
|
||||
- [ ] First message is typically `role="user"` (not `system`) — Turnstone composes its own system prompt at runtime.
|
||||
- [ ] No empty assistant rows (`content=NULL` AND `tool_calls=NULL` is invalid).
|
||||
- [ ] If multi-node: the `ws_id`'s bucket maps to a node that exists; `workstreams.node_id` matches.
|
||||
- [ ] Round-trip test: run `Storage.load_messages(ws_id)` and confirm the reconstructed list matches what you inserted (modulo timestamps).
|
||||
|
||||
## Anti-patterns
|
||||
|
||||
- **Don't import the source provider's system prompt verbatim.** Provider boilerplate ("You are Claude...", "You are ChatGPT...") will conflict with Turnstone's composed system message and confuse the model on resume. Drop it; preserve only user-authored custom instructions.
|
||||
- **Don't preserve foreign tool definitions as Turnstone tools.** If the source had custom tools that don't exist in Turnstone, the assistant rows that called them are still valid history (archive), but the workstream is **not resumable** — mark `state="closed"`.
|
||||
- **Don't fabricate `tool_call_id`s without re-pairing.** Mismatched ids silently break the replay chain on the next turn.
|
||||
- **Don't skip the `tool_name` field on `role="tool"` rows.** Some load paths use it for display and audit; NULL there will render as "unknown tool".
|
||||
- **Don't write through the LLM (`send()` per turn) for full history.** It's expensive, rewrites assistant turns, and rate-limits will bite long imports.
|
||||
|
||||
## Quick Reference
|
||||
|
||||
| Task | Path |
|
||||
|---|---|
|
||||
| Generate ws_id | `secrets.token_hex(16)` |
|
||||
| Bulk insert messages | `Storage.save_messages_bulk(rows)` |
|
||||
| Archive (read-only) | `state="closed"`, skip `provider_data` |
|
||||
| Resumable | `state="idle"`, populate `provider_data` if same provider |
|
||||
| Tool call id | OpenAI shape: `{"id": ..., "type": "function", "function": {"name": ..., "arguments": "<json string>"}}` |
|
||||
| Tool result row | `role="tool"`, `tool_name`, `tool_call_id`, `content` |
|
||||
| Source role → Turnstone role | See "Role Mapping" table |
|
||||
| Per-thread metadata | Store source IDs in `workstream_config` under `import.*` keys |
|
||||
|
||||
## Files to read before writing the importer
|
||||
|
||||
- `turnstone/core/storage/_schema.py` — authoritative table definitions.
|
||||
- `turnstone/core/storage/_protocol.py` — `save_message`, `save_messages_bulk`, `load_messages` signatures.
|
||||
- `turnstone/core/session.py` (around the message-save section) — how the runtime constructs in-memory message dicts; mirror this shape on import to round-trip cleanly.
|
||||
- `turnstone/api/server_schemas.py` — Pydantic shapes for the SDK paths if you go through HTTP.
|
||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "turnstone"
|
||||
version = "1.5.0"
|
||||
version = "1.5.7"
|
||||
description = "Multi-node AI orchestration platform with tool use, agent routing, and cluster simulation."
|
||||
readme = "README.md"
|
||||
license = "BUSL-1.1"
|
||||
|
||||
Generated
+12
-12
@@ -373,9 +373,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@tybys/wasm-util": {
|
||||
"version": "0.10.1",
|
||||
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
|
||||
"integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==",
|
||||
"version": "0.10.2",
|
||||
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz",
|
||||
"integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
@@ -902,9 +902,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
"version": "3.3.11",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
|
||||
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
|
||||
"version": "3.3.12",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz",
|
||||
"integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -959,9 +959,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.12",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.12.tgz",
|
||||
"integrity": "sha512-W62t/Se6rA0Az3DfCL0AqJwXuKwBeYg6nOaIgzP+xZ7N5BFCI7DYi1qs6ygUYT6rvfi6t9k65UMLJC+PHZpDAA==",
|
||||
"version": "8.5.13",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.13.tgz",
|
||||
"integrity": "sha512-qif0+jGGZoLWdHey3UFHHWP0H7Gbmsk8T5VEqyYFbWqPr1XqvLGBbk/sl8V5exGmcYJklJOhOQq1pV9IcsiFag==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -1060,9 +1060,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tinyexec": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.1.1.tgz",
|
||||
"integrity": "sha512-VKS/ZaQhhkKFMANmAOhhXVoIfBXblQxGX1myCQ2faQrfmobMftXeJPcZGp0gS07ocvGJWDLZGyOZDadDBqYIJg==",
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.1.2.tgz",
|
||||
"integrity": "sha512-dAqSqE/RabpBKI8+h26GfLq6Vb3JVXs30XYQjdMjaj/c2tS8IYYMbIzP599KtRj7c57/wYApb3QjgRgXmrCukA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
|
||||
@@ -35,11 +35,10 @@ def _seed_children(
|
||||
The production path populates the registry via the cluster-event
|
||||
fan-out thread observing ``ws_created`` events. These tests just
|
||||
need a known-children set for the endpoint handlers to iterate —
|
||||
inject directly under ``_children_lock`` rather than spinning up
|
||||
the collector + fan-out plumbing.
|
||||
inject directly via the registry's bulk-merge surface rather than
|
||||
spinning up the collector + fan-out plumbing.
|
||||
"""
|
||||
with adapter._children_lock:
|
||||
adapter._merge_child_ids_locked(coord_ws_id, child_ws_ids)
|
||||
adapter._registry.merge_children(coord_ws_id, child_ws_ids)
|
||||
|
||||
|
||||
class _AuthMiddleware(BaseHTTPMiddleware):
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
"""Shared test helpers — kept out of conftest.py since these are factories,
|
||||
not fixtures, and several test files want to import them directly."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
|
||||
def make_chat_session(**overrides: Any) -> Any:
|
||||
"""Build a minimal ``ChatSession`` with sane test defaults.
|
||||
|
||||
Caller passes any constructor arg as a kwarg to override the default —
|
||||
e.g. ``make_chat_session(memory_config=MemoryConfig(fetch_limit=5))``.
|
||||
"""
|
||||
from turnstone.core.session import ChatSession
|
||||
|
||||
defaults: dict[str, Any] = {
|
||||
"client": MagicMock(),
|
||||
"model": "test-model",
|
||||
"ui": MagicMock(),
|
||||
"instructions": None,
|
||||
"temperature": 0.5,
|
||||
"max_tokens": 4096,
|
||||
"tool_timeout": 30,
|
||||
}
|
||||
defaults.update(overrides)
|
||||
return ChatSession(**defaults)
|
||||
@@ -0,0 +1,481 @@
|
||||
"""Console-side coord_registry auto-refresh on model-definition CRUD + reload.
|
||||
|
||||
The console builds ``app.state.coord_registry`` once at lifespan startup
|
||||
and the coordinator session factory closes over that exact instance.
|
||||
Without these refresh hooks, an admin who edits a model definition
|
||||
through the UI sees the DB change immediately but coordinator sessions
|
||||
keep calling the prior model name — the on-disk truth diverges from the
|
||||
in-process registry until the console is restarted.
|
||||
|
||||
These tests cover both the helper (``_refresh_coord_registry``)
|
||||
and the four wired endpoints (create / update / delete / explicit reload)
|
||||
to lock in:
|
||||
|
||||
- in-place mutation: ``coord_registry`` object identity is preserved
|
||||
across refreshes (factory closure must not be invalidated);
|
||||
- failure isolation: a load or reload failure leaves the existing
|
||||
registry intact rather than tearing down a working coordinator;
|
||||
- no-op safety: the helper short-circuits when ``coord_registry`` is
|
||||
``None`` so a coord-less console (no model rows at boot) doesn't
|
||||
500 on routine model-definition CRUD.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from starlette.applications import Starlette
|
||||
from starlette.middleware import Middleware
|
||||
from starlette.routing import Route
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
from tests._coord_test_helpers import _AuthMiddleware
|
||||
from turnstone.console.server import (
|
||||
_refresh_coord_registry,
|
||||
admin_create_model_definition,
|
||||
admin_delete_model_definition,
|
||||
admin_model_reload,
|
||||
admin_update_model_definition,
|
||||
)
|
||||
from turnstone.core.model_registry import ModelConfig, ModelRegistry
|
||||
from turnstone.core.storage._sqlite import SQLiteBackend
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def storage(tmp_path: Any) -> SQLiteBackend:
|
||||
return SQLiteBackend(str(tmp_path / "models.db"))
|
||||
|
||||
|
||||
def _seed_model_def(
|
||||
storage: SQLiteBackend,
|
||||
*,
|
||||
definition_id: str,
|
||||
alias: str,
|
||||
model: str,
|
||||
base_url: str = "http://localhost:8000/v1",
|
||||
enabled: bool = True,
|
||||
) -> None:
|
||||
"""Insert a model definition row directly via the storage API."""
|
||||
storage.create_model_definition(
|
||||
definition_id=definition_id,
|
||||
alias=alias,
|
||||
model=model,
|
||||
provider="openai-compatible",
|
||||
base_url=base_url,
|
||||
api_key="sk-test",
|
||||
context_window=8192,
|
||||
capabilities="{}",
|
||||
enabled=enabled,
|
||||
created_by="admin",
|
||||
)
|
||||
|
||||
|
||||
def _make_config(alias: str, model: str) -> ModelConfig:
|
||||
return ModelConfig(
|
||||
alias=alias,
|
||||
base_url="http://localhost:8000/v1",
|
||||
api_key="sk-test",
|
||||
model=model,
|
||||
context_window=8192,
|
||||
provider="openai-compatible",
|
||||
source="db",
|
||||
)
|
||||
|
||||
|
||||
def _make_registry(
|
||||
*,
|
||||
alias: str = "local",
|
||||
model: str = "old-model",
|
||||
extras: dict[str, str] | None = None,
|
||||
) -> ModelRegistry:
|
||||
"""Build a real ModelRegistry seeded with ``alias`` (the default) plus
|
||||
any ``extras`` (alias → model). ``ModelRegistry.__init__`` rejects an
|
||||
empty model dict so tests that exercise the helper need at least one
|
||||
entry; pass ``extras`` for multi-alias scenarios (e.g. delete-by-alias).
|
||||
"""
|
||||
configs = {alias: _make_config(alias, model)}
|
||||
for extra_alias, extra_model in (extras or {}).items():
|
||||
configs[extra_alias] = _make_config(extra_alias, extra_model)
|
||||
return ModelRegistry(configs, default=alias)
|
||||
|
||||
|
||||
class _AppState:
|
||||
"""Shim mirroring Starlette's ``app.state`` for direct helper tests."""
|
||||
|
||||
coord_registry: ModelRegistry | None = None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helper-level tests — ``_refresh_coord_registry`` semantics
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_helper_rebuilds_registry_from_db(storage: SQLiteBackend) -> None:
|
||||
"""Helper pulls the latest DB rows into the existing registry."""
|
||||
_seed_model_def(storage, definition_id="m1", alias="local", model="new-model")
|
||||
state = _AppState()
|
||||
state.coord_registry = _make_registry(alias="local", model="old-model")
|
||||
|
||||
_refresh_coord_registry(state, storage)
|
||||
|
||||
assert state.coord_registry is not None
|
||||
assert state.coord_registry.get_config("local").model == "new-model"
|
||||
|
||||
|
||||
def test_helper_preserves_object_identity(storage: SQLiteBackend) -> None:
|
||||
"""The factory closes over the registry object — refresh must mutate
|
||||
in place rather than swap the attribute."""
|
||||
_seed_model_def(storage, definition_id="m1", alias="local", model="new-model")
|
||||
state = _AppState()
|
||||
state.coord_registry = _make_registry()
|
||||
before = id(state.coord_registry)
|
||||
|
||||
_refresh_coord_registry(state, storage)
|
||||
|
||||
assert id(state.coord_registry) == before
|
||||
|
||||
|
||||
def test_helper_noop_when_coord_registry_none(storage: SQLiteBackend) -> None:
|
||||
"""Console boot with no model rows leaves coord_registry = None.
|
||||
The helper must not 500 in that state — CRUD that lands the FIRST
|
||||
row would otherwise fail before the operator can recover."""
|
||||
_seed_model_def(storage, definition_id="m1", alias="local", model="m")
|
||||
state = _AppState()
|
||||
state.coord_registry = None
|
||||
|
||||
_refresh_coord_registry(state, storage) # must not raise
|
||||
|
||||
assert state.coord_registry is None
|
||||
|
||||
|
||||
def test_helper_preserves_registry_when_load_fails(
|
||||
storage: SQLiteBackend, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""An unexpected error from ``load_model_registry`` (e.g. config.toml
|
||||
parse failure, programming bug) must not tear down a working
|
||||
registry — log + leave the existing instance intact."""
|
||||
state = _AppState()
|
||||
state.coord_registry = _make_registry(alias="local", model="old-model")
|
||||
|
||||
def _boom(**_kw: Any) -> ModelRegistry:
|
||||
raise RuntimeError("simulated loader failure")
|
||||
|
||||
monkeypatch.setattr("turnstone.core.model_registry.load_model_registry", _boom)
|
||||
_refresh_coord_registry(state, storage)
|
||||
|
||||
assert state.coord_registry is not None
|
||||
assert state.coord_registry.get_config("local").model == "old-model"
|
||||
|
||||
|
||||
def test_helper_preserves_registry_when_strict_load_fails(
|
||||
storage: SQLiteBackend, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""``load_model_registry`` normally swallows storage read errors and
|
||||
would return a config.toml-only registry on a transient DB outage —
|
||||
applying that via ``reload()`` would silently drop every DB-sourced
|
||||
alias. The helper passes ``strict=True`` so the loader re-raises
|
||||
instead, the helper's outer except catches it, and the existing
|
||||
registry survives intact."""
|
||||
_seed_model_def(storage, definition_id="m1", alias="local", model="db-model")
|
||||
state = _AppState()
|
||||
state.coord_registry = _make_registry(alias="local", model="db-model")
|
||||
|
||||
def _broken(**_kw: Any) -> Any:
|
||||
raise RuntimeError("simulated transient DB outage")
|
||||
|
||||
monkeypatch.setattr(storage, "list_model_definitions", _broken)
|
||||
_refresh_coord_registry(state, storage)
|
||||
|
||||
assert state.coord_registry is not None
|
||||
# Existing registry untouched — strict=True surfaced the storage
|
||||
# error to the helper before the loader's silent fallback could
|
||||
# produce a truncated registry for reload().
|
||||
assert state.coord_registry.get_config("local").model == "db-model"
|
||||
|
||||
|
||||
def test_helper_preserves_registry_when_no_enabled_rows(storage: SQLiteBackend) -> None:
|
||||
"""All rows disabled/deleted: ModelRegistry.__init__ rejects an empty
|
||||
model dict (raises ValueError). Helper must catch and preserve the
|
||||
existing registry so coord stays usable while admin restores rows."""
|
||||
_seed_model_def(storage, definition_id="m1", alias="local", model="m", enabled=False)
|
||||
state = _AppState()
|
||||
state.coord_registry = _make_registry(alias="local", model="cached-model")
|
||||
|
||||
_refresh_coord_registry(state, storage)
|
||||
|
||||
assert state.coord_registry is not None
|
||||
assert state.coord_registry.get_config("local").model == "cached-model"
|
||||
|
||||
|
||||
def test_helper_preserves_registry_on_reload_validation_error(
|
||||
storage: SQLiteBackend, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""A reload that raises mid-mutation (e.g. validation guard) must
|
||||
leave the existing registry instance functional."""
|
||||
_seed_model_def(storage, definition_id="m1", alias="local", model="new-model")
|
||||
state = _AppState()
|
||||
state.coord_registry = _make_registry(alias="local", model="old-model")
|
||||
|
||||
def _broken_reload(*_a: Any, **_kw: Any) -> None:
|
||||
raise ValueError("simulated reload validation failure")
|
||||
|
||||
monkeypatch.setattr(state.coord_registry, "reload", _broken_reload)
|
||||
_refresh_coord_registry(state, storage)
|
||||
|
||||
# Existing registry still reachable; the broken reload was a no-op
|
||||
# at the public-facing level.
|
||||
assert state.coord_registry is not None
|
||||
assert state.coord_registry.get_config("local").model == "old-model"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Endpoint-level integration tests — verify wiring
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_client(storage: SQLiteBackend, registry: ModelRegistry | None) -> TestClient:
|
||||
"""Build a TestClient wired to the four model-definition endpoints.
|
||||
|
||||
Uses the shared header-driven ``_AuthMiddleware`` from
|
||||
``tests/_coord_test_helpers``; default headers below grant
|
||||
``admin.models`` permission so the endpoint gate passes.
|
||||
"""
|
||||
app = Starlette(
|
||||
routes=[
|
||||
Route(
|
||||
"/v1/api/admin/model-definitions",
|
||||
admin_create_model_definition,
|
||||
methods=["POST"],
|
||||
),
|
||||
Route(
|
||||
"/v1/api/admin/model-definitions/reload",
|
||||
admin_model_reload,
|
||||
methods=["POST"],
|
||||
),
|
||||
Route(
|
||||
"/v1/api/admin/model-definitions/{definition_id}",
|
||||
admin_update_model_definition,
|
||||
methods=["PUT"],
|
||||
),
|
||||
Route(
|
||||
"/v1/api/admin/model-definitions/{definition_id}",
|
||||
admin_delete_model_definition,
|
||||
methods=["DELETE"],
|
||||
),
|
||||
],
|
||||
middleware=[Middleware(_AuthMiddleware)],
|
||||
)
|
||||
app.state.auth_storage = storage
|
||||
app.state.coord_registry = registry
|
||||
# Reload endpoint also touches these — stub them so the test focuses
|
||||
# on the registry-refresh behaviour without dragging in a full
|
||||
# collector / proxy_client wiring.
|
||||
app.state.collector = MagicMock()
|
||||
app.state.collector.get_all_nodes.return_value = []
|
||||
app.state.proxy_client = MagicMock()
|
||||
app.state.config_store = MagicMock()
|
||||
client = TestClient(app)
|
||||
client.headers.update({"X-Test-User": "admin", "X-Test-Perms": "admin.models"})
|
||||
return client
|
||||
|
||||
|
||||
def test_create_endpoint_refreshes_registry(storage: SQLiteBackend) -> None:
|
||||
"""POST /api/admin/model-definitions bumps the in-process registry
|
||||
so newly-spawned coord sessions see the new alias immediately."""
|
||||
# Pre-existing alias (registry needs at least one row)
|
||||
_seed_model_def(storage, definition_id="m1", alias="local", model="m")
|
||||
registry = _make_registry(alias="local", model="m")
|
||||
client = _make_client(storage, registry)
|
||||
|
||||
resp = client.post(
|
||||
"/v1/api/admin/model-definitions",
|
||||
json={
|
||||
"alias": "fast",
|
||||
"model": "fast-model",
|
||||
"provider": "openai-compatible",
|
||||
"base_url": "http://localhost:9000/v1",
|
||||
"api_key": "sk-x",
|
||||
"context_window": 4096,
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert registry.has_alias("fast")
|
||||
assert registry.get_config("fast").model == "fast-model"
|
||||
|
||||
|
||||
def test_update_endpoint_refreshes_registry(storage: SQLiteBackend) -> None:
|
||||
"""PUT swaps the underlying model name behind a stable alias — the
|
||||
user's reported regression."""
|
||||
_seed_model_def(storage, definition_id="m1", alias="local", model="old-model")
|
||||
registry = _make_registry(alias="local", model="old-model")
|
||||
client = _make_client(storage, registry)
|
||||
|
||||
resp = client.put(
|
||||
"/v1/api/admin/model-definitions/m1",
|
||||
json={"model": "new-model"},
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert registry.get_config("local").model == "new-model"
|
||||
|
||||
|
||||
def test_update_endpoint_skips_refresh_on_empty_body(
|
||||
storage: SQLiteBackend, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""An empty PUT body must skip the registry refresh — the
|
||||
``if updates:`` gate exists because ``load_model_registry`` is
|
||||
non-trivial and a no-op refresh on every PUT would burn cycles
|
||||
rebuilding state that hasn't changed. Spy on the helper to lock
|
||||
the gate down: a regression that drops the conditional would
|
||||
register a call here and trip the assertion.
|
||||
"""
|
||||
from turnstone.console import server as server_module
|
||||
|
||||
_seed_model_def(storage, definition_id="m1", alias="local", model="locked-in")
|
||||
registry = _make_registry(alias="local", model="locked-in")
|
||||
client = _make_client(storage, registry)
|
||||
|
||||
calls: list[tuple[Any, Any]] = []
|
||||
|
||||
def _spy(app_state: Any, storage: Any) -> None:
|
||||
calls.append((app_state, storage))
|
||||
|
||||
monkeypatch.setattr(server_module, "_refresh_coord_registry", _spy)
|
||||
|
||||
resp = client.put("/v1/api/admin/model-definitions/m1", json={})
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert calls == [] # gate held: empty body did not trigger a refresh
|
||||
|
||||
|
||||
def test_create_rejects_invalid_api_surface(storage: SQLiteBackend) -> None:
|
||||
"""POST with a bogus server_compat.api_surface returns 400 rather than
|
||||
persisting a value that would make get_provider() raise on every later
|
||||
ChatSession init for the alias."""
|
||||
_seed_model_def(storage, definition_id="m1", alias="local", model="m")
|
||||
registry = _make_registry(alias="local", model="m")
|
||||
client = _make_client(storage, registry)
|
||||
|
||||
resp = client.post(
|
||||
"/v1/api/admin/model-definitions",
|
||||
json={
|
||||
"alias": "bad",
|
||||
"model": "x",
|
||||
"provider": "openai-compatible",
|
||||
"base_url": "http://localhost:9000/v1",
|
||||
"api_key": "sk-x",
|
||||
"capabilities": {"server_compat": {"api_surface": "BOGUS"}},
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 400, resp.text
|
||||
assert "api_surface" in resp.json()["error"]
|
||||
# And the alias is not persisted
|
||||
assert not registry.has_alias("bad")
|
||||
|
||||
|
||||
def test_create_rejects_non_canonical_api_surface(storage: SQLiteBackend) -> None:
|
||||
"""Strict validation: ' Responses ' / 'CHAT' don't round-trip through the
|
||||
admin <select>, so they're rejected even though they'd survive a
|
||||
case-insensitive membership check."""
|
||||
_seed_model_def(storage, definition_id="m1", alias="local", model="m")
|
||||
registry = _make_registry(alias="local", model="m")
|
||||
client = _make_client(storage, registry)
|
||||
|
||||
for bad in (" responses ", "RESPONSES", "Chat"):
|
||||
resp = client.post(
|
||||
"/v1/api/admin/model-definitions",
|
||||
json={
|
||||
"alias": "noncanon",
|
||||
"model": "x",
|
||||
"provider": "openai-compatible",
|
||||
"base_url": "http://localhost:9000/v1",
|
||||
"api_key": "sk-x",
|
||||
"capabilities": {"server_compat": {"api_surface": bad}},
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 400, f"{bad!r}: {resp.text}"
|
||||
|
||||
|
||||
def test_create_accepts_valid_api_surface(storage: SQLiteBackend) -> None:
|
||||
"""Canonical 'chat' / 'responses' / unset are all accepted and persisted."""
|
||||
_seed_model_def(storage, definition_id="m1", alias="local", model="m")
|
||||
registry = _make_registry(alias="local", model="m")
|
||||
client = _make_client(storage, registry)
|
||||
|
||||
resp = client.post(
|
||||
"/v1/api/admin/model-definitions",
|
||||
json={
|
||||
"alias": "responses-alias",
|
||||
"model": "x",
|
||||
"provider": "openai-compatible",
|
||||
"base_url": "http://localhost:9000/v1",
|
||||
"api_key": "sk-x",
|
||||
"capabilities": {"server_compat": {"api_surface": "responses"}},
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert registry.has_alias("responses-alias")
|
||||
|
||||
|
||||
def test_update_rejects_invalid_api_surface(storage: SQLiteBackend) -> None:
|
||||
"""PUT path also gates the validation, so an admin can't smuggle a bad
|
||||
value into an existing alias."""
|
||||
_seed_model_def(storage, definition_id="m1", alias="local", model="m")
|
||||
registry = _make_registry(alias="local", model="m")
|
||||
client = _make_client(storage, registry)
|
||||
|
||||
resp = client.put(
|
||||
"/v1/api/admin/model-definitions/m1",
|
||||
json={"capabilities": {"server_compat": {"api_surface": "junk"}}},
|
||||
)
|
||||
assert resp.status_code == 400, resp.text
|
||||
assert "api_surface" in resp.json()["error"]
|
||||
|
||||
|
||||
def test_delete_endpoint_refreshes_registry(storage: SQLiteBackend) -> None:
|
||||
"""DELETE drops the alias from the in-process registry too — a
|
||||
coord session that tried to resolve the deleted alias would
|
||||
otherwise hit a stale cached client."""
|
||||
_seed_model_def(storage, definition_id="m1", alias="local", model="m")
|
||||
_seed_model_def(storage, definition_id="m2", alias="extra", model="x")
|
||||
registry = _make_registry(alias="local", model="m", extras={"extra": "x"})
|
||||
client = _make_client(storage, registry)
|
||||
|
||||
resp = client.delete("/v1/api/admin/model-definitions/m2")
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert not registry.has_alias("extra")
|
||||
assert registry.has_alias("local") # default alias unaffected
|
||||
|
||||
|
||||
def test_reload_endpoint_refreshes_registry(
|
||||
storage: SQLiteBackend, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""The explicit reload button must refresh the console's own
|
||||
registry — until this PR it only fanned out to nodes."""
|
||||
_seed_model_def(storage, definition_id="m1", alias="local", model="initial")
|
||||
registry = _make_registry(alias="local", model="initial")
|
||||
client = _make_client(storage, registry)
|
||||
|
||||
# Bypass the CRUD endpoints to mimic an out-of-band DB change (e.g.
|
||||
# an operator psql session) and verify the explicit reload path
|
||||
# still pulls the change in.
|
||||
storage.update_model_definition("m1", model="reloaded-model")
|
||||
|
||||
# Stub the async cluster fan-out helpers — they require a fully-wired
|
||||
# collector / proxy_client which is orthogonal to the helper under test.
|
||||
async def _noop_publish(_request: Any) -> None:
|
||||
return None
|
||||
|
||||
async def _noop_notify(_request: Any) -> dict[str, Any]:
|
||||
return {}
|
||||
|
||||
monkeypatch.setattr("turnstone.console.server._publish_config_change", _noop_publish)
|
||||
monkeypatch.setattr("turnstone.console.server._notify_nodes_model_reload", _noop_notify)
|
||||
|
||||
resp = client.post("/v1/api/admin/model-definitions/reload")
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert registry.get_config("local").model == "reloaded-model"
|
||||
@@ -92,3 +92,71 @@ def test_tool_error_does_not_overwrite_approval_badge() -> None:
|
||||
"badge instead so the approval verdict stays visible alongside "
|
||||
"the error."
|
||||
)
|
||||
|
||||
|
||||
def test_replay_history_renders_content_before_tool_block() -> None:
|
||||
"""In ``replayHistory``'s ``role === "assistant"`` branch, the
|
||||
``msg.content`` render must precede the ``msg.tool_calls`` render.
|
||||
|
||||
Two reasons, both load-bearing:
|
||||
|
||||
1. **Structural** — the next loop iteration's ``role === "tool"``
|
||||
message anchors to ``lastToolBlock``. The tool-block branch sets
|
||||
that anchor; the content branch clears it. If content runs after
|
||||
the tool block, the clear silently drops the upcoming tool
|
||||
result. Pre-fix, every interactive tool result was missing from
|
||||
saved-workstream replays whenever the assistant turn carried
|
||||
both narration and tool calls (very common output shape).
|
||||
|
||||
2. **Visual** — the live SSE path renders content first
|
||||
(``stream_text`` streams before ``tool_info`` /
|
||||
``approve_request``), so replay should match.
|
||||
|
||||
The test pins the order via the offsets of the ``msg.content`` and
|
||||
``msg.tool_calls`` branch headers inside the function body."""
|
||||
body = _APP_JS.read_text(encoding="utf-8")
|
||||
start = body.index("Pane.prototype.replayHistory = function")
|
||||
end = body.index("Pane.prototype._attachRetryToLastAssistant", start)
|
||||
fn = body[start:end]
|
||||
# Locate the assistant branch and bound the search to its body —
|
||||
# the function also handles user / tool roles which would otherwise
|
||||
# confuse the offset comparison.
|
||||
asst_start = fn.index('msg.role === "assistant"')
|
||||
asst_end = fn.index('msg.role === "tool"', asst_start)
|
||||
asst = fn[asst_start:asst_end]
|
||||
content_idx = asst.index("if (msg.content)")
|
||||
tool_calls_idx = asst.index("if (msg.tool_calls && msg.tool_calls.length)")
|
||||
assert content_idx < tool_calls_idx, (
|
||||
"replayHistory must render msg.content BEFORE msg.tool_calls "
|
||||
"inside the assistant branch — otherwise the lastToolBlock "
|
||||
"anchor is clobbered before the next iteration's tool result "
|
||||
"can attach to it (and the visual order also drifts from the "
|
||||
"live SSE flow)."
|
||||
)
|
||||
|
||||
|
||||
def test_replay_history_renders_persisted_verdict_badge() -> None:
|
||||
"""Saved-workstream replays must paint the persisted intent verdict
|
||||
next to each tool div, using the same ``renderVerdictBadge`` helper
|
||||
the live ``showInlineToolBlock`` path uses. Pre-fix the audit trail
|
||||
was complete in storage (``intent_verdicts`` table) but never
|
||||
surfaced on replay — operators reviewing a saved workstream
|
||||
couldn't see what the heuristic / LLM judge thought of any tool
|
||||
call. This test pins the call site so a refactor that drops the
|
||||
decoration regresses the audit surface."""
|
||||
body = _APP_JS.read_text(encoding="utf-8")
|
||||
start = body.index("Pane.prototype.replayHistory = function")
|
||||
end = body.index("Pane.prototype._attachRetryToLastAssistant", start)
|
||||
fn = body[start:end]
|
||||
# Match a `renderVerdictBadge(<something>.verdict, ...)` call inside
|
||||
# the replay loop. Loose on whitespace + identifier so a future
|
||||
# rename of the iteration variable doesn't trip CI.
|
||||
badge_call_re = re.compile(
|
||||
r"renderVerdictBadge\(\s*\w+\.verdict\b",
|
||||
)
|
||||
assert badge_call_re.search(fn), (
|
||||
"replayHistory must call renderVerdictBadge(tc.verdict, ...) "
|
||||
"when a persisted verdict is attached to a tool_call entry — "
|
||||
"otherwise the audit-trail data persisted to intent_verdicts "
|
||||
"doesn't surface on saved-workstream replays."
|
||||
)
|
||||
|
||||
@@ -0,0 +1,270 @@
|
||||
"""Unit tests for :mod:`turnstone.core.child_source`.
|
||||
|
||||
Covers both strategies in isolation against fakes — no live collector,
|
||||
no live SessionManager. Adapter-level integration coverage continues to
|
||||
live in ``test_coordinator_adapter.py``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import time
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from turnstone.core.child_source import ClusterChildSource, SameNodeChildSource
|
||||
from turnstone.core.children_registry import ChildrenRegistry
|
||||
from turnstone.core.workstream import WorkstreamState
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import queue
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SameNodeChildSource
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class _FakeManager:
|
||||
"""Minimal SessionManager stand-in implementing the subscribe API."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.subscribers: list[Any] = []
|
||||
|
||||
def subscribe_to_state(self, callback: Any) -> None:
|
||||
self.subscribers.append(callback)
|
||||
|
||||
def unsubscribe_from_state(self, callback: Any) -> None:
|
||||
with contextlib.suppress(ValueError):
|
||||
self.subscribers.remove(callback)
|
||||
|
||||
def fire(self, ws_id: str, state: WorkstreamState) -> None:
|
||||
for cb in self.subscribers:
|
||||
cb(ws_id, state)
|
||||
|
||||
|
||||
class TestSameNodeChildSource:
|
||||
def test_start_subscribes_to_manager(self) -> None:
|
||||
mgr = _FakeManager()
|
||||
registry = ChildrenRegistry()
|
||||
src = SameNodeChildSource(mgr, registry)
|
||||
sink_calls: list[dict[str, Any]] = []
|
||||
src.start(sink=sink_calls.append)
|
||||
assert len(mgr.subscribers) == 1
|
||||
|
||||
def test_state_change_for_known_child_pushes_to_sink(self) -> None:
|
||||
mgr = _FakeManager()
|
||||
registry = ChildrenRegistry()
|
||||
registry.install("p1", object())
|
||||
registry.add_child("p1", "c1")
|
||||
src = SameNodeChildSource(mgr, registry)
|
||||
sink_calls: list[dict[str, Any]] = []
|
||||
src.start(sink=sink_calls.append)
|
||||
|
||||
mgr.fire("c1", WorkstreamState.RUNNING)
|
||||
|
||||
assert len(sink_calls) == 1
|
||||
ev = sink_calls[0]
|
||||
assert ev["type"] == "cluster_state"
|
||||
assert ev["ws_id"] == "c1"
|
||||
assert ev["state"] == "running"
|
||||
|
||||
def test_state_change_for_unknown_workstream_is_dropped(self) -> None:
|
||||
mgr = _FakeManager()
|
||||
registry = ChildrenRegistry()
|
||||
src = SameNodeChildSource(mgr, registry)
|
||||
sink_calls: list[dict[str, Any]] = []
|
||||
src.start(sink=sink_calls.append)
|
||||
|
||||
# No registry entry — pre-filter drops the event without
|
||||
# invoking the sink.
|
||||
mgr.fire("ws-unknown", WorkstreamState.IDLE)
|
||||
assert sink_calls == []
|
||||
|
||||
def test_shutdown_unsubscribes(self) -> None:
|
||||
mgr = _FakeManager()
|
||||
registry = ChildrenRegistry()
|
||||
src = SameNodeChildSource(mgr, registry)
|
||||
src.start(sink=lambda ev: None)
|
||||
assert len(mgr.subscribers) == 1
|
||||
src.shutdown()
|
||||
assert mgr.subscribers == []
|
||||
|
||||
def test_start_is_idempotent(self) -> None:
|
||||
mgr = _FakeManager()
|
||||
registry = ChildrenRegistry()
|
||||
src = SameNodeChildSource(mgr, registry)
|
||||
src.start(sink=lambda ev: None)
|
||||
src.start(sink=lambda ev: None)
|
||||
# Second start is a no-op; only one subscription.
|
||||
assert len(mgr.subscribers) == 1
|
||||
|
||||
def test_sink_exception_does_not_propagate(self) -> None:
|
||||
mgr = _FakeManager()
|
||||
registry = ChildrenRegistry()
|
||||
registry.install("p1", object())
|
||||
registry.add_child("p1", "c1")
|
||||
src = SameNodeChildSource(mgr, registry)
|
||||
|
||||
def bad_sink(ev: dict[str, Any]) -> None:
|
||||
raise RuntimeError("sink boom")
|
||||
|
||||
src.start(sink=bad_sink)
|
||||
# Should not raise — the strategy catches sink failures and logs.
|
||||
mgr.fire("c1", WorkstreamState.RUNNING)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ClusterChildSource
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class _FakeCollector:
|
||||
"""Minimal ClusterCollector stand-in providing the listener API."""
|
||||
|
||||
def __init__(self, snapshot: dict[str, Any] | None = None) -> None:
|
||||
self._snapshot = snapshot or {"nodes": []}
|
||||
self.queues: list[queue.Queue[dict[str, Any]]] = []
|
||||
self.unregistered: list[queue.Queue[dict[str, Any]]] = []
|
||||
|
||||
def get_snapshot_and_register(self, q: queue.Queue[dict[str, Any]]) -> dict[str, Any]:
|
||||
self.queues.append(q)
|
||||
return self._snapshot
|
||||
|
||||
def unregister_listener(self, q: queue.Queue[dict[str, Any]]) -> None:
|
||||
self.unregistered.append(q)
|
||||
|
||||
def emit(self, event: dict[str, Any]) -> None:
|
||||
"""Push an event to all registered listener queues."""
|
||||
for q in self.queues:
|
||||
q.put(event)
|
||||
|
||||
|
||||
class TestClusterChildSource:
|
||||
def test_start_subscribes_to_collector(self) -> None:
|
||||
coll = _FakeCollector()
|
||||
registry = ChildrenRegistry()
|
||||
src = ClusterChildSource(
|
||||
collector=coll,
|
||||
registry=registry,
|
||||
parents_provider=list,
|
||||
)
|
||||
try:
|
||||
src.start(sink=lambda ev: None)
|
||||
assert len(coll.queues) == 1
|
||||
finally:
|
||||
src.shutdown()
|
||||
|
||||
def test_start_primes_registry_from_snapshot(self) -> None:
|
||||
snapshot = {
|
||||
"nodes": [
|
||||
{
|
||||
"workstreams": [
|
||||
{"id": "c1", "parent_ws_id": "p1"},
|
||||
{"id": "c2", "parent_ws_id": "p1"},
|
||||
# Unknown parent — dropped
|
||||
{"id": "x", "parent_ws_id": "p-unknown"},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
coll = _FakeCollector(snapshot)
|
||||
registry = ChildrenRegistry()
|
||||
registry.install("p1", object())
|
||||
src = ClusterChildSource(
|
||||
collector=coll,
|
||||
registry=registry,
|
||||
parents_provider=lambda: ["p1"],
|
||||
)
|
||||
try:
|
||||
src.start(sink=lambda ev: None)
|
||||
assert set(registry.children_of("p1")) == {"c1", "c2"}
|
||||
assert registry.parent_for("x") is None
|
||||
finally:
|
||||
src.shutdown()
|
||||
|
||||
def test_event_dispatched_to_sink(self) -> None:
|
||||
coll = _FakeCollector()
|
||||
registry = ChildrenRegistry()
|
||||
src = ClusterChildSource(
|
||||
collector=coll,
|
||||
registry=registry,
|
||||
parents_provider=list,
|
||||
)
|
||||
sink_calls: list[dict[str, Any]] = []
|
||||
|
||||
try:
|
||||
src.start(sink=sink_calls.append)
|
||||
coll.emit({"type": "cluster_state", "ws_id": "c1", "state": "running"})
|
||||
# Daemon thread loop has 1.0s queue timeout; poll briefly.
|
||||
for _ in range(20):
|
||||
if sink_calls:
|
||||
break
|
||||
time.sleep(0.05)
|
||||
assert len(sink_calls) == 1
|
||||
assert sink_calls[0]["ws_id"] == "c1"
|
||||
finally:
|
||||
src.shutdown()
|
||||
|
||||
def test_shutdown_unregisters_and_joins_thread(self) -> None:
|
||||
coll = _FakeCollector()
|
||||
registry = ChildrenRegistry()
|
||||
src = ClusterChildSource(
|
||||
collector=coll,
|
||||
registry=registry,
|
||||
parents_provider=list,
|
||||
)
|
||||
src.start(sink=lambda ev: None)
|
||||
src.shutdown()
|
||||
assert coll.unregistered == coll.queues
|
||||
# Second shutdown is a no-op (idempotent).
|
||||
src.shutdown()
|
||||
|
||||
def test_start_is_idempotent(self) -> None:
|
||||
coll = _FakeCollector()
|
||||
registry = ChildrenRegistry()
|
||||
src = ClusterChildSource(
|
||||
collector=coll,
|
||||
registry=registry,
|
||||
parents_provider=list,
|
||||
)
|
||||
try:
|
||||
src.start(sink=lambda ev: None)
|
||||
src.start(sink=lambda ev: None)
|
||||
assert len(coll.queues) == 1
|
||||
finally:
|
||||
src.shutdown()
|
||||
|
||||
def test_sink_exception_does_not_kill_thread(self) -> None:
|
||||
coll = _FakeCollector()
|
||||
registry = ChildrenRegistry()
|
||||
src = ClusterChildSource(
|
||||
collector=coll,
|
||||
registry=registry,
|
||||
parents_provider=list,
|
||||
)
|
||||
survived_calls: list[dict[str, Any]] = []
|
||||
call_count = [0]
|
||||
|
||||
def flaky_sink(ev: dict[str, Any]) -> None:
|
||||
call_count[0] += 1
|
||||
if call_count[0] == 1:
|
||||
raise RuntimeError("first one boom")
|
||||
survived_calls.append(ev)
|
||||
|
||||
try:
|
||||
src.start(sink=flaky_sink)
|
||||
coll.emit({"type": "cluster_state", "ws_id": "c1", "state": "x"})
|
||||
coll.emit({"type": "cluster_state", "ws_id": "c2", "state": "y"})
|
||||
for _ in range(40):
|
||||
if survived_calls:
|
||||
break
|
||||
time.sleep(0.05)
|
||||
assert len(survived_calls) == 1
|
||||
assert survived_calls[0]["ws_id"] == "c2"
|
||||
finally:
|
||||
src.shutdown()
|
||||
|
||||
|
||||
# Multi-subscriber observer tests for ``SessionManager.subscribe_to_state``
|
||||
# / ``unsubscribe_from_state`` live in ``test_session_manager.py`` where
|
||||
# the proper FakeAdapter / FakeStorage construction helpers already exist.
|
||||
@@ -0,0 +1,239 @@
|
||||
"""Unit tests for :class:`turnstone.core.children_registry.ChildrenRegistry`.
|
||||
|
||||
The registry was lifted from ``CoordinatorAdapter`` in Stage 3 Step 1.
|
||||
Adapter-level coverage for the integrated behavior already lives in
|
||||
``test_coordinator_adapter.py``; this file pins the data structure
|
||||
invariants in isolation so the registry can be reused by future
|
||||
``ChildSource`` strategies (Step 2) without re-deriving the behavior
|
||||
from the adapter test surface.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
|
||||
import pytest
|
||||
|
||||
from turnstone.core.children_registry import ChildrenRegistry
|
||||
|
||||
|
||||
class _Sentinel:
|
||||
"""Lightweight UI stand-in; identity-comparable, no behavior."""
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def registry() -> ChildrenRegistry:
|
||||
return ChildrenRegistry()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# install / uninstall
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestInstallUninstall:
|
||||
def test_install_seeds_empty_child_set_and_presence(self, registry: ChildrenRegistry) -> None:
|
||||
ui = _Sentinel()
|
||||
registry.install("p1", ui)
|
||||
assert registry.children_of("p1") == []
|
||||
assert registry.ui_for("p1") is ui
|
||||
assert registry.parents() == ["p1"]
|
||||
|
||||
def test_install_is_idempotent_repoints_ui_keeps_children(
|
||||
self, registry: ChildrenRegistry
|
||||
) -> None:
|
||||
ui_a = _Sentinel()
|
||||
ui_b = _Sentinel()
|
||||
registry.install("p1", ui_a)
|
||||
registry.merge_children("p1", ["c1", "c2"])
|
||||
registry.install("p1", ui_b)
|
||||
assert registry.ui_for("p1") is ui_b
|
||||
assert set(registry.children_of("p1")) == {"c1", "c2"}
|
||||
|
||||
def test_uninstall_clears_forward_reverse_and_presence(
|
||||
self, registry: ChildrenRegistry
|
||||
) -> None:
|
||||
ui = _Sentinel()
|
||||
registry.install("p1", ui)
|
||||
registry.merge_children("p1", ["c1", "c2"])
|
||||
registry.uninstall("p1")
|
||||
assert registry.children_of("p1") == []
|
||||
assert registry.ui_for("p1") is None
|
||||
assert registry.parents() == []
|
||||
assert registry.parent_for("c1") is None
|
||||
assert registry.parent_for("c2") is None
|
||||
|
||||
def test_uninstall_unknown_parent_is_noop(self, registry: ChildrenRegistry) -> None:
|
||||
registry.uninstall("never-installed") # must not raise
|
||||
|
||||
def test_uninstall_does_not_clobber_other_parents(self, registry: ChildrenRegistry) -> None:
|
||||
registry.install("p1", _Sentinel())
|
||||
registry.install("p2", _Sentinel())
|
||||
registry.merge_children("p1", ["c1"])
|
||||
registry.merge_children("p2", ["c2"])
|
||||
registry.uninstall("p1")
|
||||
assert registry.parent_for("c1") is None
|
||||
assert registry.parent_for("c2") == "p2"
|
||||
assert registry.parents() == ["p2"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# add_child — atomic check-and-route
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestAddChild:
|
||||
def test_add_child_returns_ui_on_success(self, registry: ChildrenRegistry) -> None:
|
||||
ui = _Sentinel()
|
||||
registry.install("p1", ui)
|
||||
assert registry.add_child("p1", "c1") is ui
|
||||
assert registry.parent_for("c1") == "p1"
|
||||
assert registry.children_of("p1") == ["c1"]
|
||||
|
||||
def test_add_child_returns_none_when_parent_not_installed(
|
||||
self, registry: ChildrenRegistry
|
||||
) -> None:
|
||||
assert registry.add_child("absent", "c1") is None
|
||||
assert registry.parent_for("c1") is None
|
||||
|
||||
def test_add_child_returns_none_on_duplicate(self, registry: ChildrenRegistry) -> None:
|
||||
ui = _Sentinel()
|
||||
registry.install("p1", ui)
|
||||
assert registry.add_child("p1", "c1") is ui
|
||||
# second add for same child returns None — caller must not
|
||||
# double-dispatch.
|
||||
assert registry.add_child("p1", "c1") is None
|
||||
assert registry.children_of("p1") == ["c1"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# merge_children — bulk seeding
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestMergeChildren:
|
||||
def test_merge_seeds_forward_and_reverse(self, registry: ChildrenRegistry) -> None:
|
||||
registry.merge_children("p1", ["c1", "c2", "c3"])
|
||||
assert set(registry.children_of("p1")) == {"c1", "c2", "c3"}
|
||||
for cid in ("c1", "c2", "c3"):
|
||||
assert registry.parent_for(cid) == "p1"
|
||||
|
||||
def test_merge_is_idempotent(self, registry: ChildrenRegistry) -> None:
|
||||
registry.merge_children("p1", ["c1"])
|
||||
registry.merge_children("p1", ["c1"])
|
||||
assert registry.children_of("p1") == ["c1"]
|
||||
|
||||
def test_merge_skips_empty_or_falsy_ids(self, registry: ChildrenRegistry) -> None:
|
||||
registry.merge_children("p1", ["", "c1", "", "c2"])
|
||||
assert set(registry.children_of("p1")) == {"c1", "c2"}
|
||||
|
||||
def test_merge_does_not_require_install(self, registry: ChildrenRegistry) -> None:
|
||||
# Snapshot-priming may run before the parent's install fires —
|
||||
# the merge still seeds the forward set so the install picks
|
||||
# the children up. (Storage-seeded rebuild relies on this.)
|
||||
registry.merge_children("p1", ["c1"])
|
||||
assert registry.children_of("p1") == ["c1"]
|
||||
# ui_for is still None because install hasn't run
|
||||
assert registry.ui_for("p1") is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Lookups — return copies, not live refs
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestLookups:
|
||||
def test_children_of_returns_copy(self, registry: ChildrenRegistry) -> None:
|
||||
registry.install("p1", _Sentinel())
|
||||
registry.merge_children("p1", ["c1", "c2"])
|
||||
snap = registry.children_of("p1")
|
||||
snap.append("c3-injected")
|
||||
assert "c3-injected" not in registry.children_of("p1")
|
||||
|
||||
def test_children_of_unknown_parent_returns_empty(self, registry: ChildrenRegistry) -> None:
|
||||
assert registry.children_of("absent") == []
|
||||
|
||||
def test_parent_for_unknown_child_returns_none(self, registry: ChildrenRegistry) -> None:
|
||||
assert registry.parent_for("absent") is None
|
||||
|
||||
def test_parents_returns_copy(self, registry: ChildrenRegistry) -> None:
|
||||
registry.install("p1", _Sentinel())
|
||||
snap = registry.parents()
|
||||
snap.append("p2-injected")
|
||||
assert "p2-injected" not in registry.parents()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Concurrency — concurrent add_child must not exceed the unique-set
|
||||
# invariant or leave a half-installed reverse-index entry.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestConcurrency:
|
||||
def test_concurrent_add_child_returns_ui_exactly_once_per_unique(
|
||||
self, registry: ChildrenRegistry
|
||||
) -> None:
|
||||
ui = _Sentinel()
|
||||
registry.install("p1", ui)
|
||||
results: list[object] = []
|
||||
results_lock = threading.Lock()
|
||||
|
||||
def attempt_add(child_id: str) -> None:
|
||||
r = registry.add_child("p1", child_id)
|
||||
with results_lock:
|
||||
results.append(r)
|
||||
|
||||
threads = [threading.Thread(target=attempt_add, args=("c1",)) for _ in range(20)]
|
||||
for t in threads:
|
||||
t.start()
|
||||
for t in threads:
|
||||
t.join()
|
||||
|
||||
# Exactly one thread sees the UI; the remaining 19 see None
|
||||
# (duplicate). The forward + reverse indexes carry exactly one
|
||||
# entry for c1.
|
||||
successes = [r for r in results if r is ui]
|
||||
nones = [r for r in results if r is None]
|
||||
assert len(successes) == 1
|
||||
assert len(nones) == 19
|
||||
assert registry.children_of("p1") == ["c1"]
|
||||
assert registry.parent_for("c1") == "p1"
|
||||
|
||||
def test_concurrent_install_and_add_child_no_resurrect(
|
||||
self, registry: ChildrenRegistry
|
||||
) -> None:
|
||||
# add_child racing with uninstall: either lands first (registry
|
||||
# populated) or the parent is gone (returns None). Must NOT
|
||||
# leave a forward-set entry without presence — that would be
|
||||
# the "resurrected after close" leak the locked dispatch path
|
||||
# was guarding against.
|
||||
ui = _Sentinel()
|
||||
registry.install("p1", ui)
|
||||
|
||||
outcomes: list[object] = []
|
||||
|
||||
def adder() -> None:
|
||||
outcomes.append(registry.add_child("p1", "c1"))
|
||||
|
||||
def uninstaller() -> None:
|
||||
registry.uninstall("p1")
|
||||
|
||||
threads = [
|
||||
threading.Thread(target=adder),
|
||||
threading.Thread(target=uninstaller),
|
||||
]
|
||||
for t in threads:
|
||||
t.start()
|
||||
for t in threads:
|
||||
t.join()
|
||||
|
||||
# If add_child landed first: c1 is in the forward set, then
|
||||
# uninstall clears everything. End state: nothing.
|
||||
# If uninstall landed first: add_child sees no presence,
|
||||
# returns None, no entry added. End state: nothing.
|
||||
# Either way, the leak invariant holds: child set is empty or
|
||||
# parent is gone, never "child set populated but no presence".
|
||||
children = registry.children_of("p1")
|
||||
ui_present = registry.ui_for("p1") is not None
|
||||
if children:
|
||||
assert ui_present, "registry leaked: children set without presence"
|
||||
@@ -30,9 +30,25 @@ def _full_hdr() -> dict[str, str]:
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _isolate_metrics(monkeypatch):
|
||||
"""Swap ``turnstone.server._metrics`` for a fresh collector
|
||||
per-test, with auto-restore.
|
||||
|
||||
Bare ``srv_mod._metrics = MetricsCollector()`` (the prior
|
||||
pattern) leaks into any test file that already bound the name
|
||||
via ``from turnstone.server import _metrics`` at import time —
|
||||
those tests' patches then operate on a different instance from
|
||||
the one the live ``_publish_models_metadata`` reads, and the
|
||||
monkeypatch silently no-ops. ``monkeypatch.setattr`` restores
|
||||
after the test, so the leak is contained.
|
||||
"""
|
||||
fresh = MetricsCollector()
|
||||
fresh.model = "test-model"
|
||||
monkeypatch.setattr(srv_mod, "_metrics", fresh)
|
||||
|
||||
|
||||
def _make_app(storage: Any) -> TestClient:
|
||||
srv_mod._metrics = MetricsCollector()
|
||||
srv_mod._metrics.model = "test-model"
|
||||
mock_session = MagicMock()
|
||||
mock_ws = MagicMock()
|
||||
mock_ws.id = "ws-target"
|
||||
|
||||
+279
-19
@@ -314,6 +314,46 @@ class TestCollectorSnapshot:
|
||||
assert event["ws_id"] == "ws1"
|
||||
assert event["state"] == "running"
|
||||
|
||||
def test_apply_snapshot_state_change_does_not_carry_pending_approval_detail(self):
|
||||
"""Stage 3 cleanup — the snapshot-resync cluster_state event no
|
||||
longer piggybacks ``pending_approval_detail`` (the field is
|
||||
gone from cluster_state entirely). On reconnect the browser's
|
||||
bulk fetch — triggered by the ``activity_state="approval"``
|
||||
transition in the reducer — pulls the items directly from
|
||||
``ui.serialize_pending_approval_detail()`` via the dashboard
|
||||
endpoint."""
|
||||
c = _make_collector()
|
||||
c._nodes["node-a"] = NodeSnapshot(
|
||||
node_id="node-a",
|
||||
server_url="http://a:8080",
|
||||
workstreams={"ws1": {"id": "ws1", "name": "same", "state": "idle"}},
|
||||
)
|
||||
q: queue.Queue[dict] = queue.Queue()
|
||||
c.register_listener(q)
|
||||
|
||||
c._apply_snapshot(
|
||||
"node-a",
|
||||
{
|
||||
"type": "node_snapshot",
|
||||
"node_id": "node-a",
|
||||
"workstreams": [
|
||||
{
|
||||
"id": "ws1",
|
||||
"name": "same",
|
||||
"state": "running",
|
||||
"activity_state": "approval",
|
||||
}
|
||||
],
|
||||
"health": {},
|
||||
"aggregate": {},
|
||||
},
|
||||
)
|
||||
|
||||
event = q.get_nowait()
|
||||
assert event["type"] == "cluster_state"
|
||||
assert event["activity_state"] == "approval"
|
||||
assert "pending_approval_detail" not in event
|
||||
|
||||
def test_apply_snapshot_skips_empty_id_workstream(self):
|
||||
c = _make_collector()
|
||||
c._nodes["node-a"] = NodeSnapshot(node_id="node-a", server_url="http://a:8080")
|
||||
@@ -359,6 +399,36 @@ class TestCollectorDelta:
|
||||
# Verify in-memory state was updated
|
||||
assert c._nodes["node-a"].workstreams["ws1"]["state"] == "running"
|
||||
|
||||
def test_apply_delta_ws_state_does_not_carry_pending_approval_detail(self):
|
||||
"""Stage 3 cleanup — ``cluster_state`` no longer carries the
|
||||
``pending_approval_detail`` piggyback. Approval items now arrive
|
||||
via bulk fetch on activity_state transition; verdicts via the
|
||||
explicit ``intent_verdict`` event class. Symmetric event flow,
|
||||
no piggyback to dedupe against."""
|
||||
c = _make_collector()
|
||||
c._nodes["node-a"] = NodeSnapshot(
|
||||
node_id="node-a",
|
||||
server_url="http://a:8080",
|
||||
workstreams={"ws1": {"id": "ws1", "name": "test", "state": "idle"}},
|
||||
)
|
||||
q: queue.Queue[dict] = queue.Queue()
|
||||
c.register_listener(q)
|
||||
|
||||
c._apply_delta(
|
||||
"node-a",
|
||||
{
|
||||
"type": "ws_state",
|
||||
"ws_id": "ws1",
|
||||
"state": "running",
|
||||
"activity_state": "approval",
|
||||
},
|
||||
)
|
||||
|
||||
event = q.get_nowait()
|
||||
assert event["type"] == "cluster_state"
|
||||
assert event["activity_state"] == "approval"
|
||||
assert "pending_approval_detail" not in event
|
||||
|
||||
def test_apply_delta_ws_created(self):
|
||||
c = _make_collector()
|
||||
c._nodes["node-a"] = NodeSnapshot(node_id="node-a", server_url="http://a:8080")
|
||||
@@ -404,6 +474,139 @@ class TestCollectorDelta:
|
||||
assert event["name"] == "new-name"
|
||||
assert c._nodes["node-a"].workstreams["ws1"]["name"] == "new-name"
|
||||
|
||||
def test_apply_delta_intent_verdict_forwards_verbatim(self):
|
||||
"""Stage 3 Step 5 — node-emitted intent_verdict events flow
|
||||
through _apply_delta to cluster fan-out so coord adapters can
|
||||
re-emit as child_ws_intent_verdict on the parent's SSE."""
|
||||
c = _make_collector()
|
||||
c._nodes["node-a"] = NodeSnapshot(
|
||||
node_id="node-a",
|
||||
server_url="http://a:8080",
|
||||
workstreams={"ws1": {"id": "ws1", "name": "test", "state": "idle"}},
|
||||
)
|
||||
q: queue.Queue[dict] = queue.Queue()
|
||||
c.register_listener(q)
|
||||
|
||||
verdict = {
|
||||
"call_id": "c1",
|
||||
"risk_level": "low",
|
||||
"confidence": 0.9,
|
||||
"recommendation": "approve",
|
||||
}
|
||||
c._apply_delta(
|
||||
"node-a",
|
||||
{"type": "intent_verdict", "ws_id": "ws1", "verdict": verdict},
|
||||
)
|
||||
|
||||
event = q.get_nowait()
|
||||
assert event["type"] == "intent_verdict"
|
||||
assert event["ws_id"] == "ws1"
|
||||
assert event["node_id"] == "node-a"
|
||||
assert event["verdict"] == verdict
|
||||
|
||||
def test_apply_delta_intent_verdict_drops_when_ws_id_missing(self):
|
||||
c = _make_collector()
|
||||
c._nodes["node-a"] = NodeSnapshot(node_id="node-a", server_url="http://a:8080")
|
||||
q: queue.Queue[dict] = queue.Queue()
|
||||
c.register_listener(q)
|
||||
|
||||
c._apply_delta("node-a", {"type": "intent_verdict", "verdict": {}})
|
||||
|
||||
assert q.empty()
|
||||
|
||||
def test_apply_delta_approval_resolved_forwards_verbatim(self):
|
||||
"""Stage 3 Step 5 — paired with intent_verdict; clears the
|
||||
coord tree's pending-approval pill in lockstep with the
|
||||
actual decision rather than waiting for the state-change
|
||||
piggyback."""
|
||||
c = _make_collector()
|
||||
c._nodes["node-a"] = NodeSnapshot(
|
||||
node_id="node-a",
|
||||
server_url="http://a:8080",
|
||||
workstreams={"ws1": {"id": "ws1", "name": "test", "state": "idle"}},
|
||||
)
|
||||
q: queue.Queue[dict] = queue.Queue()
|
||||
c.register_listener(q)
|
||||
|
||||
c._apply_delta(
|
||||
"node-a",
|
||||
{
|
||||
"type": "approval_resolved",
|
||||
"ws_id": "ws1",
|
||||
"approved": True,
|
||||
"feedback": "lgtm",
|
||||
"always": False,
|
||||
},
|
||||
)
|
||||
|
||||
event = q.get_nowait()
|
||||
assert event["type"] == "approval_resolved"
|
||||
assert event["ws_id"] == "ws1"
|
||||
assert event["node_id"] == "node-a"
|
||||
assert event["approved"] is True
|
||||
assert event["feedback"] == "lgtm"
|
||||
assert event["always"] is False
|
||||
|
||||
def test_apply_delta_approve_request_forwards_detail(self):
|
||||
"""Push path for the initial approval items — eliminates the
|
||||
bulk-fetch race that left the coord row stuck on a loading
|
||||
placeholder when the bulk fetch landed in the gap between
|
||||
_emit_state(ATTENTION) and approve_tools setting _pending_approval."""
|
||||
c = _make_collector()
|
||||
c._nodes["node-a"] = NodeSnapshot(
|
||||
node_id="node-a",
|
||||
server_url="http://a:8080",
|
||||
workstreams={"ws1": {"id": "ws1", "name": "test", "state": "idle"}},
|
||||
)
|
||||
q: queue.Queue[dict] = queue.Queue()
|
||||
c.register_listener(q)
|
||||
|
||||
detail = {
|
||||
"type": "approve_request",
|
||||
"items": [{"call_id": "c1", "header": "tool x"}],
|
||||
"judge_pending": True,
|
||||
}
|
||||
c._apply_delta(
|
||||
"node-a",
|
||||
{"type": "approve_request", "ws_id": "ws1", "detail": detail},
|
||||
)
|
||||
|
||||
event = q.get_nowait()
|
||||
assert event["type"] == "approve_request"
|
||||
assert event["ws_id"] == "ws1"
|
||||
assert event["node_id"] == "node-a"
|
||||
assert event["detail"] == detail
|
||||
|
||||
def test_apply_delta_approve_request_drops_when_ws_id_missing(self):
|
||||
c = _make_collector()
|
||||
c._nodes["node-a"] = NodeSnapshot(node_id="node-a", server_url="http://a:8080")
|
||||
q: queue.Queue[dict] = queue.Queue()
|
||||
c.register_listener(q)
|
||||
|
||||
c._apply_delta("node-a", {"type": "approve_request", "detail": {}})
|
||||
|
||||
assert q.empty()
|
||||
|
||||
def test_apply_delta_approval_resolved_coerces_missing_fields(self):
|
||||
"""Defensive: ``approved`` / ``always`` / ``feedback`` may be
|
||||
omitted by older nodes mid-rolling-upgrade; collector coerces
|
||||
to safe defaults."""
|
||||
c = _make_collector()
|
||||
c._nodes["node-a"] = NodeSnapshot(
|
||||
node_id="node-a",
|
||||
server_url="http://a:8080",
|
||||
workstreams={"ws1": {"id": "ws1", "name": "test", "state": "idle"}},
|
||||
)
|
||||
q: queue.Queue[dict] = queue.Queue()
|
||||
c.register_listener(q)
|
||||
|
||||
c._apply_delta("node-a", {"type": "approval_resolved", "ws_id": "ws1"})
|
||||
|
||||
event = q.get_nowait()
|
||||
assert event["approved"] is False
|
||||
assert event["feedback"] == ""
|
||||
assert event["always"] is False
|
||||
|
||||
def test_apply_delta_health_changed(self):
|
||||
c = _make_collector()
|
||||
c._nodes["node-a"] = NodeSnapshot(
|
||||
@@ -1309,11 +1512,62 @@ class TestProxyRewriting:
|
||||
assert "window.fetch" in _JS_PROXY_SHIM
|
||||
assert "window.EventSource" in _JS_PROXY_SHIM
|
||||
|
||||
def test_console_banner_contains_placeholder(self):
|
||||
from turnstone.console.server import _CONSOLE_BANNER_TEMPLATE
|
||||
def test_js_shim_carries_node_id_placeholder(self):
|
||||
"""The picker reads the current node_id from the shim's _nodeId
|
||||
closure variable; the placeholder must be present and substitutable."""
|
||||
from turnstone.console.server import _JS_PROXY_SHIM
|
||||
|
||||
assert "NODE_ID_PLACEHOLDER" in _CONSOLE_BANNER_TEMPLATE
|
||||
assert "Console" in _CONSOLE_BANNER_TEMPLATE
|
||||
assert "NODE_ID_PLACEHOLDER" in _JS_PROXY_SHIM
|
||||
replaced = _JS_PROXY_SHIM.replace("NODE_ID_PLACEHOLDER", "node-a")
|
||||
assert "node-a" in replaced
|
||||
assert "NODE_ID_PLACEHOLDER" not in replaced
|
||||
|
||||
def test_js_shim_includes_picker_pieces(self):
|
||||
"""Picker logic ships in the same IIFE as the prefix shim — verify
|
||||
the moving parts are present so a future refactor doesn't silently
|
||||
drop them. /v1/api/cluster/nodes is the lazy-fetch target;
|
||||
#ui-header is the DOM anchor; console-node-pill is the trigger
|
||||
class; ws-tab-dropdown is the menu shell we share with the
|
||||
workstream chevron menu (style + behaviour parity); ArrowDown is
|
||||
the keyboard-nav primitive that disambiguates this from a plain
|
||||
click-only menu."""
|
||||
from turnstone.console.server import _JS_PROXY_SHIM
|
||||
|
||||
# limit=1000 matches the collector's hard cap; without it the
|
||||
# picker would silently drop nodes past the 100-default in
|
||||
# clusters with >100 nodes.
|
||||
assert "/v1/api/cluster/nodes?limit=1000" in _JS_PROXY_SHIM
|
||||
assert "ui-header" in _JS_PROXY_SHIM
|
||||
assert "console-node-pill" in _JS_PROXY_SHIM
|
||||
assert "ws-tab-dropdown" in _JS_PROXY_SHIM
|
||||
assert "ArrowDown" in _JS_PROXY_SHIM
|
||||
assert "DOMContentLoaded" in _JS_PROXY_SHIM
|
||||
|
||||
def test_proxy_style_drops_banner_styles(self):
|
||||
"""The legacy banner CSS classes (.console-banner, .ts-header-back-link
|
||||
offsets, .dashboard-overlay top:32px hack) should be gone — the new
|
||||
picker lives inside #ui-header and doesn't need overlay offsets."""
|
||||
from turnstone.console.server import _CONSOLE_PROXY_STYLE
|
||||
|
||||
assert ".console-banner" not in _CONSOLE_PROXY_STYLE
|
||||
assert "dashboard-overlay" not in _CONSOLE_PROXY_STYLE
|
||||
assert ".console-node-pill" in _CONSOLE_PROXY_STYLE
|
||||
assert ".console-node-menu" in _CONSOLE_PROXY_STYLE
|
||||
|
||||
def test_proxy_style_uses_canonical_degraded_color(self):
|
||||
"""Degraded health dot must use --accent (the canonical "needs
|
||||
attention" token used by the cluster-overview node table at
|
||||
console/static/style.css:548) and not --yellow. Yellow is reserved
|
||||
for the dash-state attention dot, a stronger signal."""
|
||||
from turnstone.console.server import _CONSOLE_PROXY_STYLE
|
||||
|
||||
assert "console-node-menu-item-dot--degraded" in _CONSOLE_PROXY_STYLE
|
||||
# The degraded rule sits on its own line; assert it uses --accent
|
||||
# by checking the CSS substring has --accent and not --yellow.
|
||||
idx = _CONSOLE_PROXY_STYLE.find("console-node-menu-item-dot--degraded")
|
||||
rule = _CONSOLE_PROXY_STYLE[idx : idx + 200]
|
||||
assert "var(--accent)" in rule
|
||||
assert "var(--yellow)" not in rule
|
||||
|
||||
def test_html_rewriting_changes_static_paths(self):
|
||||
"""Simulate the proxy_index rewriting logic."""
|
||||
@@ -1330,16 +1584,24 @@ class TestProxyRewriting:
|
||||
assert 'href="/static/' not in rewritten
|
||||
assert 'src="/static/' not in rewritten
|
||||
|
||||
def test_banner_injection_after_body(self):
|
||||
"""Simulate the banner injection logic."""
|
||||
from turnstone.console.server import _CONSOLE_BANNER_TEMPLATE
|
||||
def test_shim_injection_after_body(self):
|
||||
"""Simulate the proxy shim injection — the shim ships the node-id
|
||||
and prefix as JS literals and renders the picker at runtime, so
|
||||
we assert the substituted JS literals land in the page."""
|
||||
from turnstone.console.server import _CONSOLE_PROXY_STYLE, _JS_PROXY_SHIM
|
||||
|
||||
sample_html = "<html><body><div>content</div></body></html>"
|
||||
banner = _CONSOLE_BANNER_TEMPLATE.replace("NODE_ID_PLACEHOLDER", "node-a")
|
||||
result = sample_html.replace("<body>", "<body>" + banner, 1)
|
||||
assert "node-a" in result
|
||||
assert "Console" in result
|
||||
assert result.startswith("<html><body><div")
|
||||
prefix = "/node/node-a"
|
||||
shim_js = _JS_PROXY_SHIM.replace('"PREFIX_PLACEHOLDER"', json.dumps(prefix)).replace(
|
||||
'"NODE_ID_PLACEHOLDER"', json.dumps("node-a")
|
||||
)
|
||||
injection = _CONSOLE_PROXY_STYLE + "<script>" + shim_js + "</script>"
|
||||
result = sample_html.replace("<body>", "<body>" + injection, 1)
|
||||
assert '"node-a"' in result
|
||||
assert '"/node/node-a"' in result
|
||||
assert "PREFIX_PLACEHOLDER" not in result
|
||||
assert "NODE_ID_PLACEHOLDER" not in result
|
||||
assert result.startswith("<html><body><style>")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -1574,17 +1836,15 @@ class TestProxySharedStatic:
|
||||
def test_proxy_shim_injected_in_html(self):
|
||||
"""Verify shim is injected as inline script in proxied HTML."""
|
||||
|
||||
from turnstone.console.server import _CONSOLE_BANNER_TEMPLATE, _JS_PROXY_SHIM
|
||||
from turnstone.console.server import _JS_PROXY_SHIM
|
||||
|
||||
sample_html = "<html><body><div>content</div></body></html>"
|
||||
prefix = "/node/test-node"
|
||||
banner = _CONSOLE_BANNER_TEMPLATE.replace("NODE_ID_PLACEHOLDER", "test-node")
|
||||
shim = (
|
||||
"<script>"
|
||||
+ _JS_PROXY_SHIM.replace('"PREFIX_PLACEHOLDER"', json.dumps(prefix))
|
||||
+ "</script>"
|
||||
shim_js = _JS_PROXY_SHIM.replace('"PREFIX_PLACEHOLDER"', json.dumps(prefix)).replace(
|
||||
'"NODE_ID_PLACEHOLDER"', json.dumps("test-node")
|
||||
)
|
||||
result = sample_html.replace("<body>", "<body>" + banner + shim, 1)
|
||||
shim = "<script>" + shim_js + "</script>"
|
||||
result = sample_html.replace("<body>", "<body>" + shim, 1)
|
||||
assert "<script>" in result
|
||||
assert "/node/test-node" in result
|
||||
assert "window.fetch" in result
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
"""Tests for the console's coordinator idle-cleanup thread helper.
|
||||
|
||||
The helper itself is a tiny loop wrapping ``mgr.close_idle``; the heavy
|
||||
lifting is in ``SessionManager.close_idle`` (covered in
|
||||
``test_session_manager.py``) and ``bulk_close_stale_orphans`` (covered
|
||||
in ``test_storage_sqlite.py``). These tests verify the glue:
|
||||
|
||||
- the helper runs an initial sweep BEFORE its first sleep (cold-start
|
||||
cleanup without blocking the lifespan),
|
||||
- the helper swallows exceptions so a transient DB blip can't kill the
|
||||
daemon thread,
|
||||
- the helper exits cleanly when ``stop_event`` is set.
|
||||
|
||||
The ``stop_event`` parameter is exclusively for tests — production
|
||||
callers pass ``None`` and the daemon runs for process lifetime.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
from unittest.mock import patch
|
||||
|
||||
from turnstone.console.server import _coord_idle_cleanup_thread
|
||||
|
||||
|
||||
class _StubMgr:
|
||||
def __init__(
|
||||
self, *, stop_event: threading.Event, expected_calls: int, raise_after: int = -1
|
||||
) -> None:
|
||||
self.calls: list[float] = []
|
||||
self.sleep_calls_at_each_close: list[int] = []
|
||||
self._stop_event = stop_event
|
||||
self._expected = expected_calls
|
||||
self._raise_after = raise_after
|
||||
self._sleep_count = 0
|
||||
|
||||
def close_idle(self, timeout_sec: float) -> list[str]:
|
||||
# Snapshot how many sleeps preceded this close — lets the
|
||||
# "initial sweep" test verify the first close_idle ran with
|
||||
# zero preceding sleeps.
|
||||
self.sleep_calls_at_each_close.append(self._sleep_count)
|
||||
self.calls.append(timeout_sec)
|
||||
try:
|
||||
if 0 <= self._raise_after < len(self.calls):
|
||||
raise RuntimeError("simulated DB blip")
|
||||
finally:
|
||||
# Set stop after the helper has been exercised enough,
|
||||
# regardless of whether this call raised.
|
||||
if len(self.calls) >= self._expected:
|
||||
self._stop_event.set()
|
||||
return []
|
||||
|
||||
def record_sleep(self, _seconds: float) -> None:
|
||||
self._sleep_count += 1
|
||||
|
||||
|
||||
def _run_until_done(mgr: _StubMgr, stop_event: threading.Event, timeout_sec: float) -> None:
|
||||
with patch("turnstone.console.server.time.sleep", mgr.record_sleep):
|
||||
thread = threading.Thread(
|
||||
target=_coord_idle_cleanup_thread,
|
||||
args=(mgr, timeout_sec, stop_event),
|
||||
daemon=True,
|
||||
)
|
||||
thread.start()
|
||||
thread.join(timeout=2.0)
|
||||
assert not thread.is_alive(), "helper failed to exit on stop_event"
|
||||
|
||||
|
||||
def test_coord_idle_cleanup_runs_initial_sweep_before_sleep() -> None:
|
||||
"""The first close_idle call must happen BEFORE the first time.sleep —
|
||||
otherwise cold-start orphans wait one ``check_every`` interval (~30 min
|
||||
on default 2h timeout) for the first reap. Crucial because the
|
||||
lifespan no longer does a synchronous initial sweep."""
|
||||
stop_event = threading.Event()
|
||||
mgr = _StubMgr(stop_event=stop_event, expected_calls=1)
|
||||
_run_until_done(mgr, stop_event, timeout_sec=120.0)
|
||||
assert mgr.sleep_calls_at_each_close == [0], "first close_idle should run before any sleep"
|
||||
|
||||
|
||||
def test_coord_idle_cleanup_calls_close_idle_each_tick() -> None:
|
||||
stop_event = threading.Event()
|
||||
mgr = _StubMgr(stop_event=stop_event, expected_calls=3)
|
||||
_run_until_done(mgr, stop_event, timeout_sec=120.0)
|
||||
assert len(mgr.calls) == 3
|
||||
assert all(t == 120.0 for t in mgr.calls)
|
||||
|
||||
|
||||
def test_coord_idle_cleanup_survives_close_idle_exceptions() -> None:
|
||||
"""A transient DB error must not kill the daemon thread — the next
|
||||
tick should still fire close_idle. Without the try/except, a single
|
||||
blip would silently leak orphans forever."""
|
||||
stop_event = threading.Event()
|
||||
mgr = _StubMgr(stop_event=stop_event, expected_calls=4, raise_after=1)
|
||||
_run_until_done(mgr, stop_event, timeout_sec=120.0)
|
||||
# All four calls must have fired despite calls 2-4 raising.
|
||||
assert len(mgr.calls) == 4
|
||||
|
||||
|
||||
def test_coord_idle_cleanup_exits_cleanly_on_stop_event() -> None:
|
||||
"""The stop_event mechanism is the test contract; verify the thread
|
||||
actually exits when the event is set, without needing exceptions or
|
||||
daemon-process termination."""
|
||||
stop_event = threading.Event()
|
||||
mgr = _StubMgr(stop_event=stop_event, expected_calls=2)
|
||||
_run_until_done(mgr, stop_event, timeout_sec=120.0)
|
||||
assert stop_event.is_set()
|
||||
@@ -464,3 +464,148 @@ def test_coord_budget_override_survives_wildcard_allow_policy() -> None:
|
||||
assert approved is True
|
||||
types = [e.get("type") for e in captured_events]
|
||||
assert "approve_request" in types, "Wildcard allow must not strip the budget-override prompt"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Cluster-bus broadcast hooks — _broadcast_intent_verdict / _approval_resolved
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestBroadcastIntentVerdict:
|
||||
"""``ConsoleCoordinatorUI._broadcast_intent_verdict`` overrides the
|
||||
no-op base hook to push the verdict onto the cluster bus via
|
||||
``ClusterCollector.emit_console_ws_intent_verdict``. The far more
|
||||
common path is the per-node ``WebUI`` override (covered in
|
||||
test_webui_content.py); this lights up the rare coord-self path
|
||||
(a coord that runs its own LLM judge).
|
||||
"""
|
||||
|
||||
def test_calls_collector_emit_with_ws_id_and_verdict(self) -> None:
|
||||
ui = ConsoleCoordinatorUI(ws_id="coord-a", user_id="u1")
|
||||
collector = MagicMock()
|
||||
ConsoleCoordinatorUI._collector = collector
|
||||
try:
|
||||
verdict = {
|
||||
"call_id": "c1",
|
||||
"risk_level": "high",
|
||||
"confidence": 0.91,
|
||||
}
|
||||
ui._broadcast_intent_verdict(verdict)
|
||||
collector.emit_console_ws_intent_verdict.assert_called_once_with(
|
||||
"coord-a",
|
||||
verdict,
|
||||
)
|
||||
finally:
|
||||
ConsoleCoordinatorUI._collector = None
|
||||
|
||||
def test_no_op_when_collector_unset(self) -> None:
|
||||
ui = ConsoleCoordinatorUI(ws_id="coord-a", user_id="u1")
|
||||
ConsoleCoordinatorUI._collector = None
|
||||
# Doesn't raise.
|
||||
ui._broadcast_intent_verdict({"call_id": "c1"})
|
||||
|
||||
def test_collector_exception_swallowed(self) -> None:
|
||||
ui = ConsoleCoordinatorUI(ws_id="coord-a", user_id="u1")
|
||||
collector = MagicMock()
|
||||
collector.emit_console_ws_intent_verdict.side_effect = RuntimeError("boom")
|
||||
ConsoleCoordinatorUI._collector = collector
|
||||
try:
|
||||
# Doesn't raise — collector failures are observational only.
|
||||
ui._broadcast_intent_verdict({"call_id": "c1"})
|
||||
finally:
|
||||
ConsoleCoordinatorUI._collector = None
|
||||
|
||||
|
||||
class TestBroadcastApprovalResolved:
|
||||
"""``ConsoleCoordinatorUI._broadcast_approval_resolved`` overrides
|
||||
the base hook to push the resolution onto the cluster bus via
|
||||
``ClusterCollector.emit_console_ws_approval_resolved``."""
|
||||
|
||||
def test_calls_collector_with_decision_fields(self) -> None:
|
||||
ui = ConsoleCoordinatorUI(ws_id="coord-a", user_id="u1")
|
||||
collector = MagicMock()
|
||||
ConsoleCoordinatorUI._collector = collector
|
||||
try:
|
||||
ui._broadcast_approval_resolved(True, "lgtm", always=True)
|
||||
collector.emit_console_ws_approval_resolved.assert_called_once_with(
|
||||
"coord-a",
|
||||
approved=True,
|
||||
feedback="lgtm",
|
||||
always=True,
|
||||
)
|
||||
finally:
|
||||
ConsoleCoordinatorUI._collector = None
|
||||
|
||||
def test_normalises_none_feedback_to_empty_string(self) -> None:
|
||||
ui = ConsoleCoordinatorUI(ws_id="coord-a", user_id="u1")
|
||||
collector = MagicMock()
|
||||
ConsoleCoordinatorUI._collector = collector
|
||||
try:
|
||||
ui._broadcast_approval_resolved(False, None)
|
||||
collector.emit_console_ws_approval_resolved.assert_called_once_with(
|
||||
"coord-a",
|
||||
approved=False,
|
||||
feedback="",
|
||||
always=False,
|
||||
)
|
||||
finally:
|
||||
ConsoleCoordinatorUI._collector = None
|
||||
|
||||
def test_no_op_when_collector_unset(self) -> None:
|
||||
ui = ConsoleCoordinatorUI(ws_id="coord-a", user_id="u1")
|
||||
ConsoleCoordinatorUI._collector = None
|
||||
# Doesn't raise.
|
||||
ui._broadcast_approval_resolved(True, None)
|
||||
|
||||
def test_collector_exception_swallowed(self) -> None:
|
||||
ui = ConsoleCoordinatorUI(ws_id="coord-a", user_id="u1")
|
||||
collector = MagicMock()
|
||||
collector.emit_console_ws_approval_resolved.side_effect = RuntimeError("boom")
|
||||
ConsoleCoordinatorUI._collector = collector
|
||||
try:
|
||||
# Doesn't raise.
|
||||
ui._broadcast_approval_resolved(True, "ok")
|
||||
finally:
|
||||
ConsoleCoordinatorUI._collector = None
|
||||
|
||||
|
||||
class TestBroadcastApproveRequest:
|
||||
"""Coord-side override for the approve_request push. Same rationale
|
||||
as the WebUI override — the coord-self path is rare today, but
|
||||
parity keeps the override symmetric with the rest of the broadcast
|
||||
family."""
|
||||
|
||||
def test_calls_collector_emit_with_ws_id_and_detail(self) -> None:
|
||||
ui = ConsoleCoordinatorUI(ws_id="coord-a", user_id="u1")
|
||||
collector = MagicMock()
|
||||
ConsoleCoordinatorUI._collector = collector
|
||||
try:
|
||||
detail = {
|
||||
"type": "approve_request",
|
||||
"items": [{"call_id": "c1", "header": "tool x"}],
|
||||
"judge_pending": True,
|
||||
}
|
||||
ui._broadcast_approve_request(detail)
|
||||
collector.emit_console_ws_approve_request.assert_called_once_with(
|
||||
"coord-a",
|
||||
detail,
|
||||
)
|
||||
finally:
|
||||
ConsoleCoordinatorUI._collector = None
|
||||
|
||||
def test_no_op_when_collector_unset(self) -> None:
|
||||
ui = ConsoleCoordinatorUI(ws_id="coord-a", user_id="u1")
|
||||
ConsoleCoordinatorUI._collector = None
|
||||
# Doesn't raise.
|
||||
ui._broadcast_approve_request({"items": []})
|
||||
|
||||
def test_collector_exception_swallowed(self) -> None:
|
||||
ui = ConsoleCoordinatorUI(ws_id="coord-a", user_id="u1")
|
||||
collector = MagicMock()
|
||||
collector.emit_console_ws_approve_request.side_effect = RuntimeError("boom")
|
||||
ConsoleCoordinatorUI._collector = collector
|
||||
try:
|
||||
# Doesn't raise.
|
||||
ui._broadcast_approve_request({"items": []})
|
||||
finally:
|
||||
ConsoleCoordinatorUI._collector = None
|
||||
|
||||
@@ -378,13 +378,21 @@ class TestCoordinatorAdapterWorkerDispatch:
|
||||
|
||||
|
||||
class TestCoordinatorAdapterChildrenRegistry:
|
||||
def test_emit_created_seeds_empty_children_set(self) -> None:
|
||||
"""Adapter-level integration with :class:`ChildrenRegistry`.
|
||||
|
||||
Pure-registry invariants (forward/reverse consistency, idempotent
|
||||
merge, locking) live in ``test_children_registry.py``. These
|
||||
tests cover the adapter's wiring: that ``emit_*`` paths drive the
|
||||
registry correctly and that the snapshot-priming bridge between
|
||||
a collector snapshot and the registry preserves merge semantics.
|
||||
"""
|
||||
|
||||
def test_emit_created_installs_parent(self) -> None:
|
||||
adapter, _ = _make_adapter()
|
||||
ws = _make_ws()
|
||||
adapter.emit_created(ws)
|
||||
assert ws.id in adapter._children
|
||||
assert adapter._children[ws.id] == set()
|
||||
assert adapter._active_coords[ws.id] is ws.ui
|
||||
assert adapter._registry.children_of(ws.id) == []
|
||||
assert adapter._registry.ui_for(ws.id) is ws.ui
|
||||
|
||||
def test_emit_rehydrated_calls_rebuild(self) -> None:
|
||||
adapter, _ = _make_adapter()
|
||||
@@ -398,42 +406,36 @@ class TestCoordinatorAdapterChildrenRegistry:
|
||||
adapter.emit_rehydrated(ws)
|
||||
assert calls == [ws.id]
|
||||
|
||||
def test_emit_closed_clears_forward_and_reverse_indexes(self) -> None:
|
||||
def test_emit_closed_uninstalls_parent_and_clears_children(self) -> None:
|
||||
adapter, _ = _make_adapter()
|
||||
with adapter._children_lock:
|
||||
adapter._merge_child_ids_locked("coord-a", ["child-a1", "child-a2"])
|
||||
adapter._merge_child_ids_locked("coord-b", ["child-b1"])
|
||||
adapter._active_coords["coord-a"] = object()
|
||||
adapter._active_coords["coord-b"] = object()
|
||||
adapter._registry.install("coord-a", object())
|
||||
adapter._registry.install("coord-b", object())
|
||||
adapter._registry.merge_children("coord-a", ["child-a1", "child-a2"])
|
||||
adapter._registry.merge_children("coord-b", ["child-b1"])
|
||||
|
||||
adapter.emit_closed("coord-a")
|
||||
|
||||
assert "coord-a" not in adapter._children
|
||||
assert "coord-a" not in adapter._active_coords
|
||||
assert "child-a1" not in adapter._child_to_coord
|
||||
assert "child-a2" not in adapter._child_to_coord
|
||||
assert adapter._registry.ui_for("coord-a") is None
|
||||
assert adapter._registry.children_of("coord-a") == []
|
||||
assert adapter._registry.parent_for("child-a1") is None
|
||||
assert adapter._registry.parent_for("child-a2") is None
|
||||
# coord-b untouched
|
||||
assert adapter._child_to_coord["child-b1"] == "coord-b"
|
||||
assert "coord-b" in adapter._children
|
||||
|
||||
def test_merge_child_ids_locked_is_idempotent(self) -> None:
|
||||
adapter, _ = _make_adapter()
|
||||
with adapter._children_lock:
|
||||
adapter._merge_child_ids_locked("coord-a", ["child-1"])
|
||||
adapter._merge_child_ids_locked("coord-a", ["child-1"])
|
||||
assert adapter._children["coord-a"] == {"child-1"}
|
||||
assert adapter._child_to_coord == {"child-1": "coord-a"}
|
||||
assert adapter._registry.parent_for("child-b1") == "coord-b"
|
||||
assert adapter._registry.ui_for("coord-b") is not None
|
||||
|
||||
def test_prime_children_from_snapshot_merges_without_overwriting(self) -> None:
|
||||
# Snapshot priming now lives on ClusterChildSource (production
|
||||
# path). The adapter no longer carries its own duplicate copy.
|
||||
from turnstone.core.child_source import ClusterChildSource
|
||||
|
||||
adapter, _ = _make_adapter()
|
||||
# Seed one in-memory coord + one existing child
|
||||
coord_ws = _make_ws()
|
||||
coord_ws.id = "coord-a"
|
||||
mgr = MagicMock()
|
||||
mgr.list_all.return_value = [coord_ws]
|
||||
adapter.attach(mgr)
|
||||
with adapter._children_lock:
|
||||
adapter._merge_child_ids_locked("coord-a", ["child-a1"])
|
||||
adapter._registry.merge_children("coord-a", ["child-a1"])
|
||||
|
||||
source = ClusterChildSource(
|
||||
collector=MagicMock(),
|
||||
registry=adapter._registry,
|
||||
parents_provider=lambda: ["coord-a"],
|
||||
)
|
||||
|
||||
snapshot = {
|
||||
"nodes": [
|
||||
@@ -448,10 +450,13 @@ class TestCoordinatorAdapterChildrenRegistry:
|
||||
},
|
||||
],
|
||||
}
|
||||
adapter._prime_children_from_snapshot(snapshot)
|
||||
assert adapter._children["coord-a"] == {"child-a1", "child-a2"}
|
||||
assert adapter._child_to_coord["child-a2"] == "coord-a"
|
||||
assert "child-x" not in adapter._child_to_coord
|
||||
source._prime_from_snapshot(snapshot)
|
||||
assert set(adapter._registry.children_of("coord-a")) == {
|
||||
"child-a1",
|
||||
"child-a2",
|
||||
}
|
||||
assert adapter._registry.parent_for("child-a2") == "coord-a"
|
||||
assert adapter._registry.parent_for("child-x") is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -478,9 +483,7 @@ class TestCoordinatorAdapterDispatchChildEvent:
|
||||
coord_ws.id = coord_id
|
||||
recorder = _UIRecorder()
|
||||
coord_ws.ui = recorder # type: ignore[assignment]
|
||||
with adapter._children_lock:
|
||||
adapter._children.setdefault(coord_id, set())
|
||||
adapter._active_coords[coord_id] = recorder
|
||||
adapter._registry.install(coord_id, recorder)
|
||||
adapter.attach(_StubManager(coord_ws)) # type: ignore[arg-type]
|
||||
return adapter, recorder, coord_ws
|
||||
|
||||
@@ -510,12 +513,11 @@ class TestCoordinatorAdapterDispatchChildEvent:
|
||||
assert payload["child_ws_id"] == "child-a1"
|
||||
assert payload["parent_ws_id"] == "coord-a"
|
||||
# Reverse index updated for subsequent cluster_state events.
|
||||
assert adapter._child_to_coord["child-a1"] == "coord-a"
|
||||
assert adapter._registry.parent_for("child-a1") == "coord-a"
|
||||
|
||||
def test_dispatch_cluster_state_routes_via_reverse_index(self) -> None:
|
||||
adapter, recorder, _ = self._setup()
|
||||
with adapter._children_lock:
|
||||
adapter._merge_child_ids_locked("coord-a", ["child-a1"])
|
||||
adapter._registry.merge_children("coord-a", ["child-a1"])
|
||||
adapter._dispatch_child_event(
|
||||
{
|
||||
"type": "cluster_state",
|
||||
@@ -533,8 +535,7 @@ class TestCoordinatorAdapterDispatchChildEvent:
|
||||
|
||||
def test_dispatch_ws_closed_routes_to_parent_coord(self) -> None:
|
||||
adapter, recorder, _ = self._setup()
|
||||
with adapter._children_lock:
|
||||
adapter._merge_child_ids_locked("coord-a", ["child-a1"])
|
||||
adapter._registry.merge_children("coord-a", ["child-a1"])
|
||||
adapter._dispatch_child_event(
|
||||
{"type": "ws_closed", "ws_id": "child-a1", "reason": "evicted"}
|
||||
)
|
||||
@@ -548,8 +549,7 @@ class TestCoordinatorAdapterDispatchChildEvent:
|
||||
"""perf-6: _enqueue_on_ui mutates the payload dict in place with
|
||||
the coord's ws_id so the browser can discriminate child events."""
|
||||
adapter, recorder, _ = self._setup()
|
||||
with adapter._children_lock:
|
||||
adapter._merge_child_ids_locked("coord-a", ["child-a1"])
|
||||
adapter._registry.merge_children("coord-a", ["child-a1"])
|
||||
adapter._dispatch_child_event(
|
||||
{
|
||||
"type": "cluster_state",
|
||||
@@ -558,3 +558,161 @@ class TestCoordinatorAdapterDispatchChildEvent:
|
||||
}
|
||||
)
|
||||
assert recorder.enqueued[0]["ws_id"] == "coord-a"
|
||||
|
||||
def test_dispatch_cluster_state_does_not_carry_pending_approval_detail(
|
||||
self,
|
||||
) -> None:
|
||||
"""Stage 3 cleanup — the ``pending_approval_detail`` piggyback
|
||||
on ``cluster_state`` is gone. Approval items now arrive via
|
||||
bulk fetch (triggered by ``activity_state="approval"`` in the
|
||||
browser); verdicts via ``child_ws_intent_verdict``; resolution
|
||||
via ``child_ws_approval_resolved``. The state event carries
|
||||
only state + activity_state — no detail field."""
|
||||
adapter, recorder, _ = self._setup()
|
||||
adapter._registry.merge_children("coord-a", ["child-a1"])
|
||||
adapter._dispatch_child_event(
|
||||
{
|
||||
"type": "cluster_state",
|
||||
"ws_id": "child-a1",
|
||||
"state": "running",
|
||||
"activity_state": "approval",
|
||||
}
|
||||
)
|
||||
assert len(recorder.enqueued) == 1
|
||||
payload = recorder.enqueued[0]
|
||||
assert payload["type"] == "child_ws_state"
|
||||
assert payload["activity_state"] == "approval"
|
||||
assert "pending_approval_detail" not in payload
|
||||
|
||||
def test_dispatch_intent_verdict_emits_child_ws_intent_verdict(self) -> None:
|
||||
"""Stage 3 Step 6 — explicit verdict events are re-emitted as
|
||||
child_ws_intent_verdict on the parent's SSE so the tree UI
|
||||
renders the risk pill without polling."""
|
||||
adapter, recorder, _ = self._setup()
|
||||
adapter._registry.merge_children("coord-a", ["child-a1"])
|
||||
verdict = {
|
||||
"call_id": "c1",
|
||||
"risk_level": "low",
|
||||
"confidence": 0.92,
|
||||
"recommendation": "approve",
|
||||
}
|
||||
adapter._dispatch_child_event(
|
||||
{
|
||||
"type": "intent_verdict",
|
||||
"ws_id": "child-a1",
|
||||
"node_id": "node-1",
|
||||
"verdict": verdict,
|
||||
}
|
||||
)
|
||||
assert len(recorder.enqueued) == 1
|
||||
payload = recorder.enqueued[0]
|
||||
assert payload["type"] == "child_ws_intent_verdict"
|
||||
assert payload["child_ws_id"] == "child-a1"
|
||||
assert payload["parent_ws_id"] == "coord-a"
|
||||
assert payload["node_id"] == "node-1"
|
||||
assert payload["verdict"] == verdict
|
||||
|
||||
def test_dispatch_intent_verdict_unknown_child_drops(self) -> None:
|
||||
adapter, recorder, _ = self._setup()
|
||||
adapter._dispatch_child_event(
|
||||
{
|
||||
"type": "intent_verdict",
|
||||
"ws_id": "ws-orphan",
|
||||
"verdict": {"call_id": "c1"},
|
||||
}
|
||||
)
|
||||
assert recorder.enqueued == []
|
||||
|
||||
def test_dispatch_approval_resolved_emits_child_ws_approval_resolved(
|
||||
self,
|
||||
) -> None:
|
||||
"""Stage 3 Step 6 — paired with intent_verdict; clears the
|
||||
pending-approval pill on the parent's tree UI in lockstep
|
||||
with the actual decision."""
|
||||
adapter, recorder, _ = self._setup()
|
||||
adapter._registry.merge_children("coord-a", ["child-a1"])
|
||||
adapter._dispatch_child_event(
|
||||
{
|
||||
"type": "approval_resolved",
|
||||
"ws_id": "child-a1",
|
||||
"node_id": "node-1",
|
||||
"approved": True,
|
||||
"feedback": "lgtm",
|
||||
"always": False,
|
||||
}
|
||||
)
|
||||
assert len(recorder.enqueued) == 1
|
||||
payload = recorder.enqueued[0]
|
||||
assert payload["type"] == "child_ws_approval_resolved"
|
||||
assert payload["child_ws_id"] == "child-a1"
|
||||
assert payload["parent_ws_id"] == "coord-a"
|
||||
assert payload["approved"] is True
|
||||
assert payload["feedback"] == "lgtm"
|
||||
assert payload["always"] is False
|
||||
|
||||
def test_dispatch_approval_resolved_coerces_missing_fields(self) -> None:
|
||||
"""Older nodes mid-rolling-upgrade may omit approved / always /
|
||||
feedback; dispatch coerces to safe defaults."""
|
||||
adapter, recorder, _ = self._setup()
|
||||
adapter._registry.merge_children("coord-a", ["child-a1"])
|
||||
adapter._dispatch_child_event({"type": "approval_resolved", "ws_id": "child-a1"})
|
||||
assert len(recorder.enqueued) == 1
|
||||
payload = recorder.enqueued[0]
|
||||
assert payload["approved"] is False
|
||||
assert payload["feedback"] == ""
|
||||
assert payload["always"] is False
|
||||
|
||||
def test_dispatch_approval_resolved_unknown_child_drops(self) -> None:
|
||||
"""Symmetric to the intent_verdict drop test — events for
|
||||
ws_ids the registry doesn't know about silently drop instead
|
||||
of fanning out to a parent that has no business seeing them."""
|
||||
adapter, recorder, _ = self._setup()
|
||||
adapter._dispatch_child_event(
|
||||
{
|
||||
"type": "approval_resolved",
|
||||
"ws_id": "ws-orphan",
|
||||
"approved": True,
|
||||
},
|
||||
)
|
||||
assert recorder.enqueued == []
|
||||
|
||||
def test_dispatch_approve_request_emits_child_ws_approve_request(
|
||||
self,
|
||||
) -> None:
|
||||
"""Push path for the initial approval items — eliminates the
|
||||
bulk-fetch race that left the coord row stuck on a loading
|
||||
placeholder when the bulk fetch landed in the gap between
|
||||
_emit_state(ATTENTION) and approve_tools setting _pending_approval."""
|
||||
adapter, recorder, _ = self._setup()
|
||||
adapter._registry.merge_children("coord-a", ["child-a1"])
|
||||
detail = {
|
||||
"type": "approve_request",
|
||||
"items": [{"call_id": "c1", "header": "tool x"}],
|
||||
"judge_pending": True,
|
||||
}
|
||||
adapter._dispatch_child_event(
|
||||
{
|
||||
"type": "approve_request",
|
||||
"ws_id": "child-a1",
|
||||
"node_id": "node-1",
|
||||
"detail": detail,
|
||||
},
|
||||
)
|
||||
assert len(recorder.enqueued) == 1
|
||||
payload = recorder.enqueued[0]
|
||||
assert payload["type"] == "child_ws_approve_request"
|
||||
assert payload["child_ws_id"] == "child-a1"
|
||||
assert payload["parent_ws_id"] == "coord-a"
|
||||
assert payload["node_id"] == "node-1"
|
||||
assert payload["detail"] == detail
|
||||
|
||||
def test_dispatch_approve_request_unknown_child_drops(self) -> None:
|
||||
adapter, recorder, _ = self._setup()
|
||||
adapter._dispatch_child_event(
|
||||
{
|
||||
"type": "approve_request",
|
||||
"ws_id": "ws-orphan",
|
||||
"detail": {"items": []},
|
||||
},
|
||||
)
|
||||
assert recorder.enqueued == []
|
||||
|
||||
@@ -949,6 +949,137 @@ def test_list_nodes_empty_on_no_matching_filters(storage_with_nodes):
|
||||
assert result["truncated"] is False
|
||||
|
||||
|
||||
def test_list_nodes_surfaces_healthy_model_aliases(tmp_path):
|
||||
"""The node's heartbeat loop projects its registry into a ``models``
|
||||
metadata entry shaped like ``[{alias, provider, healthy}, ...]``.
|
||||
``list_nodes`` flattens that to the healthy-alias list at the top
|
||||
level (under ``model_aliases``) so a coordinator can pass aliases
|
||||
straight to ``spawn_workstream(model=)`` without having to
|
||||
introspect the metadata blob. The provider-side model identifier
|
||||
(``cfg.model``) is intentionally NOT in the payload — coords kept
|
||||
reaching for it when they should pass the local alias."""
|
||||
st = SQLiteBackend(str(tmp_path / "nodes.db"))
|
||||
_set_meta(
|
||||
st,
|
||||
"node-x",
|
||||
[
|
||||
("arch", "x86_64", "auto"),
|
||||
(
|
||||
"models",
|
||||
[
|
||||
{"alias": "gpt5", "provider": "openai", "healthy": True},
|
||||
{"alias": "claude-opus-47", "provider": "anthropic", "healthy": True},
|
||||
{"alias": "broken", "provider": "openai", "healthy": False},
|
||||
],
|
||||
"auto",
|
||||
),
|
||||
],
|
||||
)
|
||||
_register_service(st, "node-x")
|
||||
client = _make_read_client(st)
|
||||
result = client.list_nodes()
|
||||
node = result["nodes"][0]
|
||||
assert node["model_aliases"] == ["gpt5", "claude-opus-47"]
|
||||
# Full per-alias info still available under metadata for callers
|
||||
# that want provider / healthy detail (e.g. surfacing degraded
|
||||
# aliases in a UI).
|
||||
full = node["metadata"]["models"]["value"]
|
||||
assert {row["alias"] for row in full} == {"gpt5", "claude-opus-47", "broken"}
|
||||
# ``model`` (the provider-side identifier) is intentionally absent
|
||||
# — keep the payload to the three values a coord actually uses.
|
||||
for row in full:
|
||||
assert "model" not in row
|
||||
|
||||
|
||||
def test_list_nodes_model_aliases_distinct_from_metadata_models(tmp_path):
|
||||
"""Pin the naming distinction explicitly: the top-level shortlist
|
||||
(``model_aliases``, list of strings) and the rich metadata blob
|
||||
(``metadata.models.value``, list of dicts) live under different
|
||||
keys so a caller that confuses them gets a clear KeyError rather
|
||||
than a silent shape mismatch."""
|
||||
st = SQLiteBackend(str(tmp_path / "nodes.db"))
|
||||
_set_meta(
|
||||
st,
|
||||
"node-x",
|
||||
[
|
||||
(
|
||||
"models",
|
||||
[{"alias": "a", "provider": "openai", "healthy": True}],
|
||||
"auto",
|
||||
),
|
||||
],
|
||||
)
|
||||
_register_service(st, "node-x")
|
||||
client = _make_read_client(st)
|
||||
node = client.list_nodes()["nodes"][0]
|
||||
# No top-level ``models`` field — only ``model_aliases``.
|
||||
assert "models" not in node
|
||||
assert node["model_aliases"] == ["a"]
|
||||
# Rich shape stays under metadata.
|
||||
assert isinstance(node["metadata"]["models"]["value"], list)
|
||||
assert isinstance(node["metadata"]["models"]["value"][0], dict)
|
||||
|
||||
|
||||
def test_list_nodes_model_aliases_empty_when_node_has_not_published(tmp_path):
|
||||
"""Nodes from older builds — or a node mid-startup before its first
|
||||
metadata write — won't have a ``models`` entry. The top-level
|
||||
``model_aliases`` field defaults to ``[]`` rather than being
|
||||
omitted so coordinators can rely on the key being present."""
|
||||
st = SQLiteBackend(str(tmp_path / "nodes.db"))
|
||||
_set_meta(st, "node-y", [("arch", "x86_64", "auto")])
|
||||
_register_service(st, "node-y")
|
||||
client = _make_read_client(st)
|
||||
result = client.list_nodes()
|
||||
assert result["nodes"][0]["model_aliases"] == []
|
||||
|
||||
|
||||
def test_list_nodes_models_tolerates_malformed_entries(tmp_path):
|
||||
"""If a node ever stores a malformed ``models`` entry (wrong outer
|
||||
type, missing alias, non-bool healthy), the projection drops the
|
||||
bad rows rather than raising — the rest of the response should
|
||||
still be useful."""
|
||||
st = SQLiteBackend(str(tmp_path / "nodes.db"))
|
||||
_set_meta(
|
||||
st,
|
||||
"node-z",
|
||||
[
|
||||
(
|
||||
"models",
|
||||
[
|
||||
{"alias": "ok", "provider": "p", "healthy": True},
|
||||
"not-a-dict",
|
||||
{"provider": "p", "healthy": True}, # missing alias
|
||||
{"alias": "", "healthy": True}, # empty alias
|
||||
{"alias": "degraded", "healthy": False},
|
||||
{"alias": 42, "healthy": True}, # non-string alias
|
||||
],
|
||||
"auto",
|
||||
),
|
||||
],
|
||||
)
|
||||
_register_service(st, "node-z")
|
||||
client = _make_read_client(st)
|
||||
result = client.list_nodes()
|
||||
assert result["nodes"][0]["model_aliases"] == ["ok"]
|
||||
|
||||
|
||||
def test_list_nodes_models_handles_non_list_payload(tmp_path):
|
||||
"""A node with a corrupted models entry (dict, scalar, null) shouldn't
|
||||
blow up the whole list_nodes call. ``model_aliases`` falls back to ``[]``."""
|
||||
st = SQLiteBackend(str(tmp_path / "nodes.db"))
|
||||
_set_meta(
|
||||
st,
|
||||
"node-w",
|
||||
[
|
||||
("models", {"oops": "not a list"}, "auto"),
|
||||
],
|
||||
)
|
||||
_register_service(st, "node-w")
|
||||
client = _make_read_client(st)
|
||||
result = client.list_nodes()
|
||||
assert result["nodes"][0]["model_aliases"] == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# list_skills
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
+144
-16
@@ -79,9 +79,10 @@ def test_coordinator_js_exposes_inline_approval_helpers():
|
||||
assert "function submitChildApproval" in body or "submitChildApproval(" in body
|
||||
# The shared approve POST helper (parameterized for child ws_ids)
|
||||
assert "function approveWorkstream" in body or "approveWorkstream(" in body
|
||||
# The urgent live-bulk fetch option that fires on activity_state
|
||||
# transitions in/out of "approval"
|
||||
assert "{ urgent: true }" in body or "urgent: true" in body
|
||||
# The 409 stale-call_id retry path uses invalidateLiveBadge +
|
||||
# scheduleLiveFetch (Stage 3 cleanup removed the urgent flag —
|
||||
# cache invalidation makes the TTL gate fall through naturally).
|
||||
assert "invalidateLiveBadge(targetWsId)" in body
|
||||
# Server-side payload field — drift here means the JS reads stale keys
|
||||
assert "pending_approval_detail" in body
|
||||
# Reconnect parity (chunk 4): the SSE re-open handler must drop
|
||||
@@ -90,10 +91,10 @@ def test_coordinator_js_exposes_inline_approval_helpers():
|
||||
# can't render zombie approve/deny buttons on a row whose
|
||||
# approval was resolved during the gap. The implementation
|
||||
# iterates the cache and deletes only !permanent entries —
|
||||
# asserting the literal Map iteration form keeps a refactor
|
||||
# back to liveBadgeCache.clear() (which would re-pay 403s on
|
||||
# every reconnect for denied ids) from sneaking in.
|
||||
assert "liveBadgeCache.delete" in body
|
||||
# asserting the literal helper call keeps a refactor back to
|
||||
# _liveBadgeCacheClear() (which would re-pay 403s on every
|
||||
# reconnect for denied ids) from sneaking in.
|
||||
assert "_liveBadgeCacheDelete" in body
|
||||
# Edge-case matrix sentinel labels — POLICY-BLOCKED renders when
|
||||
# an item has error set + needs_approval=False (server-side
|
||||
# tool policy already blocked the call); "(judge unavailable)"
|
||||
@@ -113,15 +114,19 @@ def test_coordinator_js_exposes_inline_approval_helpers():
|
||||
# coord-self ws_id (the coord lives on the console process).
|
||||
# Children live on cluster nodes and 404 without the prefix.
|
||||
assert "/v1/api/route/workstreams/" in body
|
||||
# Late-judge polling — the LLM judge runs async on the child
|
||||
# node and never pushes a signal that reaches the coord, so
|
||||
# the row's pending_approval_detail with judge_pending=true
|
||||
# would freeze on heuristic verdicts forever without this
|
||||
# poll loop. The poller is GLOBAL (not per-row) so off-screen
|
||||
# rows still refresh — a per-row poller's scheduleLiveFetch
|
||||
# call short-circuits on non-visible rows, leaving them stuck.
|
||||
assert "_maybeStartJudgePoll" in body
|
||||
assert "_judgePollTick" in body
|
||||
# Late-arriving LLM judge verdicts — Stage 3 Step 5 promoted
|
||||
# ``intent_verdict`` and ``approval_resolved`` to first-class
|
||||
# cluster-bus event types, so the coord adapter dispatches them
|
||||
# as ``child_ws_intent_verdict`` / ``child_ws_approval_resolved``
|
||||
# on the parent's SSE stream. The browser handlers write
|
||||
# directly to liveBadgeCache (bypassing scheduleLiveFetch's
|
||||
# visibility gate cleanly) so off-screen rows pick up verdicts
|
||||
# without polling. Replaced the old ``_judgePollTick`` 90-second
|
||||
# global poll loop and its visibility-gate-bypass workaround.
|
||||
assert "handleChildIntentVerdict" in body
|
||||
assert "handleChildApprovalResolved" in body
|
||||
assert "child_ws_intent_verdict" in body
|
||||
assert "child_ws_approval_resolved" in body
|
||||
# Reload parity for the coord-self approval gate: init() must
|
||||
# consume the authoritative GET /workstreams snapshot's
|
||||
# pending_approval_detail so a freshly opened tab can render
|
||||
@@ -152,3 +157,126 @@ def test_coordinator_js_exposes_inline_approval_helpers():
|
||||
# any prior denial. bug-1 / bug-3 from the second /review pass.
|
||||
assert "Denied by user" in body
|
||||
assert "callOutcomes" in body
|
||||
# User-message attachment pills — both live send (coordSend) and
|
||||
# history replay route through appendUserMessageWithAttachments.
|
||||
# Renaming or dropping the helper would silently regress the
|
||||
# attachment affordance to the pre-fix plain-text bubble, which
|
||||
# would only surface in manual testing of an attached-file flow.
|
||||
# The CSS class is the visual anchor (coordinator.css) — keeping
|
||||
# both literals in the smoke layer covers JS↔CSS drift in either
|
||||
# direction.
|
||||
assert "function appendUserMessageWithAttachments" in body
|
||||
assert "msg-user-attach" in body
|
||||
|
||||
|
||||
def test_coordinator_js_handle_child_state_no_longer_reads_sse_pending_approval_detail():
|
||||
"""Stage 3 cleanup — ``pending_approval_detail`` is no longer
|
||||
piggybacked on child_ws_state events. Approval items now arrive
|
||||
via bulk fetch on the activity_state="approval" transition;
|
||||
verdicts via the explicit ``child_ws_intent_verdict`` event class;
|
||||
resolution via ``child_ws_approval_resolved``. A refactor that
|
||||
re-introduces the piggyback would silently re-open the
|
||||
duplicate-path race the dedicated event classes were added to
|
||||
eliminate.
|
||||
|
||||
Structural assertions (regex against multi-line source) — symbol-
|
||||
presence alone wouldn't catch a guard that keeps the names but
|
||||
inverts the comparison or drops the ``prev.live`` check. This
|
||||
codebase has no JS test framework, so locking the guard's shape
|
||||
here is the next-best thing to a behavioral test."""
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
coord_js = Path(__file__).resolve().parent.parent / (
|
||||
"turnstone/console/static/coordinator/coordinator.js"
|
||||
)
|
||||
body = coord_js.read_text(encoding="utf-8")
|
||||
|
||||
# The piggyback read is gone from handleChildState. (The string
|
||||
# may still appear elsewhere — e.g. handleChildIntentVerdict
|
||||
# reading from cache, or comments — but never as ``ev.pending_approval_detail``.)
|
||||
assert "ev.pending_approval_detail" not in body
|
||||
# The pre-fix urgent-fetch on activity_state transitions is gone.
|
||||
assert "enteredApproval" not in body
|
||||
assert "leftApproval" not in body
|
||||
# ``pendingApproval`` flag derivation must check BOTH state and
|
||||
# activity_state. The worker thread can fire the state transition
|
||||
# to "attention" before approve_tools updates activity_state, so
|
||||
# checking only activity_state misses children that legitimately
|
||||
# need approval. Pin the disjunction so the regression doesn't
|
||||
# silently re-introduce.
|
||||
assert re.search(
|
||||
r'existing\.state\s*===\s*"attention"\s*\|\|\s*'
|
||||
r'existing\.activity_state\s*===\s*"approval"',
|
||||
body,
|
||||
), (
|
||||
"handleChildState must derive pendingApproval from "
|
||||
"(state==='attention' || activity_state==='approval')"
|
||||
)
|
||||
|
||||
# SSE-authoritative window constant is defined and used.
|
||||
assert re.search(r"\bconst\s+SSE_AUTHORITATIVE_MS\s*=\s*\d+", body), (
|
||||
"SSE_AUTHORITATIVE_MS constant must be defined as a numeric literal"
|
||||
)
|
||||
|
||||
# SSE writers tag entries with sseUpdatedAt: Date.now() so the
|
||||
# merge guard in flushLiveFetches preserves them against stale
|
||||
# bulk-fetch responses. handleChildState only stamps when it
|
||||
# AUTHORITATIVELY clears the detail (off-approval transition);
|
||||
# writers that stamp unconditionally are intent_verdict (verdict
|
||||
# stamp), approval_resolved (clear), and the optimistic-clear
|
||||
# path in submitChildApproval. Pinning the literal Date.now()
|
||||
# call keeps a refactor that drops the SSE-source tag entirely
|
||||
# from sneaking in.
|
||||
assert re.search(
|
||||
r"sseUpdatedAt:\s*Date\.now\(\)",
|
||||
body,
|
||||
), "Critical SSE writers must stamp sseUpdatedAt: Date.now()"
|
||||
|
||||
# flushLiveFetches' merge guard structure: SSE-set pending_approval
|
||||
# / _detail wins over a stale bulk-poll snapshot when (live) AND
|
||||
# (prev exists) AND (prev.sseUpdatedAt set) AND (within window)
|
||||
# AND (prev.live exists). Inverting the comparison or dropping
|
||||
# any of these guards reopens the clobber bug.
|
||||
merge_guard = re.search(
|
||||
r"if\s*\(\s*live\s*&&\s*prev\s*&&\s*prev\.sseUpdatedAt\s*&&\s*"
|
||||
r"now\s*-\s*prev\.sseUpdatedAt\s*<\s*SSE_AUTHORITATIVE_MS\s*&&\s*"
|
||||
r"prev\.live\s*\)",
|
||||
body,
|
||||
)
|
||||
assert merge_guard is not None, (
|
||||
"flushLiveFetches merge guard must be the conjunction "
|
||||
"(live && prev && prev.sseUpdatedAt && now - prev.sseUpdatedAt < "
|
||||
"SSE_AUTHORITATIVE_MS && prev.live). An inverted comparison or "
|
||||
"missing prev.live check would let a stale bulk-poll clobber a "
|
||||
"fresh SSE-set approval."
|
||||
)
|
||||
|
||||
# The merge body must preserve BOTH pending_approval and
|
||||
# pending_approval_detail from prev — preserving only one would
|
||||
# render a row with a phantom badge but no buttons (or vice versa).
|
||||
merge_body = re.search(
|
||||
r"mergedLive\s*=\s*Object\.assign\(\s*\{\}\s*,\s*live\s*,\s*\{"
|
||||
r"[^}]*pending_approval:\s*prev\.live\.pending_approval[^}]*"
|
||||
r"pending_approval_detail:\s*prev\.live\.pending_approval_detail",
|
||||
body,
|
||||
)
|
||||
assert merge_body is not None, (
|
||||
"Merge body must preserve both pending_approval AND "
|
||||
"pending_approval_detail from prev.live — preserving only one "
|
||||
"creates a half-rendered approval row."
|
||||
)
|
||||
|
||||
# flushLiveFetches must forward sseUpdatedAt onto the new cache
|
||||
# entry so the SSE-source tag survives the bulk-poll write back —
|
||||
# without this, every bulk-poll resets the window and the next
|
||||
# late-arriving poll silently clobbers.
|
||||
assert re.search(
|
||||
r"sseUpdatedAt:\s*prev\s*\?\s*prev\.sseUpdatedAt",
|
||||
body,
|
||||
), (
|
||||
"flushLiveFetches must forward prev.sseUpdatedAt onto the new "
|
||||
"cache entry (preserving the SSE-source window across bulk-poll "
|
||||
"cycles) — without this, the second bulk-poll after an SSE "
|
||||
"transition silently clobbers."
|
||||
)
|
||||
|
||||
@@ -15,6 +15,16 @@ class TestIsSecret:
|
||||
assert _is_secret("TURNSTONE_JWT_SECRET") is True
|
||||
assert _is_secret("AWS_SECRET_ACCESS_KEY") is True
|
||||
|
||||
def test_tool_config_paths_scrubbed(self):
|
||||
"""Tool-config env vars whose target files load executable
|
||||
directives must be scrubbed even though they don't match a
|
||||
secret-suffix pattern. Defence-in-depth alongside on-CLI
|
||||
``--no-config`` for ripgrep and friends."""
|
||||
assert _is_secret("RIPGREP_CONFIG_PATH") is True
|
||||
assert _is_secret("GIT_CONFIG") is True
|
||||
assert _is_secret("GIT_CONFIG_GLOBAL") is True
|
||||
assert _is_secret("GIT_CONFIG_SYSTEM") is True
|
||||
|
||||
def test_suffix_matching(self):
|
||||
assert _is_secret("MY_CUSTOM_API_KEY") is True
|
||||
assert _is_secret("DB_PASSWORD") is True
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
"""Unit tests for ``turnstone.core.history_decoration``.
|
||||
|
||||
The decoration helpers are shared between two surfaces — interactive's
|
||||
SSE replay (``_build_history``) and the lifted ``/history`` REST
|
||||
endpoint (``make_history_handler``, used by both interactive and
|
||||
coord). Pinning the wire shape here lets a future schema/projection
|
||||
change land in one file rather than spread across the two surfaces.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from turnstone.core.history_decoration import (
|
||||
build_output_assessment_payload,
|
||||
build_verdict_payload,
|
||||
decorate_history_messages,
|
||||
decorate_tool_call,
|
||||
)
|
||||
|
||||
|
||||
class TestBuildVerdictPayload:
|
||||
"""The wire-shape projection that's the single source of truth for
|
||||
what intent_verdict fields ship to the client."""
|
||||
|
||||
def test_skips_unflagged_baseline(self) -> None:
|
||||
"""``risk_level`` "none" is the unflagged-tool baseline; the
|
||||
client filters those anyway, so projecting None at the wire
|
||||
layer keeps the payload tight on long workstreams."""
|
||||
row = {"risk_level": "none", "recommendation": "approve", "tier": "heuristic"}
|
||||
assert build_verdict_payload(row) is None
|
||||
|
||||
def test_drops_call_id_and_func_name(self) -> None:
|
||||
"""The client already has these on ``tc.id`` / ``tc.name``;
|
||||
re-shipping them per-tool_call would balloon long replays."""
|
||||
row = {
|
||||
"call_id": "call_abc",
|
||||
"func_name": "bash",
|
||||
"risk_level": "medium",
|
||||
"recommendation": "review",
|
||||
"confidence": 0.8,
|
||||
"intent_summary": "summary",
|
||||
"tier": "heuristic",
|
||||
}
|
||||
out = build_verdict_payload(row)
|
||||
assert out is not None
|
||||
assert "call_id" not in out
|
||||
assert "func_name" not in out
|
||||
# Sanity — the kept fields are the ones renderVerdictBadge reads.
|
||||
assert out["risk_level"] == "medium"
|
||||
assert out["recommendation"] == "review"
|
||||
assert out["confidence"] == 0.8
|
||||
assert out["intent_summary"] == "summary"
|
||||
assert out["tier"] == "heuristic"
|
||||
|
||||
def test_includes_reasoning_for_either_tier_when_present(self) -> None:
|
||||
"""Heuristic verdicts in this project emit structured
|
||||
rationales (one per matched pattern) — e.g.
|
||||
``policy.py`` writes a reasoning string per heuristic hit.
|
||||
Ship the field for either tier when it has content; only
|
||||
omit when the row didn't write one."""
|
||||
for tier in ("heuristic", "llm"):
|
||||
row = {
|
||||
"risk_level": "high",
|
||||
"tier": tier,
|
||||
"reasoning": "The command exfiltrates ~/.ssh/id_rsa over an external connection.",
|
||||
}
|
||||
out = build_verdict_payload(row)
|
||||
assert out is not None
|
||||
assert "id_rsa" in out["reasoning"]
|
||||
|
||||
def test_omits_reasoning_when_empty(self) -> None:
|
||||
"""An absent / empty reasoning string shouldn't ship as
|
||||
``reasoning: ""`` — the rationale ``<details>`` block on the
|
||||
client renders an empty disclosure when the field is present
|
||||
but empty."""
|
||||
row = {"risk_level": "high", "tier": "heuristic", "reasoning": ""}
|
||||
out = build_verdict_payload(row)
|
||||
assert out is not None
|
||||
assert "reasoning" not in out
|
||||
|
||||
def test_includes_judge_model_when_present(self) -> None:
|
||||
"""``judge_model`` rides through so the batch tier badge can
|
||||
render ``⚖ llm:claude-haiku-4`` on history-only replays
|
||||
rather than the bare ``⚖ llm`` label."""
|
||||
row = {"risk_level": "high", "tier": "llm", "judge_model": "claude-haiku-4"}
|
||||
out = build_verdict_payload(row)
|
||||
assert out is not None
|
||||
assert out["judge_model"] == "claude-haiku-4"
|
||||
|
||||
def test_omits_judge_model_when_empty(self) -> None:
|
||||
row = {"risk_level": "medium", "tier": "heuristic", "judge_model": ""}
|
||||
out = build_verdict_payload(row)
|
||||
assert out is not None
|
||||
assert "judge_model" not in out
|
||||
|
||||
|
||||
class TestBuildOutputAssessmentPayload:
|
||||
"""Output-guard wire shape — flags decoded from JSON string at
|
||||
this layer so the client never has to parse twice."""
|
||||
|
||||
def test_skips_unflagged_baseline(self) -> None:
|
||||
row = {"risk_level": "none", "flags": "[]"}
|
||||
assert build_output_assessment_payload(row) is None
|
||||
|
||||
def test_decodes_flags_from_json(self) -> None:
|
||||
row = {"risk_level": "high", "flags": '["api_key","email"]', "redacted": 1}
|
||||
out = build_output_assessment_payload(row)
|
||||
assert out is not None
|
||||
assert out["flags"] == ["api_key", "email"]
|
||||
assert out["redacted"] is True
|
||||
assert out["risk_level"] == "high"
|
||||
|
||||
def test_handles_malformed_flags_json(self) -> None:
|
||||
"""Bad JSON in ``flags`` must not block the rest of the
|
||||
assessment from rendering — degrade to empty list."""
|
||||
row = {"risk_level": "medium", "flags": "not-json", "redacted": 0}
|
||||
out = build_output_assessment_payload(row)
|
||||
assert out is not None
|
||||
assert out["flags"] == []
|
||||
assert out["redacted"] is False
|
||||
|
||||
|
||||
class TestDecorateToolCall:
|
||||
"""In-place mutation of either OpenAI-format or flattened tool_call
|
||||
entries — both shapes carry ``id`` at the top level."""
|
||||
|
||||
def test_attaches_verdict_when_present(self) -> None:
|
||||
tc: dict[str, object] = {"id": "call_1", "function": {"name": "bash", "arguments": "{}"}}
|
||||
verdicts = {
|
||||
"call_1": {
|
||||
"risk_level": "medium",
|
||||
"recommendation": "review",
|
||||
"confidence": 0.7,
|
||||
"intent_summary": "summary",
|
||||
"tier": "heuristic",
|
||||
}
|
||||
}
|
||||
decorate_tool_call(tc, verdicts, {})
|
||||
assert "verdict" in tc
|
||||
assert tc["verdict"]["risk_level"] == "medium" # type: ignore[index]
|
||||
|
||||
def test_skips_when_no_call_id_match(self) -> None:
|
||||
tc: dict[str, object] = {"id": "call_other", "name": "bash"}
|
||||
verdicts = {
|
||||
"call_1": {"risk_level": "medium", "tier": "heuristic"},
|
||||
}
|
||||
decorate_tool_call(tc, verdicts, {})
|
||||
assert "verdict" not in tc
|
||||
|
||||
def test_skips_unflagged_verdict(self) -> None:
|
||||
"""``build_verdict_payload`` returns None for unflagged rows;
|
||||
decorate_tool_call must not stamp ``verdict`` in that case."""
|
||||
tc: dict[str, object] = {"id": "call_1", "name": "bash"}
|
||||
verdicts = {"call_1": {"risk_level": "none", "tier": "heuristic"}}
|
||||
decorate_tool_call(tc, verdicts, {})
|
||||
assert "verdict" not in tc
|
||||
|
||||
def test_handles_empty_id(self) -> None:
|
||||
"""A tool_call with no id can't be paired against the lookup
|
||||
table — must not raise (or stamp the wrong row's verdict)."""
|
||||
tc: dict[str, object] = {"id": "", "name": "bash"}
|
||||
verdicts = {"call_1": {"risk_level": "high", "tier": "heuristic"}}
|
||||
decorate_tool_call(tc, verdicts, {})
|
||||
assert "verdict" not in tc
|
||||
|
||||
|
||||
class TestDecorateHistoryMessages:
|
||||
"""End-to-end mutation of a /history-shaped message list — covers
|
||||
the full transform applied by ``make_history_handler``."""
|
||||
|
||||
def test_decorates_tool_calls_and_marks_truncated(self) -> None:
|
||||
verdicts = {
|
||||
"call_a": {
|
||||
"risk_level": "high",
|
||||
"recommendation": "deny",
|
||||
"confidence": 0.95,
|
||||
"intent_summary": "exfil",
|
||||
"tier": "llm",
|
||||
"reasoning": "ssh key access",
|
||||
}
|
||||
}
|
||||
assessments = {
|
||||
"call_a": {"risk_level": "high", "flags": '["secret"]', "redacted": 1},
|
||||
}
|
||||
# Tool result content of exactly TOOL_RESULT_STORAGE_CAP chars
|
||||
# hits the storage cap (longer is impossible — storage clamps
|
||||
# at the cap). Reference the constant rather than a literal so
|
||||
# this test stays correct if the cap moves again.
|
||||
from turnstone.core.history_decoration import TOOL_RESULT_STORAGE_CAP
|
||||
|
||||
truncated_content = "x" * TOOL_RESULT_STORAGE_CAP
|
||||
messages: list[dict[str, object]] = [
|
||||
{"role": "user", "content": "hi"},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "running",
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "call_a",
|
||||
"function": {"name": "bash", "arguments": "{}"},
|
||||
}
|
||||
],
|
||||
},
|
||||
{"role": "tool", "tool_call_id": "call_a", "content": truncated_content},
|
||||
{"role": "tool", "tool_call_id": "call_b", "content": "short"},
|
||||
]
|
||||
decorate_history_messages(messages, verdicts, assessments)
|
||||
# Assistant tool_calls got both decorations.
|
||||
tc = messages[1]["tool_calls"][0] # type: ignore[index]
|
||||
assert tc["verdict"]["risk_level"] == "high"
|
||||
assert tc["verdict"]["tier"] == "llm"
|
||||
assert "reasoning" in tc["verdict"]
|
||||
assert tc["output_assessment"]["flags"] == ["secret"]
|
||||
assert tc["output_assessment"]["redacted"] is True
|
||||
# Truncated tool message got the flag; the short one did not.
|
||||
assert messages[2].get("truncated") is True
|
||||
assert "truncated" not in messages[3]
|
||||
|
||||
def test_no_op_on_empty_indexes(self) -> None:
|
||||
"""When neither table has rows for the workstream, the wire
|
||||
shape passes through unchanged — replay must degrade
|
||||
gracefully when verdict storage is empty / unavailable."""
|
||||
messages: list[dict[str, object]] = [
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "",
|
||||
"tool_calls": [{"id": "call_a", "function": {"name": "bash", "arguments": "{}"}}],
|
||||
},
|
||||
]
|
||||
decorate_history_messages(messages, {}, {})
|
||||
tc = messages[0]["tool_calls"][0] # type: ignore[index]
|
||||
assert "verdict" not in tc
|
||||
assert "output_assessment" not in tc
|
||||
@@ -1,6 +1,9 @@
|
||||
"""Tests for turnstone.core.memory_relevance — scoring, formatting, context extraction."""
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
from turnstone.core.memory_relevance import (
|
||||
MemoryConfig,
|
||||
build_memory_context,
|
||||
extract_recent_context,
|
||||
score_memories,
|
||||
@@ -192,3 +195,265 @@ class TestExtractRecentContext:
|
||||
|
||||
def test_empty_messages(self):
|
||||
assert extract_recent_context([]) == ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Composition candidate-selection (_init_system_messages)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_mem(name: str, content: str = "", memory_id: str | None = None) -> dict[str, str]:
|
||||
return {
|
||||
"name": name,
|
||||
"memory_id": memory_id or f"mid_{name}",
|
||||
"type": "project",
|
||||
"scope": "global",
|
||||
"scope_id": "",
|
||||
"description": "",
|
||||
"content": content or name,
|
||||
"updated": "2024-01-01T00:00:00",
|
||||
}
|
||||
|
||||
|
||||
def _make_session(fetch_limit: int = 5, relevance_k: int = 3, **kwargs: object):
|
||||
"""Composition tests need a real ChatSession (constructor calls
|
||||
``_init_system_messages`` once, unpatched, before the test gets a chance
|
||||
to install patches). ``tmp_db`` initializes the storage singleton that
|
||||
constructor needs; tests then patch the visibility helpers and call
|
||||
``_init_system_messages`` a second time to exercise the new logic.
|
||||
"""
|
||||
from tests._helpers import make_chat_session
|
||||
|
||||
return make_chat_session(
|
||||
memory_config=MemoryConfig(fetch_limit=fetch_limit, relevance_k=relevance_k),
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
|
||||
class TestCompositionCandidateSelection:
|
||||
"""Verify the query-aware candidate set in _init_system_messages."""
|
||||
|
||||
def test_recency_ceiling_regression(self, tmp_db):
|
||||
"""Old relevant memory not in recency top-N still injected via search path."""
|
||||
session = _make_session(fetch_limit=5, relevance_k=3)
|
||||
session.messages = [{"role": "user", "content": "postgres database configuration"}]
|
||||
|
||||
old_mem = _make_mem(
|
||||
"ancient_db_config",
|
||||
content="postgres database configuration connection host port",
|
||||
memory_id="m_old",
|
||||
)
|
||||
# Recency top-5 do not include old_mem
|
||||
recent = [_make_mem(f"recent_{i}", memory_id=f"mr{i}") for i in range(5)]
|
||||
|
||||
with (
|
||||
patch.object(session, "_search_visible_memories", return_value=[old_mem]),
|
||||
patch.object(session, "_list_visible_memories", return_value=recent),
|
||||
):
|
||||
session._init_system_messages()
|
||||
|
||||
joined = "\n".join(m["content"] for m in session.system_messages if m["role"] == "system")
|
||||
# With the fix, old_mem enters the candidate pool via search and wins BM25
|
||||
assert "ancient_db_config" in joined
|
||||
|
||||
def test_empty_query_falls_back_to_recency(self, tmp_db):
|
||||
"""No user messages → empty context → recency path, search never called."""
|
||||
session = _make_session()
|
||||
session.messages = [] # extract_recent_context returns ""
|
||||
|
||||
recency = [_make_mem("note_alpha"), _make_mem("note_beta")]
|
||||
|
||||
with (
|
||||
patch.object(session, "_list_visible_memories", return_value=recency),
|
||||
patch.object(session, "_search_visible_memories") as search_mock,
|
||||
):
|
||||
session._init_system_messages()
|
||||
|
||||
search_mock.assert_not_called()
|
||||
joined = "\n".join(m["content"] for m in session.system_messages if m["role"] == "system")
|
||||
assert "note_alpha" in joined
|
||||
|
||||
def test_sparse_match_union_fills_candidate_pool(self, tmp_db):
|
||||
"""Search returning < fetch_limit results unions with recency fillers."""
|
||||
session = _make_session(fetch_limit=5, relevance_k=4)
|
||||
session.messages = [{"role": "user", "content": "unique_term xyzzy"}]
|
||||
|
||||
hit_a = _make_mem("hit_alpha", content="unique_term xyzzy alpha", memory_id="m_ha")
|
||||
hit_b = _make_mem("hit_beta", content="unique_term xyzzy beta", memory_id="m_hb")
|
||||
search_hits = [hit_a, hit_b] # 2 < fetch_limit=5 → triggers union
|
||||
|
||||
# Recency overlaps on hit_a/hit_b and adds 3 fillers
|
||||
filler = [_make_mem(f"filler_{i}", memory_id=f"mf{i}") for i in range(3)]
|
||||
recency = [hit_a, hit_b] + filler
|
||||
|
||||
with (
|
||||
patch.object(session, "_search_visible_memories", return_value=search_hits),
|
||||
patch.object(session, "_list_visible_memories", return_value=recency),
|
||||
):
|
||||
session._init_system_messages()
|
||||
|
||||
joined = "\n".join(m["content"] for m in session.system_messages if m["role"] == "system")
|
||||
# Both hits match "unique_term xyzzy" well → appear after BM25 ranking
|
||||
assert "hit_alpha" in joined
|
||||
assert "hit_beta" in joined
|
||||
|
||||
def test_recency_preserved_when_search_returns_noise_above_relevance_k(self, tmp_db):
|
||||
"""Pool guarantee: recency-50 always reaches BM25, even when search
|
||||
returns enough noise hits to clear ``relevance_k``.
|
||||
|
||||
Closes the narrow regression vs. the original bug — without the
|
||||
``fetch_limit`` threshold, a stopword-dominated cap-search that
|
||||
returned >= relevance_k irrelevant hits would short-circuit and
|
||||
evict the recency-only memory the bug had been surfacing.
|
||||
"""
|
||||
session = _make_session(fetch_limit=10, relevance_k=3)
|
||||
session.messages = [{"role": "user", "content": "configure host"}]
|
||||
|
||||
# Search returns relevance_k=3 noise hits — enough to skip recency
|
||||
# under the OLD threshold, not enough to fill fetch_limit=10.
|
||||
noise = [
|
||||
_make_mem(f"noise_{i}", content="generic content", memory_id=f"mn{i}") for i in range(3)
|
||||
]
|
||||
# The memory the user actually wants — distinctive, in recency,
|
||||
# but its content doesn't share any token with the noise hits.
|
||||
wanted = _make_mem(
|
||||
"host_config_v2",
|
||||
content="host=localhost port=5432 db=production",
|
||||
memory_id="m_wanted",
|
||||
)
|
||||
recency = [wanted] + [_make_mem(f"recent_{i}", memory_id=f"mr{i}") for i in range(5)]
|
||||
|
||||
with (
|
||||
patch.object(session, "_search_visible_memories", return_value=noise),
|
||||
patch.object(session, "_list_visible_memories", return_value=recency),
|
||||
):
|
||||
session._init_system_messages()
|
||||
|
||||
joined = "\n".join(m["content"] for m in session.system_messages if m["role"] == "system")
|
||||
# ``wanted`` reached BM25 via the union and matched "host" → injected.
|
||||
assert "host_config_v2" in joined
|
||||
|
||||
def test_recency_tail_preserved_when_search_adds_distinct_hits(self, tmp_db):
|
||||
"""SUPERSET invariant: every recency item is in the candidate pool
|
||||
when search adds hits, even if the resulting union exceeds
|
||||
fetch_limit. Truncating the union at fetch_limit (the prior
|
||||
behavior) evicted the recency tail — which is exactly where
|
||||
ancient-but-recently-touched memories live, the recall this PR
|
||||
sets out to improve.
|
||||
"""
|
||||
session = _make_session(fetch_limit=10, relevance_k=3)
|
||||
session.messages = [{"role": "user", "content": "alpha"}]
|
||||
|
||||
# 5 search hits, none of which appear in recency.
|
||||
search_hits = [
|
||||
_make_mem(f"search_{i}", content="alpha", memory_id=f"ms{i}") for i in range(5)
|
||||
]
|
||||
# 10 recency items; without the union uncap, the 5 oldest of these
|
||||
# would be displaced by the 5 search hits.
|
||||
recency = [_make_mem(f"recency_{i}", memory_id=f"mr{i}") for i in range(10)]
|
||||
|
||||
with (
|
||||
patch.object(session, "_search_visible_memories", return_value=search_hits),
|
||||
patch.object(session, "_list_visible_memories", return_value=recency),
|
||||
):
|
||||
candidates, source = session._select_memory_candidates("alpha")
|
||||
|
||||
candidate_ids = {c["memory_id"] for c in candidates}
|
||||
# Pool is search_hits ∪ recency — 15 items, no truncation.
|
||||
assert len(candidates) == 15
|
||||
assert source == "union"
|
||||
# Every recency item present (no tail eviction).
|
||||
for i in range(10):
|
||||
assert f"mr{i}" in candidate_ids, f"recency item {i} evicted"
|
||||
# And every search hit is also in the pool.
|
||||
for i in range(5):
|
||||
assert f"ms{i}" in candidate_ids, f"search hit {i} missing"
|
||||
|
||||
def test_coord_scope_isolated_visibility(self, tmp_db):
|
||||
"""Coord composition queries the coord scope alone, never the
|
||||
global/workstream/user union."""
|
||||
from turnstone.core.workstream import WorkstreamKind
|
||||
|
||||
coord = _make_session(
|
||||
fetch_limit=5,
|
||||
relevance_k=3,
|
||||
ws_id="coord-1",
|
||||
user_id="user-1",
|
||||
kind=WorkstreamKind.COORDINATOR,
|
||||
)
|
||||
scopes = coord._visible_scopes()
|
||||
assert scopes == [("coordinator", "coord-1")]
|
||||
# And: search uses those same scopes (no global/user fan-in)
|
||||
coord.messages = [{"role": "user", "content": "anything"}]
|
||||
with patch(
|
||||
"turnstone.core.session.search_visible_structured_memories",
|
||||
return_value=[],
|
||||
) as search_mock:
|
||||
coord._search_visible_memories("anything", limit=5)
|
||||
search_mock.assert_called_once()
|
||||
# Second positional arg is the scopes list
|
||||
assert search_mock.call_args.args[1] == [("coordinator", "coord-1")]
|
||||
|
||||
|
||||
class TestMemorySearchToolExecution:
|
||||
"""End-to-end test of ``memory(action='search')`` through _exec_memory.
|
||||
|
||||
Drives the actual tool dispatch (not just the storage facade) so the
|
||||
OR-of-terms fix and the coalesced ``memory.search`` log get exercised
|
||||
together.
|
||||
"""
|
||||
|
||||
def test_search_action_returns_or_of_terms_results(self, tmp_db):
|
||||
"""Multi-word query returns rows where ANY term matches — not all."""
|
||||
from turnstone.core.memory import save_structured_memory
|
||||
|
||||
save_structured_memory("postgres_notes", "host=localhost port=5432")
|
||||
save_structured_memory("redis_notes", "host=redis port=6379")
|
||||
save_structured_memory("unrelated", "completely different")
|
||||
|
||||
session = _make_session()
|
||||
item = session._prepare_memory(
|
||||
"call-1",
|
||||
{"action": "search", "query": "postgres no_such_word_a no_such_word_b"},
|
||||
)
|
||||
# Sanity: prepare returned a search-ready dispatch (not an error item)
|
||||
assert item.get("action") == "search"
|
||||
|
||||
call_id, msg = session._exec_memory(item)
|
||||
assert call_id == "call-1"
|
||||
assert "postgres_notes" in msg
|
||||
# Other memories don't match any query term
|
||||
assert "unrelated" not in msg
|
||||
|
||||
|
||||
class TestPerTurnSearchCache:
|
||||
"""The per-turn cache spares redundant SQL across mid-turn rebuilds."""
|
||||
|
||||
def test_repeated_search_in_same_turn_hits_cache(self, tmp_db):
|
||||
from turnstone.core.memory import save_structured_memory
|
||||
|
||||
save_structured_memory("hello_mem", "alpha beta gamma")
|
||||
session = _make_session()
|
||||
with patch(
|
||||
"turnstone.core.session.search_visible_structured_memories",
|
||||
return_value=[],
|
||||
) as backend_mock:
|
||||
session._search_visible_memories("alpha beta", limit=5)
|
||||
session._search_visible_memories("alpha beta", limit=5)
|
||||
session._search_visible_memories("alpha beta", limit=5)
|
||||
# 3 calls but only 1 backend hit — cache absorbed the rest
|
||||
assert backend_mock.call_count == 1
|
||||
|
||||
def test_user_turn_invalidates_cache(self, tmp_db):
|
||||
from turnstone.core.memory import save_structured_memory
|
||||
|
||||
save_structured_memory("hello_mem", "alpha")
|
||||
session = _make_session()
|
||||
with patch(
|
||||
"turnstone.core.session.search_visible_structured_memories",
|
||||
return_value=[],
|
||||
) as backend_mock:
|
||||
session._search_visible_memories("alpha", limit=5)
|
||||
session._invalidate_memory_cache() # simulates new user turn
|
||||
session._search_visible_memories("alpha", limit=5)
|
||||
assert backend_mock.call_count == 2
|
||||
|
||||
@@ -8,6 +8,7 @@ from turnstone.core.metacognition import (
|
||||
NUDGE_RESUME,
|
||||
NUDGE_START,
|
||||
NUDGE_TOOL_ERROR,
|
||||
RepeatDetector,
|
||||
detect_completion,
|
||||
detect_correction,
|
||||
format_nudge,
|
||||
@@ -308,3 +309,70 @@ class TestRepeatNudge:
|
||||
"""Repeat nudge should fire even with zero memories."""
|
||||
state: dict[str, float] = {}
|
||||
assert should_nudge("repeat", state, message_count=5, memory_count=0) is True
|
||||
|
||||
|
||||
class TestRepeatDetector:
|
||||
"""Repeat-detection streak machine — fires only when the same signature
|
||||
is recorded ``threshold`` times *consecutively* (default 3). Recording
|
||||
any different signature resets the streak, so an interrupted repeat
|
||||
isn't flagged as a stuck loop."""
|
||||
|
||||
def test_below_threshold_does_not_fire(self):
|
||||
det = RepeatDetector()
|
||||
assert det.record("a") is False
|
||||
assert det.record("a") is False # second call still under threshold
|
||||
|
||||
def test_at_threshold_fires(self):
|
||||
det = RepeatDetector()
|
||||
det.record("a")
|
||||
det.record("a")
|
||||
assert det.record("a") is True
|
||||
|
||||
def test_continues_to_fire_past_threshold(self):
|
||||
# Caller is responsible for clearing after a fire — until they do,
|
||||
# subsequent identical calls keep returning True.
|
||||
det = RepeatDetector()
|
||||
det.record("a")
|
||||
det.record("a")
|
||||
assert det.record("a") is True
|
||||
assert det.record("a") is True
|
||||
|
||||
def test_clear_resets_count(self):
|
||||
det = RepeatDetector()
|
||||
det.record("a")
|
||||
det.record("a")
|
||||
det.clear()
|
||||
assert det.record("a") is False # back to 1 after clear
|
||||
|
||||
def test_intervening_sig_resets_streak(self):
|
||||
# The streak is consecutive: recording any other sig mid-streak
|
||||
# discards the in-progress count. An alternating pattern like
|
||||
# [A, A, B, A, A] is two short streaks of 2, not a streak of 4.
|
||||
det = RepeatDetector()
|
||||
det.record("a")
|
||||
det.record("a")
|
||||
assert det.record("b") is False # b at count 1; a's streak is gone
|
||||
assert det.record("a") is False # a starts fresh at 1
|
||||
assert det.record("a") is False # a at 2
|
||||
assert det.record("a") is True # a hits 3 — fresh streak completes
|
||||
|
||||
def test_errored_signature_counts_toward_repeat(self):
|
||||
# Regression: when metacog was split out of the system message,
|
||||
# the error-output skip got reintroduced and stuck-loop detection
|
||||
# silently broke for tools that kept failing. Detector itself is
|
||||
# signature-only — error vs. success is the caller's policy.
|
||||
det = RepeatDetector()
|
||||
# Caller records an errored call's sig the same as a successful one;
|
||||
# the streak is what matters.
|
||||
for _ in range(3):
|
||||
last = det.record("bash:ls /nonexistent")
|
||||
assert last is True
|
||||
|
||||
def test_custom_threshold(self):
|
||||
det = RepeatDetector(threshold=2)
|
||||
assert det.record("a") is False
|
||||
assert det.record("a") is True
|
||||
|
||||
def test_threshold_one_fires_immediately(self):
|
||||
det = RepeatDetector(threshold=1)
|
||||
assert det.record("a") is True
|
||||
|
||||
+144
-12
@@ -770,16 +770,76 @@ class TestRegistryReload:
|
||||
assert reg.has_alias("b")
|
||||
assert reg.default == "b"
|
||||
|
||||
def test_reload_clears_clients(self) -> None:
|
||||
models = {"a": ModelConfig("a", "http://x/v1", "key", "m")}
|
||||
def test_reload_keeps_clients_when_connection_target_unchanged(self) -> None:
|
||||
"""Selective teardown: a model edit that leaves base_url / api_key /
|
||||
provider intact (e.g. admin tweaks the underlying ``model`` name or
|
||||
``temperature``) keeps the cached HTTP client warm — no need to
|
||||
re-establish TLS+pool when the endpoint is the same."""
|
||||
models = {"a": ModelConfig("a", "http://x/v1", "key", "m1", provider="openai")}
|
||||
reg = ModelRegistry(models=models, default="a")
|
||||
# Force client creation
|
||||
reg.get_client("a")
|
||||
assert "a" in reg._clients
|
||||
client_before = reg._clients["a"]
|
||||
provider_before = reg.get_provider("a")
|
||||
|
||||
# Same endpoint (base_url, api_key, provider), only ``model`` changed.
|
||||
new_models = {"a": ModelConfig("a", "http://x/v1", "key", "m2", provider="openai")}
|
||||
reg.reload(new_models, "a")
|
||||
|
||||
assert "a" in reg._clients
|
||||
assert reg._clients["a"] is client_before
|
||||
assert "a" in reg._providers
|
||||
assert reg._providers["a"] is provider_before
|
||||
|
||||
def test_reload_drops_client_when_base_url_changes(self) -> None:
|
||||
"""A ``base_url`` change drops the cached client (different
|
||||
endpoint = new connection) but keeps the cached provider —
|
||||
``LLMProvider`` is keyed only on the provider string, which
|
||||
didn't change."""
|
||||
models = {"a": ModelConfig("a", "http://x/v1", "key", "m", provider="openai")}
|
||||
reg = ModelRegistry(models=models, default="a")
|
||||
reg.get_client("a")
|
||||
provider_before = reg.get_provider("a")
|
||||
|
||||
new_models = {"a": ModelConfig("a", "http://y/v1", "key", "m", provider="openai")}
|
||||
reg.reload(new_models, "a")
|
||||
|
||||
# Reload with same models — clients should be cleared
|
||||
reg.reload(dict(models), "a")
|
||||
assert "a" not in reg._clients
|
||||
assert "a" in reg._providers
|
||||
assert reg._providers["a"] is provider_before
|
||||
|
||||
def test_reload_drops_provider_when_provider_string_changes(self) -> None:
|
||||
"""A provider-type swap (e.g. openai → anthropic) drops both the
|
||||
client AND the provider so the next resolve picks up the right
|
||||
``LLMProvider`` implementation against the new SDK."""
|
||||
models = {"a": ModelConfig("a", "http://x/v1", "key", "m", provider="openai")}
|
||||
reg = ModelRegistry(models=models, default="a")
|
||||
reg.get_client("a")
|
||||
reg.get_provider("a")
|
||||
|
||||
new_models = {"a": ModelConfig("a", "http://x/v1", "key", "m", provider="anthropic")}
|
||||
reg.reload(new_models, "a")
|
||||
|
||||
assert "a" not in reg._clients
|
||||
assert "a" not in reg._providers
|
||||
|
||||
def test_reload_drops_clients_for_removed_aliases(self) -> None:
|
||||
"""Aliases removed from the registry must release their cached
|
||||
clients — otherwise a deleted endpoint's connection pool would
|
||||
outlive the alias indefinitely."""
|
||||
models = {
|
||||
"a": ModelConfig("a", "http://x/v1", "key", "m"),
|
||||
"b": ModelConfig("b", "http://y/v1", "key", "m"),
|
||||
}
|
||||
reg = ModelRegistry(models=models, default="a")
|
||||
reg.get_client("a")
|
||||
reg.get_client("b")
|
||||
|
||||
# Drop "b" entirely.
|
||||
new_models = {"a": ModelConfig("a", "http://x/v1", "key", "m")}
|
||||
reg.reload(new_models, "a")
|
||||
|
||||
assert "a" in reg._clients # unchanged endpoint, kept warm
|
||||
assert "b" not in reg._clients
|
||||
|
||||
def test_reload_validates_default(self) -> None:
|
||||
models_a = {"a": ModelConfig("a", "x", "x", "m")}
|
||||
@@ -1066,24 +1126,57 @@ class TestSessionAgentModel:
|
||||
def _captured_effort(captured: dict[str, Any]) -> str | None:
|
||||
"""Pull reasoning_effort out of provider-specific shapes.
|
||||
|
||||
openai-compatible servers receive it via extra_body.chat_template_kwargs;
|
||||
commercial providers receive it as a top-level kwarg.
|
||||
Chat Completions delivers it as a top-level ``reasoning_effort`` kwarg
|
||||
(when the model's caps permit it). Operators who route reasoning_effort
|
||||
through ``chat_template_kwargs`` (gpt-oss-style local templates) get
|
||||
it inside ``extra_body.chat_template_kwargs``.
|
||||
"""
|
||||
if "reasoning_effort" in captured:
|
||||
return captured["reasoning_effort"]
|
||||
eb = captured.get("extra_body") or {}
|
||||
ctk = eb.get("chat_template_kwargs") or {}
|
||||
return ctk.get("reasoning_effort") or captured.get("reasoning_effort")
|
||||
return ctk.get("reasoning_effort")
|
||||
|
||||
@staticmethod
|
||||
def _effort_caps() -> dict[str, Any]:
|
||||
"""Capabilities that allow Chat-Completions reasoning_effort to flow."""
|
||||
return {
|
||||
"reasoning_effort_values": [
|
||||
"minimal",
|
||||
"low",
|
||||
"medium",
|
||||
"high",
|
||||
"max",
|
||||
],
|
||||
}
|
||||
|
||||
def _three_model_registry(self, **kwargs: Any) -> ModelRegistry:
|
||||
caps = self._effort_caps()
|
||||
return ModelRegistry(
|
||||
models={
|
||||
"main": ModelConfig(
|
||||
"main", "http://m/v1", "k", "main-model", provider="openai-compatible"
|
||||
"main",
|
||||
"http://m/v1",
|
||||
"k",
|
||||
"main-model",
|
||||
provider="openai-compatible",
|
||||
capabilities=dict(caps),
|
||||
),
|
||||
"smart": ModelConfig(
|
||||
"smart", "http://s/v1", "k", "smart-model", provider="openai-compatible"
|
||||
"smart",
|
||||
"http://s/v1",
|
||||
"k",
|
||||
"smart-model",
|
||||
provider="openai-compatible",
|
||||
capabilities=dict(caps),
|
||||
),
|
||||
"fast": ModelConfig(
|
||||
"fast", "http://f/v1", "k", "fast-model", provider="openai-compatible"
|
||||
"fast",
|
||||
"http://f/v1",
|
||||
"k",
|
||||
"fast-model",
|
||||
provider="openai-compatible",
|
||||
capabilities=dict(caps),
|
||||
),
|
||||
},
|
||||
default="main",
|
||||
@@ -1189,6 +1282,45 @@ class TestSessionAgentModel:
|
||||
session._run_agent([{"role": "user", "content": "x"}], label="plan", agent_alias="fast")
|
||||
assert captured["model"] == "fast-model"
|
||||
|
||||
def test_session_fallback_inherits_primary_alias_for_caps(self) -> None:
|
||||
"""When _run_agent has no registry agent route, it must fall back to
|
||||
the session's primary alias for capability and server_compat lookup —
|
||||
otherwise per-model caps (reasoning_effort_values, server_compat) get
|
||||
silently dropped on the agent path."""
|
||||
reg = self._three_model_registry() # no agent_model / plan_model set
|
||||
session = _make_session(registry=reg, model_alias="main")
|
||||
# Probe what _run_agent passes to _provider_extra_params and
|
||||
# _resolve_capabilities by recording the model_alias on each call.
|
||||
captured_extra_alias: list[str | None] = []
|
||||
captured_resolve_alias: list[str | None] = []
|
||||
original_extra = session._provider_extra_params
|
||||
original_resolve = session._resolve_capabilities
|
||||
|
||||
def spy_extra(*args: Any, **kwargs: Any) -> Any:
|
||||
captured_extra_alias.append(kwargs.get("model_alias"))
|
||||
return original_extra(*args, **kwargs)
|
||||
|
||||
def spy_resolve(*args: Any, **kwargs: Any) -> Any:
|
||||
# _resolve_capabilities(provider, model, alias)
|
||||
alias = args[2] if len(args) >= 3 else kwargs.get("alias")
|
||||
captured_resolve_alias.append(alias)
|
||||
return original_resolve(*args, **kwargs)
|
||||
|
||||
session._provider_extra_params = spy_extra # type: ignore[method-assign]
|
||||
session._resolve_capabilities = spy_resolve # type: ignore[method-assign]
|
||||
|
||||
self._capture_on(session.client) # patch client.chat.completions.create
|
||||
session._run_agent([{"role": "user", "content": "x"}], label="plan")
|
||||
|
||||
assert captured_extra_alias and captured_extra_alias[-1] == "main", (
|
||||
f"agent fallback path did not inherit primary alias for extra_params: "
|
||||
f"{captured_extra_alias!r}"
|
||||
)
|
||||
assert captured_resolve_alias and captured_resolve_alias[-1] == "main", (
|
||||
f"agent fallback path did not inherit primary alias for caps: "
|
||||
f"{captured_resolve_alias!r}"
|
||||
)
|
||||
|
||||
def test_invalid_alias_raises_in_run_agent(self) -> None:
|
||||
"""Defence-in-depth: _prepare_* validates first, but _run_agent
|
||||
rejects unknown aliases too rather than silently falling back."""
|
||||
|
||||
@@ -0,0 +1,274 @@
|
||||
"""``models_changed`` SSE fanout coverage.
|
||||
|
||||
The console pushes a ``models_changed`` cluster event whenever a model
|
||||
definition is created / updated / deleted / reloaded, or whenever a
|
||||
setting in :data:`turnstone.console.server._MODEL_AFFECTING_SETTING_KEYS`
|
||||
is updated or reset. Connected browsers refetch ``/v1/api/models`` on
|
||||
receipt so the home composer dropdown + admin Models → Roles sub-tab
|
||||
reflect alias edits without a manual reload.
|
||||
|
||||
These tests pin two contracts:
|
||||
|
||||
- every model-definition CRUD path emits exactly one ``models_changed``
|
||||
fanout (so the browser stays in sync with the DB);
|
||||
- settings PUT / DELETE only emit the fanout when the key is
|
||||
model-affecting — unrelated keys (e.g. ``session.retention_days``)
|
||||
must not trigger spurious dropdown re-renders across the cluster.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from starlette.applications import Starlette
|
||||
from starlette.middleware import Middleware
|
||||
from starlette.routing import Route
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
from tests._coord_test_helpers import _AuthMiddleware
|
||||
from turnstone.console.server import (
|
||||
_MODEL_AFFECTING_SETTING_KEYS,
|
||||
admin_create_model_definition,
|
||||
admin_delete_model_definition,
|
||||
admin_delete_setting,
|
||||
admin_model_reload,
|
||||
admin_update_model_definition,
|
||||
admin_update_setting,
|
||||
)
|
||||
from turnstone.core.storage._sqlite import SQLiteBackend
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def storage(tmp_path: Any) -> SQLiteBackend:
|
||||
return SQLiteBackend(str(tmp_path / "models_changed.db"))
|
||||
|
||||
|
||||
def _seed(storage: SQLiteBackend, *, definition_id: str, alias: str) -> None:
|
||||
storage.create_model_definition(
|
||||
definition_id=definition_id,
|
||||
alias=alias,
|
||||
model="model-x",
|
||||
provider="openai-compatible",
|
||||
base_url="http://localhost:8000/v1",
|
||||
api_key="sk-test",
|
||||
context_window=8192,
|
||||
capabilities="{}",
|
||||
enabled=True,
|
||||
created_by="admin",
|
||||
)
|
||||
|
||||
|
||||
def _make_client(storage: SQLiteBackend) -> tuple[TestClient, MagicMock]:
|
||||
"""Build a TestClient + return the stub collector for assertion.
|
||||
|
||||
Wires the four model-definition CRUD/reload routes plus the two
|
||||
settings mutation routes. Collector is a MagicMock so each
|
||||
``emit_models_changed`` call lands as a recorded call without
|
||||
spinning up the full SSE listener queue.
|
||||
"""
|
||||
app = Starlette(
|
||||
routes=[
|
||||
Route(
|
||||
"/v1/api/admin/model-definitions",
|
||||
admin_create_model_definition,
|
||||
methods=["POST"],
|
||||
),
|
||||
Route(
|
||||
"/v1/api/admin/model-definitions/reload",
|
||||
admin_model_reload,
|
||||
methods=["POST"],
|
||||
),
|
||||
Route(
|
||||
"/v1/api/admin/model-definitions/{definition_id}",
|
||||
admin_update_model_definition,
|
||||
methods=["PUT"],
|
||||
),
|
||||
Route(
|
||||
"/v1/api/admin/model-definitions/{definition_id}",
|
||||
admin_delete_model_definition,
|
||||
methods=["DELETE"],
|
||||
),
|
||||
Route(
|
||||
"/v1/api/admin/settings/{key:path}",
|
||||
admin_update_setting,
|
||||
methods=["PUT"],
|
||||
),
|
||||
Route(
|
||||
"/v1/api/admin/settings/{key:path}",
|
||||
admin_delete_setting,
|
||||
methods=["DELETE"],
|
||||
),
|
||||
],
|
||||
middleware=[Middleware(_AuthMiddleware)],
|
||||
)
|
||||
app.state.auth_storage = storage
|
||||
app.state.coord_registry = None # CRUD endpoints handle this gracefully
|
||||
collector = MagicMock()
|
||||
collector.get_all_nodes.return_value = []
|
||||
app.state.collector = collector
|
||||
app.state.proxy_client = MagicMock()
|
||||
app.state.config_store = MagicMock()
|
||||
client = TestClient(app)
|
||||
client.headers.update(
|
||||
{
|
||||
"X-Test-User": "admin",
|
||||
"X-Test-Perms": "admin.models,admin.settings",
|
||||
}
|
||||
)
|
||||
return client, collector
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Model-definition CRUD endpoints fan out ``models_changed``
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_create_emits_models_changed(storage: SQLiteBackend) -> None:
|
||||
client, collector = _make_client(storage)
|
||||
resp = client.post(
|
||||
"/v1/api/admin/model-definitions",
|
||||
json={
|
||||
"alias": "fast",
|
||||
"model": "fast-model",
|
||||
"provider": "openai-compatible",
|
||||
"base_url": "http://localhost:9000/v1",
|
||||
"api_key": "sk-x",
|
||||
"context_window": 4096,
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert collector.emit_models_changed.call_count == 1
|
||||
|
||||
|
||||
def test_update_emits_models_changed(storage: SQLiteBackend) -> None:
|
||||
_seed(storage, definition_id="m1", alias="local")
|
||||
client, collector = _make_client(storage)
|
||||
resp = client.put(
|
||||
"/v1/api/admin/model-definitions/m1",
|
||||
json={"model": "swapped-model"},
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert collector.emit_models_changed.call_count == 1
|
||||
|
||||
|
||||
def test_update_with_empty_body_does_not_emit(storage: SQLiteBackend) -> None:
|
||||
"""Empty-body PUT writes no rows + skips the registry refresh — no
|
||||
SSE fanout either, since nothing actually changed."""
|
||||
_seed(storage, definition_id="m1", alias="local")
|
||||
client, collector = _make_client(storage)
|
||||
resp = client.put("/v1/api/admin/model-definitions/m1", json={})
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert collector.emit_models_changed.call_count == 0
|
||||
|
||||
|
||||
def test_delete_emits_models_changed(storage: SQLiteBackend) -> None:
|
||||
_seed(storage, definition_id="m1", alias="local")
|
||||
client, collector = _make_client(storage)
|
||||
resp = client.delete("/v1/api/admin/model-definitions/m1")
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert collector.emit_models_changed.call_count == 1
|
||||
|
||||
|
||||
def test_reload_emits_models_changed(storage: SQLiteBackend) -> None:
|
||||
_seed(storage, definition_id="m1", alias="local")
|
||||
client, collector = _make_client(storage)
|
||||
resp = client.post("/v1/api/admin/model-definitions/reload")
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert collector.emit_models_changed.call_count == 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Settings PUT / DELETE only emit for model-affecting keys
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
# Pinned snapshot of the role-related keys we expect the allowlist to
|
||||
# cover today. The frozenset itself is asserted further down so a
|
||||
# stray addition doesn't silently bypass coverage.
|
||||
_EXPECTED_AFFECTING_KEYS = frozenset(
|
||||
{
|
||||
"model.default_alias",
|
||||
"model.plan_alias",
|
||||
"model.plan_effort",
|
||||
"model.task_alias",
|
||||
"model.task_effort",
|
||||
"coordinator.model_alias",
|
||||
"coordinator.reasoning_effort",
|
||||
"judge.model",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _value_for_key(key: str) -> str:
|
||||
"""Return a registry-valid value for ``key``.
|
||||
|
||||
``reasoning_effort`` keys have a fixed choice list; alias-shaped
|
||||
keys accept arbitrary strings. Avoids per-key custom payloads.
|
||||
"""
|
||||
if (
|
||||
key.endswith("reasoning_effort")
|
||||
or key.endswith("plan_effort")
|
||||
or key.endswith("task_effort")
|
||||
):
|
||||
return "low"
|
||||
return "anything"
|
||||
|
||||
|
||||
def test_affecting_keys_set_matches_expected() -> None:
|
||||
"""Lock in the allowlist so an unintentional removal is caught."""
|
||||
assert _MODEL_AFFECTING_SETTING_KEYS == _EXPECTED_AFFECTING_KEYS
|
||||
|
||||
|
||||
@pytest.mark.parametrize("key", sorted(_EXPECTED_AFFECTING_KEYS))
|
||||
def test_settings_put_emits_for_model_affecting_key(storage: SQLiteBackend, key: str) -> None:
|
||||
client, collector = _make_client(storage)
|
||||
resp = client.put(
|
||||
f"/v1/api/admin/settings/{key}",
|
||||
json={"value": _value_for_key(key)},
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert collector.emit_models_changed.call_count == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize("key", sorted(_EXPECTED_AFFECTING_KEYS))
|
||||
def test_settings_delete_emits_for_model_affecting_key(storage: SQLiteBackend, key: str) -> None:
|
||||
client, collector = _make_client(storage)
|
||||
# Seed a row so DELETE has something to remove (otherwise 404).
|
||||
client.put(
|
||||
f"/v1/api/admin/settings/{key}",
|
||||
json={"value": _value_for_key(key)},
|
||||
)
|
||||
collector.emit_models_changed.reset_mock()
|
||||
resp = client.delete(f"/v1/api/admin/settings/{key}")
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert collector.emit_models_changed.call_count == 1
|
||||
|
||||
|
||||
def test_settings_put_does_not_emit_for_unrelated_key(
|
||||
storage: SQLiteBackend,
|
||||
) -> None:
|
||||
"""Updating a non-model setting (here: a session retention knob)
|
||||
must not trigger a cluster-wide dropdown refresh."""
|
||||
client, collector = _make_client(storage)
|
||||
resp = client.put(
|
||||
"/v1/api/admin/settings/session.retention_days",
|
||||
json={"value": 30},
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert collector.emit_models_changed.call_count == 0
|
||||
|
||||
|
||||
def test_settings_delete_does_not_emit_for_unrelated_key(
|
||||
storage: SQLiteBackend,
|
||||
) -> None:
|
||||
client, collector = _make_client(storage)
|
||||
client.put(
|
||||
"/v1/api/admin/settings/session.retention_days",
|
||||
json={"value": 30},
|
||||
)
|
||||
collector.emit_models_changed.reset_mock()
|
||||
resp = client.delete("/v1/api/admin/settings/session.retention_days")
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert collector.emit_models_changed.call_count == 0
|
||||
@@ -224,6 +224,25 @@ class TestOpenAIProvider:
|
||||
sanitize_messages([original])
|
||||
assert original["content"] is None
|
||||
|
||||
def test_sanitize_messages_strips_underscore_sibling_keys(self) -> None:
|
||||
"""Internal sibling metadata (``_reminders``, ``_reminders_delivered``,
|
||||
``_attachments_meta``, ``_provider_content``) must be stripped
|
||||
before the wire — the OpenAI-compat APIs reject unknown fields."""
|
||||
msgs = [
|
||||
{
|
||||
"role": "user",
|
||||
"content": "hi",
|
||||
"_reminders": [{"type": "correction", "text": "watch"}],
|
||||
"_reminders_delivered": True,
|
||||
"_attachments_meta": [{"kind": "image"}],
|
||||
}
|
||||
]
|
||||
result = sanitize_messages(msgs)
|
||||
assert result == [{"role": "user", "content": "hi"}]
|
||||
assert "_reminders" not in result[0]
|
||||
assert "_reminders_delivered" not in result[0]
|
||||
assert "_attachments_meta" not in result[0]
|
||||
|
||||
# -- sanitize_messages: orphan detection -----------------------------------
|
||||
|
||||
def test_sanitize_orphaned_tool_call_synthesized(self) -> None:
|
||||
@@ -1364,6 +1383,34 @@ class TestProviderFactory:
|
||||
p2 = create_provider("openai")
|
||||
assert p1 is p2
|
||||
|
||||
def test_create_provider_compat_responses_surface(self) -> None:
|
||||
"""openai-compatible + api_surface=responses returns the Responses provider."""
|
||||
from turnstone.core.providers import OpenAIResponsesProvider, create_provider
|
||||
|
||||
provider = create_provider("openai-compatible", api_surface="responses")
|
||||
assert isinstance(provider, OpenAIResponsesProvider)
|
||||
|
||||
def test_create_provider_compat_chat_surface_default(self) -> None:
|
||||
"""openai-compatible defaults to Chat Completions."""
|
||||
from turnstone.core.providers import create_provider
|
||||
|
||||
for surface in (None, "", "chat"):
|
||||
provider = create_provider("openai-compatible", api_surface=surface)
|
||||
assert isinstance(provider, OpenAIChatCompletionsProvider)
|
||||
|
||||
def test_create_provider_invalid_api_surface(self) -> None:
|
||||
from turnstone.core.providers import create_provider
|
||||
|
||||
with pytest.raises(ValueError, match="Unknown api_surface"):
|
||||
create_provider("openai-compatible", api_surface="bogus")
|
||||
|
||||
def test_create_provider_openai_ignores_api_surface(self) -> None:
|
||||
"""Cloud OpenAI is always Responses regardless of api_surface."""
|
||||
from turnstone.core.providers import OpenAIResponsesProvider, create_provider
|
||||
|
||||
provider = create_provider("openai", api_surface="chat")
|
||||
assert isinstance(provider, OpenAIResponsesProvider)
|
||||
|
||||
# -- Google provider -------------------------------------------------------
|
||||
|
||||
def test_create_provider_google(self) -> None:
|
||||
|
||||
@@ -10,6 +10,7 @@ from __future__ import annotations
|
||||
|
||||
import queue
|
||||
import threading
|
||||
import uuid
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
@@ -707,22 +708,26 @@ class TestQueuedAttachmentReservation:
|
||||
mgr.get.return_value = ws
|
||||
return ws, session
|
||||
|
||||
def _queue_with_attachment(self, client, mgr, ws_id: str, filename: str = "q.md"):
|
||||
def _reserve_attachment(self, client, mgr, ws_id: str, filename: str = "q.md"):
|
||||
"""Set up a reserved attachment for the busy-worker tests below.
|
||||
|
||||
The queue-with-attachments path was removed (queued user turns
|
||||
can't carry attachments — see ``AttachmentsNotQueueableError``),
|
||||
so the tests reserve directly via ``reserve_attachments`` to
|
||||
produce the same on-disk state without going through the
|
||||
rejected route path.
|
||||
"""
|
||||
from turnstone.core.memory import reserve_attachments
|
||||
|
||||
aid = _upload(client, ws_id, "userA", filename, b"Q", "text/markdown")
|
||||
ws, session = self._wire_busy_ws(mgr, ws_id)
|
||||
resp = client.post(
|
||||
f"/v1/api/workstreams/{ws_id}/send",
|
||||
json={"message": "queued", "attachment_ids": [aid]},
|
||||
headers=_auth("userA"),
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["status"] == "queued"
|
||||
return aid, body["msg_id"], session
|
||||
msg_id = uuid.uuid4().hex
|
||||
reserve_attachments([aid], msg_id, ws_id, "userA")
|
||||
return aid, msg_id, session
|
||||
|
||||
def test_reserved_attachment_hidden_from_pending_listing(self, app_client):
|
||||
client, mgr = app_client
|
||||
aid, _mid, _session = self._queue_with_attachment(client, mgr, "ws-A")
|
||||
aid, _mid, _session = self._reserve_attachment(client, mgr, "ws-A")
|
||||
resp = client.get("/v1/api/workstreams/ws-A/attachments", headers=_auth("userA"))
|
||||
# Reserved attachment is not in the pending listing
|
||||
ids = [a["attachment_id"] for a in resp.json()["attachments"]]
|
||||
@@ -730,7 +735,7 @@ class TestQueuedAttachmentReservation:
|
||||
|
||||
def test_reserved_attachment_cannot_be_deleted(self, app_client):
|
||||
client, mgr = app_client
|
||||
aid, _mid, _session = self._queue_with_attachment(client, mgr, "ws-A")
|
||||
aid, _mid, _session = self._reserve_attachment(client, mgr, "ws-A")
|
||||
resp = client.delete(
|
||||
f"/v1/api/workstreams/ws-A/attachments/{aid}",
|
||||
headers=_auth("userA"),
|
||||
@@ -745,7 +750,7 @@ class TestQueuedAttachmentReservation:
|
||||
|
||||
def test_reserved_attachment_not_auto_consumed_by_later_send(self, app_client):
|
||||
client, mgr = app_client
|
||||
aid, _mid, session = self._queue_with_attachment(client, mgr, "ws-A")
|
||||
aid, _mid, session = self._reserve_attachment(client, mgr, "ws-A")
|
||||
|
||||
# Swap the busy worker for an idle one and capture the next
|
||||
# session.send call so we can assert on its attachment list.
|
||||
@@ -781,7 +786,7 @@ class TestQueuedAttachmentReservation:
|
||||
|
||||
def test_reserved_attachment_rejected_in_explicit_ids(self, app_client):
|
||||
client, mgr = app_client
|
||||
aid, _mid, session = self._queue_with_attachment(client, mgr, "ws-A")
|
||||
aid, _mid, session = self._reserve_attachment(client, mgr, "ws-A")
|
||||
|
||||
captured: dict = {}
|
||||
|
||||
@@ -813,29 +818,26 @@ class TestQueuedAttachmentReservation:
|
||||
if atts is not None:
|
||||
assert aid not in [a.attachment_id for a in atts]
|
||||
|
||||
def test_dequeue_releases_reservation(self, app_client):
|
||||
def test_send_with_attachments_to_busy_worker_returns_attachments_busy(self, app_client):
|
||||
"""An attempt to attach mid-tool-call returns ``attachments_busy``;
|
||||
attachments stay pending so the client can retry once idle."""
|
||||
client, mgr = app_client
|
||||
aid, mid, session = self._queue_with_attachment(client, mgr, "ws-A")
|
||||
|
||||
# Cancel the queued message — DELETE /api/send with msg_id
|
||||
resp = client.request(
|
||||
"DELETE",
|
||||
aid = _upload(client, "ws-A", "userA", "x.md", b"X", "text/markdown")
|
||||
self._wire_busy_ws(mgr, "ws-A")
|
||||
resp = client.post(
|
||||
"/v1/api/workstreams/ws-A/send",
|
||||
json={"msg_id": mid},
|
||||
json={"message": "with file", "attachment_ids": [aid]},
|
||||
headers=_auth("userA"),
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json().get("status") == "removed"
|
||||
|
||||
# Attachment is back to pending — visible + deletable
|
||||
body = resp.json()
|
||||
assert body["status"] == "attachments_busy"
|
||||
assert body["attached_ids"] == []
|
||||
assert body["dropped_attachment_ids"] == [aid]
|
||||
# Reservation released — attachment is still pending and visible.
|
||||
resp = client.get("/v1/api/workstreams/ws-A/attachments", headers=_auth("userA"))
|
||||
ids = [a["attachment_id"] for a in resp.json()["attachments"]]
|
||||
assert aid in ids
|
||||
resp = client.delete(
|
||||
f"/v1/api/workstreams/ws-A/attachments/{aid}",
|
||||
headers=_auth("userA"),
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
|
||||
|
||||
class TestReserveThenDispatchRace:
|
||||
|
||||
+80
-35
@@ -89,6 +89,27 @@ class TestSuggestProfile:
|
||||
p = suggest_profile("vllm", "Google/GEMMA-4-31B-IT")
|
||||
assert p["capabilities"]["thinking_mode"] == "manual"
|
||||
|
||||
def test_vllm_mistral_medium_not_auto_suggested(self) -> None:
|
||||
"""Mistral medium falls back to the generic vLLM profile.
|
||||
|
||||
We don't auto-suggest the Responses surface for Mistral medium because
|
||||
vLLM's Responses API tool-call parser isn't wired up for it yet —
|
||||
operators who want per-request reasoning effort must pick "Responses
|
||||
API" manually in the admin UI and accept the tool-calling limitation.
|
||||
"""
|
||||
p = suggest_profile("vllm", "mistralai/Mistral-Medium-3-Instruct")
|
||||
assert p["server_compat"]["server_type"] == "vllm"
|
||||
assert "api_surface" not in p["server_compat"]
|
||||
assert "capabilities" not in p
|
||||
|
||||
def test_vllm_mistral_medium_profile_still_available(self) -> None:
|
||||
"""The vllm-mistral-medium profile remains in _PROFILES so an operator
|
||||
who explicitly opts in via the admin UI gets the Responses surface."""
|
||||
from turnstone.core.server_compat import _PROFILES
|
||||
|
||||
assert "vllm-mistral-medium" in _PROFILES
|
||||
assert _PROFILES["vllm-mistral-medium"]["server_compat"]["api_surface"] == "responses"
|
||||
|
||||
def test_holo_requires_holo2(self) -> None:
|
||||
"""Short 'holo' prefix shouldn't false-match; 'holo2' should match."""
|
||||
p_short = suggest_profile("vllm", "some-org/hologram-7b")
|
||||
@@ -110,32 +131,47 @@ class TestSuggestProfile:
|
||||
|
||||
|
||||
class TestMergeServerCompat:
|
||||
def test_empty_compat_returns_base_only(self) -> None:
|
||||
def test_empty_base_and_compat_is_empty(self) -> None:
|
||||
"""No base, no compat → no extra_body needed."""
|
||||
assert merge_server_compat(None, {}) == {}
|
||||
assert merge_server_compat({}, {}) == {}
|
||||
|
||||
def test_explicit_base_passes_through(self) -> None:
|
||||
"""Explicit chat_template_kwargs base is forwarded as-is."""
|
||||
base = {"reasoning_effort": "medium"}
|
||||
result = merge_server_compat(base, {})
|
||||
assert result == {"chat_template_kwargs": {"reasoning_effort": "medium"}}
|
||||
|
||||
def test_extra_body_merged_top_level(self) -> None:
|
||||
base = {"reasoning_effort": "medium"}
|
||||
compat = {"extra_body": {"skip_special_tokens": False}}
|
||||
result = merge_server_compat(base, compat)
|
||||
assert result["skip_special_tokens"] is False
|
||||
assert "chat_template_kwargs" in result
|
||||
def test_extra_body_merged_top_level_no_base(self) -> None:
|
||||
"""Server-level overrides forward without a chat_template_kwargs wrapper."""
|
||||
result = merge_server_compat(None, {"extra_body": {"skip_special_tokens": False}})
|
||||
assert result == {"skip_special_tokens": False}
|
||||
|
||||
def test_full_vllm_gemma_compat(self) -> None:
|
||||
base = {"reasoning_effort": "medium"}
|
||||
def test_full_vllm_gemma_compat_no_base(self) -> None:
|
||||
"""vLLM workaround forwards on its own."""
|
||||
compat = {
|
||||
"server_type": "vllm",
|
||||
"extra_body": {"skip_special_tokens": False},
|
||||
}
|
||||
result = merge_server_compat(base, compat)
|
||||
result = merge_server_compat(None, compat)
|
||||
assert result == {"skip_special_tokens": False}
|
||||
|
||||
def test_operator_chat_template_kwargs_only(self) -> None:
|
||||
"""Operator can set chat_template_kwargs explicitly without seeding the base."""
|
||||
compat = {
|
||||
"extra_body": {
|
||||
"chat_template_kwargs": {"reasoning_effort": "high"},
|
||||
"skip_special_tokens": False,
|
||||
},
|
||||
}
|
||||
result = merge_server_compat(None, compat)
|
||||
assert result == {
|
||||
"chat_template_kwargs": {"reasoning_effort": "medium"},
|
||||
"chat_template_kwargs": {"reasoning_effort": "high"},
|
||||
"skip_special_tokens": False,
|
||||
}
|
||||
|
||||
def test_extra_body_chat_template_kwargs_deep_merged(self) -> None:
|
||||
"""chat_template_kwargs in extra_body is deep-merged, operator wins."""
|
||||
def test_extra_body_chat_template_kwargs_deep_merged_with_base(self) -> None:
|
||||
"""Operator chat_template_kwargs deep-merges over the seeded base."""
|
||||
base = {"reasoning_effort": "medium"}
|
||||
compat = {
|
||||
"extra_body": {
|
||||
@@ -144,17 +180,15 @@ class TestMergeServerCompat:
|
||||
},
|
||||
}
|
||||
result = merge_server_compat(base, compat)
|
||||
# Operator values win over base
|
||||
assert result["chat_template_kwargs"]["custom_flag"] is True
|
||||
# Operator value wins over seeded base
|
||||
assert result["chat_template_kwargs"]["reasoning_effort"] == "high"
|
||||
assert result["skip_special_tokens"] is False
|
||||
|
||||
def test_extra_body_chat_template_kwargs_non_dict_ignored(self) -> None:
|
||||
"""Non-dict chat_template_kwargs in extra_body is safely ignored."""
|
||||
base = {"reasoning_effort": "medium"}
|
||||
compat = {"extra_body": {"chat_template_kwargs": "bad"}}
|
||||
result = merge_server_compat(base, compat)
|
||||
assert result["chat_template_kwargs"] == {"reasoning_effort": "medium"}
|
||||
assert merge_server_compat(None, compat) == {}
|
||||
|
||||
def test_base_not_mutated(self) -> None:
|
||||
base = {"reasoning_effort": "medium"}
|
||||
@@ -164,9 +198,7 @@ class TestMergeServerCompat:
|
||||
|
||||
def test_non_dict_extra_body_ignored(self) -> None:
|
||||
"""Gracefully handle malformed server_compat."""
|
||||
base = {"reasoning_effort": "medium"}
|
||||
result = merge_server_compat(base, {"extra_body": 42})
|
||||
assert result == {"chat_template_kwargs": {"reasoning_effort": "medium"}}
|
||||
assert merge_server_compat(None, {"extra_body": 42}) == {}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -178,45 +210,58 @@ class TestEndToEndRequestShaping:
|
||||
"""Compose both layers — session builds extra_params, provider applies thinking."""
|
||||
|
||||
def test_vllm_gemma_full_flow(self) -> None:
|
||||
"""Session merges server workarounds, provider adds thinking param."""
|
||||
"""Session forwards server workarounds, provider adds thinking param."""
|
||||
caps = ModelCapabilities(thinking_mode="manual", thinking_param="enable_thinking")
|
||||
base_ctk = {"reasoning_effort": "medium"}
|
||||
server_compat = {
|
||||
"server_type": "vllm",
|
||||
"extra_body": {"skip_special_tokens": False},
|
||||
}
|
||||
# Step 1: session merges
|
||||
extra_params = merge_server_compat(base_ctk, server_compat)
|
||||
# Step 2: provider finalises
|
||||
# Step 1: session forwards (no auto-injection of reasoning_effort).
|
||||
extra_params = merge_server_compat(None, server_compat)
|
||||
# Step 2: provider injects thinking param into chat_template_kwargs.
|
||||
extra_body = dict(extra_params)
|
||||
OpenAIChatCompletionsProvider._apply_thinking_mode(extra_body, caps)
|
||||
|
||||
assert extra_body == {
|
||||
"chat_template_kwargs": {
|
||||
"reasoning_effort": "medium",
|
||||
"enable_thinking": True,
|
||||
},
|
||||
"chat_template_kwargs": {"enable_thinking": True},
|
||||
"skip_special_tokens": False,
|
||||
}
|
||||
|
||||
def test_granite_thinking_key(self) -> None:
|
||||
"""Granite uses 'thinking' instead of 'enable_thinking'."""
|
||||
caps = ModelCapabilities(thinking_mode="manual", thinking_param="thinking")
|
||||
extra_params = merge_server_compat({"reasoning_effort": "low"}, {})
|
||||
extra_params = merge_server_compat(None, {})
|
||||
extra_body = dict(extra_params)
|
||||
OpenAIChatCompletionsProvider._apply_thinking_mode(extra_body, caps)
|
||||
|
||||
assert extra_body["chat_template_kwargs"]["thinking"] is True
|
||||
assert "enable_thinking" not in extra_body["chat_template_kwargs"]
|
||||
assert extra_body == {"chat_template_kwargs": {"thinking": True}}
|
||||
|
||||
def test_non_thinking_model_no_injection(self) -> None:
|
||||
"""Non-thinking model gets no thinking params."""
|
||||
"""Non-thinking model gets no chat_template_kwargs at all."""
|
||||
caps = ModelCapabilities() # thinking_mode="none"
|
||||
extra_params = merge_server_compat({"reasoning_effort": "medium"}, {})
|
||||
extra_params = merge_server_compat(None, {})
|
||||
extra_body = dict(extra_params)
|
||||
OpenAIChatCompletionsProvider._apply_thinking_mode(extra_body, caps)
|
||||
|
||||
assert extra_body == {"chat_template_kwargs": {"reasoning_effort": "medium"}}
|
||||
assert extra_body == {}
|
||||
|
||||
def test_operator_reasoning_effort_passthrough(self) -> None:
|
||||
"""Operator-supplied reasoning_effort under chat_template_kwargs is preserved."""
|
||||
caps = ModelCapabilities(thinking_mode="manual", thinking_param="enable_thinking")
|
||||
compat = {
|
||||
"server_type": "vllm",
|
||||
"extra_body": {"chat_template_kwargs": {"reasoning_effort": "high"}},
|
||||
}
|
||||
extra_params = merge_server_compat(None, compat)
|
||||
extra_body = dict(extra_params)
|
||||
OpenAIChatCompletionsProvider._apply_thinking_mode(extra_body, caps)
|
||||
|
||||
assert extra_body == {
|
||||
"chat_template_kwargs": {
|
||||
"reasoning_effort": "high",
|
||||
"enable_thinking": True,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,346 @@
|
||||
"""Tests for the per-node ``models`` metadata pipeline.
|
||||
|
||||
Two helpers in ``server.py`` carry the load:
|
||||
|
||||
- ``_collect_node_models_metadata`` projects the live ``ModelRegistry``
|
||||
into the node_metadata row shape ``[{alias, provider, healthy}, ...]``.
|
||||
- ``_publish_models_metadata`` short-circuits redundant writes via a
|
||||
payload cache on ``app_state`` and is the helper called from both
|
||||
the heartbeat loop and ``internal_model_reload``.
|
||||
|
||||
These tests pin the projection shape, the health-flag wiring, the
|
||||
cache short-circuit, and the model-reload integration.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from turnstone.core.healthcheck import HealthTrackerRegistry
|
||||
from turnstone.core.model_registry import ModelConfig, ModelRegistry
|
||||
from turnstone.server import (
|
||||
_collect_node_models_metadata,
|
||||
_publish_models_metadata,
|
||||
)
|
||||
|
||||
|
||||
def _registry(*aliases_with_url: tuple[str, str]) -> ModelRegistry:
|
||||
"""Build a registry from ``(alias, base_url)`` pairs.
|
||||
|
||||
Two aliases sharing a ``base_url`` deliberately share a tracker —
|
||||
that's the contract the cluster-level health surface needs to
|
||||
preserve, and it's worth pinning in a test.
|
||||
"""
|
||||
models = {
|
||||
alias: ModelConfig(alias=alias, base_url=url, api_key="k", model=alias, provider="openai")
|
||||
for alias, url in aliases_with_url
|
||||
}
|
||||
default = aliases_with_url[0][0]
|
||||
return ModelRegistry(models, default=default)
|
||||
|
||||
|
||||
def test_returns_none_when_registry_missing():
|
||||
state = SimpleNamespace()
|
||||
assert _collect_node_models_metadata(state) is None
|
||||
|
||||
|
||||
def test_projects_all_aliases_with_default_healthy_when_no_tracker():
|
||||
"""Without a ``health_registry`` (or before any request has flowed
|
||||
through a backend), every alias surfaces as ``healthy=True`` —
|
||||
operators shouldn't get an empty ``models`` list on a freshly
|
||||
started node just because the backends haven't been exercised."""
|
||||
reg = _registry(("a", "http://x"), ("b", "http://y"))
|
||||
state = SimpleNamespace(registry=reg)
|
||||
entry = _collect_node_models_metadata(state)
|
||||
assert entry is not None
|
||||
key, value, source = entry
|
||||
assert key == "models"
|
||||
assert source == "auto"
|
||||
rows = json.loads(value)
|
||||
assert len(rows) == 2
|
||||
aliases = {r["alias"] for r in rows}
|
||||
assert aliases == {"a", "b"}
|
||||
assert all(r["healthy"] is True for r in rows)
|
||||
assert all(r["provider"] == "openai" for r in rows)
|
||||
# Provider-side model identifier intentionally omitted — coords
|
||||
# kept passing it as ``spawn_workstream(model=...)`` when they
|
||||
# should have passed the local alias. Lock the projected keys
|
||||
# so a future contributor doesn't reintroduce the footgun.
|
||||
for row in rows:
|
||||
assert set(row.keys()) == {"alias", "provider", "healthy"}
|
||||
|
||||
|
||||
def test_health_flag_reflects_tracker_state():
|
||||
reg = _registry(("a", "http://x"), ("b", "http://y"))
|
||||
health_reg = HealthTrackerRegistry(failure_threshold=2)
|
||||
# Seed the tracker for "a"'s backend and drive it into the degraded
|
||||
# state — two consecutive failures cross the threshold.
|
||||
bad_tracker = health_reg.get_tracker(provider="openai", base_url="http://x")
|
||||
bad_tracker.record_failure()
|
||||
bad_tracker.record_failure()
|
||||
assert bad_tracker.is_degraded
|
||||
# "b" gets a tracker that has only seen successes.
|
||||
good_tracker = health_reg.get_tracker(provider="openai", base_url="http://y")
|
||||
good_tracker.record_success()
|
||||
state = SimpleNamespace(registry=reg, health_registry=health_reg)
|
||||
rows = json.loads(_collect_node_models_metadata(state)[1])
|
||||
by_alias = {r["alias"]: r for r in rows}
|
||||
assert by_alias["a"]["healthy"] is False
|
||||
assert by_alias["b"]["healthy"] is True
|
||||
|
||||
|
||||
def test_two_aliases_sharing_a_backend_share_a_tracker():
|
||||
"""Two aliases that point at the same ``(provider, base_url)``
|
||||
share a single :class:`BackendHealthTracker` — degrading one is
|
||||
expected to surface as degraded on the other. The list_nodes
|
||||
projection should respect that, otherwise a coord could see
|
||||
``alias-a`` healthy and ``alias-b`` degraded for the same
|
||||
backend."""
|
||||
reg = _registry(("alpha", "http://shared"), ("beta", "http://shared"))
|
||||
health_reg = HealthTrackerRegistry(failure_threshold=1)
|
||||
tracker = health_reg.get_tracker(provider="openai", base_url="http://shared")
|
||||
tracker.record_failure() # threshold=1 — degraded immediately
|
||||
state = SimpleNamespace(registry=reg, health_registry=health_reg)
|
||||
rows = json.loads(_collect_node_models_metadata(state)[1])
|
||||
assert {r["alias"]: r["healthy"] for r in rows} == {"alpha": False, "beta": False}
|
||||
|
||||
|
||||
def test_alias_with_no_tracker_yet_defaults_to_healthy():
|
||||
"""An alias the registry knows about but whose backend hasn't been
|
||||
invoked yet has no tracker. Default to healthy so a brand-new
|
||||
alias is immediately visible to coordinators rather than waiting
|
||||
for the first request to seed a tracker.
|
||||
|
||||
The collector calls ``health_reg.get_tracker(...)`` which mints a
|
||||
fresh tracker on first lookup — that's the path under test here.
|
||||
The freshly minted tracker reports ``is_healthy=True`` (default
|
||||
state), so the projection labels the alias healthy.
|
||||
"""
|
||||
reg = _registry(("a", "http://x"))
|
||||
health_reg = HealthTrackerRegistry() # empty — no trackers seeded
|
||||
state = SimpleNamespace(registry=reg, health_registry=health_reg)
|
||||
rows = json.loads(_collect_node_models_metadata(state)[1])
|
||||
assert rows[0]["healthy"] is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _publish_models_metadata — cache short-circuit + projection wiring
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _publish_state() -> SimpleNamespace:
|
||||
"""Build an ``app_state`` with a minimal registry + health surface."""
|
||||
reg = _registry(("a", "http://x"))
|
||||
return SimpleNamespace(registry=reg, health_registry=HealthTrackerRegistry())
|
||||
|
||||
|
||||
def test_publish_writes_when_payload_changes():
|
||||
"""First publish has nothing in the cache — write happens; cache
|
||||
fills. Second publish on the same unchanged registry skips the
|
||||
write entirely."""
|
||||
state = _publish_state()
|
||||
storage = MagicMock()
|
||||
_publish_models_metadata(state, storage, "node-a")
|
||||
assert storage.set_node_metadata_bulk.call_count == 1
|
||||
cached = state._last_models_payload
|
||||
assert isinstance(cached, str) and "alias" in cached
|
||||
# Second call, same registry, same health: cached payload matches
|
||||
# — write must be skipped to avoid the per-30s UPSERT churn.
|
||||
_publish_models_metadata(state, storage, "node-a")
|
||||
assert storage.set_node_metadata_bulk.call_count == 1
|
||||
|
||||
|
||||
def test_publish_records_metric_outcome(monkeypatch):
|
||||
"""The publish helper feeds ``record_node_models_publish`` so
|
||||
Prometheus can expose the hit-rate. Storage failures must NOT
|
||||
record either outcome — counters should reflect actual cache
|
||||
decisions, not transient DB errors that will retry.
|
||||
|
||||
Replaces the module-level ``turnstone.server._metrics`` binding
|
||||
via string-form monkeypatch (with auto-restore) rather than
|
||||
patching an instance attribute on the imported singleton. Other
|
||||
tests in the suite reassign ``srv_mod._metrics`` (some without
|
||||
using monkeypatch), so an instance captured at import time can
|
||||
diverge from the binding the live ``_publish_models_metadata``
|
||||
reads on each call.
|
||||
"""
|
||||
state = _publish_state()
|
||||
storage = MagicMock()
|
||||
calls: list[bool] = []
|
||||
|
||||
class _FakeMetrics:
|
||||
def record_node_models_publish(self, *, written: bool) -> None:
|
||||
calls.append(written)
|
||||
|
||||
monkeypatch.setattr("turnstone.server._metrics", _FakeMetrics())
|
||||
|
||||
_publish_models_metadata(state, storage, "node-a") # first → write
|
||||
_publish_models_metadata(state, storage, "node-a") # second → skip
|
||||
assert calls == [True, False]
|
||||
|
||||
# Storage error: no metric recorded.
|
||||
storage.set_node_metadata_bulk.side_effect = RuntimeError("db down")
|
||||
state._last_models_payload = None # invalidate cache to force a write attempt
|
||||
_publish_models_metadata(state, storage, "node-a")
|
||||
assert calls == [True, False] # unchanged
|
||||
|
||||
|
||||
def test_publish_rewrites_when_health_flips():
|
||||
"""A health-tracker state change must invalidate the cache and
|
||||
drive a fresh write — otherwise the discovery surface would lag
|
||||
a flip indefinitely."""
|
||||
state = _publish_state()
|
||||
storage = MagicMock()
|
||||
_publish_models_metadata(state, storage, "node-a")
|
||||
assert storage.set_node_metadata_bulk.call_count == 1
|
||||
# Drive the only tracker to degraded.
|
||||
tracker = state.health_registry.get_tracker(provider="openai", base_url="http://x")
|
||||
for _ in range(10):
|
||||
tracker.record_failure()
|
||||
assert tracker.is_degraded
|
||||
_publish_models_metadata(state, storage, "node-a")
|
||||
assert storage.set_node_metadata_bulk.call_count == 2
|
||||
|
||||
|
||||
def test_publish_swallows_storage_error_without_updating_cache():
|
||||
"""A storage failure must NOT poison the cache — the next call
|
||||
should retry the write rather than think it succeeded."""
|
||||
state = _publish_state()
|
||||
storage = MagicMock()
|
||||
storage.set_node_metadata_bulk.side_effect = RuntimeError("db down")
|
||||
_publish_models_metadata(state, storage, "node-a")
|
||||
assert storage.set_node_metadata_bulk.call_count == 1
|
||||
assert getattr(state, "_last_models_payload", None) is None
|
||||
# Recover: a subsequent successful call writes again.
|
||||
storage.set_node_metadata_bulk.side_effect = None
|
||||
_publish_models_metadata(state, storage, "node-a")
|
||||
assert storage.set_node_metadata_bulk.call_count == 2
|
||||
assert state._last_models_payload is not None
|
||||
|
||||
|
||||
def test_publish_skips_when_registry_missing():
|
||||
"""Without a registry there's nothing to project; nothing should
|
||||
be written and the cache must not be set."""
|
||||
state = SimpleNamespace()
|
||||
storage = MagicMock()
|
||||
_publish_models_metadata(state, storage, "node-a")
|
||||
assert storage.set_node_metadata_bulk.call_count == 0
|
||||
assert getattr(state, "_last_models_payload", None) is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# internal_model_reload — integration: registry change must rewrite the row
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_model_reload_endpoint_rewrites_models_metadata(monkeypatch, tmp_path):
|
||||
"""A successful ``internal_model_reload`` must refresh
|
||||
``node_metadata.models`` so a coordinator sees the new alias on
|
||||
its next ``list_nodes`` without waiting up to 30s for the
|
||||
heartbeat tick.
|
||||
|
||||
The endpoint pulls a fresh registry from
|
||||
``load_model_registry(...)`` and reloads in-place — we stub the
|
||||
loader to return a registry with a different alias set so the
|
||||
publish-cache invalidation is exercised end-to-end.
|
||||
"""
|
||||
from turnstone.core.storage._sqlite import SQLiteBackend
|
||||
from turnstone.server import internal_model_reload
|
||||
|
||||
storage = SQLiteBackend(str(tmp_path / "reload.db"))
|
||||
|
||||
# Old registry — single alias "a".
|
||||
old_reg = _registry(("a", "http://x"))
|
||||
# New registry that ``load_model_registry`` will return — adds "b".
|
||||
new_reg = ModelRegistry(
|
||||
{
|
||||
"a": ModelConfig(
|
||||
alias="a", base_url="http://x", api_key="k", model="a", provider="openai"
|
||||
),
|
||||
"b": ModelConfig(
|
||||
alias="b", base_url="http://y", api_key="k", model="b", provider="openai"
|
||||
),
|
||||
},
|
||||
default="a",
|
||||
)
|
||||
health_reg = HealthTrackerRegistry()
|
||||
|
||||
app_state = SimpleNamespace(
|
||||
registry=old_reg,
|
||||
health_registry=health_reg,
|
||||
cli_model_args={
|
||||
"base_url": "",
|
||||
"api_key": "",
|
||||
"model": "",
|
||||
"context_window": 0,
|
||||
"provider": "openai",
|
||||
},
|
||||
config_store=None,
|
||||
node_id="node-a",
|
||||
)
|
||||
request = SimpleNamespace(app=SimpleNamespace(state=app_state))
|
||||
|
||||
# Patch the loader and storage accessors used inside the endpoint.
|
||||
# ``internal_model_reload`` does ``from turnstone.core.storage._registry
|
||||
# import get_storage`` inline, so patching the symbol on that module
|
||||
# is what intercepts the call.
|
||||
monkeypatch.setattr("turnstone.core.model_registry.load_model_registry", lambda **_kw: new_reg)
|
||||
monkeypatch.setattr("turnstone.core.storage._registry.get_storage", lambda: storage)
|
||||
# The endpoint also broadcasts schema refreshes to active sessions
|
||||
# — stub this out, it's irrelevant to the metadata-write path.
|
||||
monkeypatch.setattr("turnstone.server._broadcast_agent_tool_schema_refresh", lambda _s: None)
|
||||
|
||||
response = internal_model_reload(request) # type: ignore[arg-type]
|
||||
assert response.status_code == 200
|
||||
|
||||
rows = storage.get_node_metadata("node-a")
|
||||
by_key = {r["key"]: r for r in rows}
|
||||
assert "models" in by_key
|
||||
payload = json.loads(by_key["models"]["value"])
|
||||
assert {r["alias"] for r in payload} == {"a", "b"}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Shutdown race: heartbeat write must NOT resurrect post-shutdown delete
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_heartbeat_write_awaits_before_shutdown_delete():
|
||||
"""Pin the shutdown-race fix.
|
||||
|
||||
Before the fix, the lifespan shutdown sequence was:
|
||||
|
||||
1. ``_heartbeat_task.cancel()`` — fire-and-forget
|
||||
2. ``delete_node_metadata_by_source(node_id, "auto")``
|
||||
|
||||
A heartbeat tick already inside ``asyncio.to_thread(...)`` for
|
||||
the ``set_node_metadata_bulk`` call would complete AFTER step 2,
|
||||
resurrecting the deleted ``models`` row. The fix awaits the
|
||||
cancelled task with ``contextlib.suppress(...)`` between (1) and
|
||||
(2), so the in-flight write lands first.
|
||||
|
||||
We verify the fix by introspecting ``server.py`` source — the
|
||||
real lifespan is hard to test deterministically without a full
|
||||
Starlette app, but the textual ordering between
|
||||
``_heartbeat_task.cancel()`` and the delete is a stable contract
|
||||
that catches the regression cheaply.
|
||||
"""
|
||||
import inspect
|
||||
import sys
|
||||
|
||||
src = inspect.getsource(sys.modules[_collect_node_models_metadata.__module__])
|
||||
cancel_idx = src.find("_heartbeat_task.cancel()")
|
||||
delete_idx = src.find('delete_node_metadata_by_source, _svc_node_id, "auto"')
|
||||
await_idx = src.find("await _heartbeat_task", cancel_idx)
|
||||
assert cancel_idx != -1
|
||||
assert delete_idx != -1
|
||||
assert await_idx != -1
|
||||
# The fix-line must sit BETWEEN the cancel and the delete.
|
||||
assert cancel_idx < await_idx < delete_idx, (
|
||||
"Shutdown race regression: "
|
||||
"_heartbeat_task.cancel() must be followed by `await _heartbeat_task` "
|
||||
"BEFORE delete_node_metadata_by_source(..., 'auto') so an in-flight "
|
||||
"set_node_metadata_bulk lands before the delete."
|
||||
)
|
||||
+1422
-211
File diff suppressed because it is too large
Load Diff
@@ -4,6 +4,8 @@ from __future__ import annotations
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from turnstone.core.attachments import Attachment
|
||||
from turnstone.core.memory import (
|
||||
get_attachment,
|
||||
@@ -273,149 +275,29 @@ class TestProviderIntegration:
|
||||
assert "DO THE THING" in parts[1]["text"]
|
||||
|
||||
|
||||
class TestQueuedWithAttachments:
|
||||
"""Queued user turns must carry their attachments through to dequeue."""
|
||||
class TestQueuedAttachmentsRejected:
|
||||
"""Queued user messages can't carry attachments — see
|
||||
:class:`AttachmentsNotQueueableError` for the role-ordering reason
|
||||
(an attachment-bearing queued item would have to be appended as a
|
||||
separate user turn, injecting ``user`` between
|
||||
``assistant(tool_calls)`` and ``tool``)."""
|
||||
|
||||
def test_queue_message_rejects_attachments(self, tmp_db, mock_openai_client):
|
||||
from turnstone.core.session import AttachmentsNotQueueableError
|
||||
|
||||
def test_queue_message_stores_attachment_ids(self, tmp_db, mock_openai_client):
|
||||
s = _make_session(mock_openai_client)
|
||||
# Seed a pending attachment owned by the session user
|
||||
save_attachment("a-q1", s._ws_id, "u1", "q.md", "text/markdown", 1, "text", b"q")
|
||||
cleaned, priority, msg_id = s.queue_message("queued text", attachment_ids=["a-q1"])
|
||||
assert cleaned == "queued text"
|
||||
with pytest.raises(AttachmentsNotQueueableError):
|
||||
s.queue_message("queued text", attachment_ids=["a-q1"])
|
||||
# Queue stayed empty — nothing partially committed.
|
||||
assert s._queued_messages == {}
|
||||
|
||||
def test_queue_message_accepts_text_only(self, tmp_db, mock_openai_client):
|
||||
s = _make_session(mock_openai_client)
|
||||
cleaned, priority, msg_id = s.queue_message("plain text")
|
||||
assert cleaned == "plain text"
|
||||
with s._queued_lock:
|
||||
entry = s._queued_messages[msg_id]
|
||||
# Entry shape is (cleaned, priority, attachment_ids_tuple)
|
||||
assert entry[0] == "queued text"
|
||||
assert entry[2] == ("a-q1",)
|
||||
|
||||
def test_flush_queued_injects_multipart_user_turn(self, tmp_db, mock_openai_client):
|
||||
from turnstone.core.memory import reserve_attachments
|
||||
|
||||
s = _make_session(mock_openai_client)
|
||||
save_attachment("a-f1", s._ws_id, "u1", "f.md", "text/markdown", 3, "text", b"DAT")
|
||||
_c, _p, msg_id = s.queue_message("please review", attachment_ids=["a-f1"])
|
||||
# Server-side would have reserved before queueing; mirror that
|
||||
# so consume's token match succeeds on flush.
|
||||
reserve_attachments(["a-f1"], msg_id, s._ws_id, "u1")
|
||||
s._flush_queued_messages()
|
||||
|
||||
msgs = s.messages
|
||||
assert len(msgs) == 1
|
||||
msg = msgs[0]
|
||||
assert msg["role"] == "user"
|
||||
# Multipart shape — text + document parts
|
||||
assert isinstance(msg["content"], list)
|
||||
assert msg["content"][0] == {"type": "text", "text": "please review"}
|
||||
doc = msg["content"][1]
|
||||
assert doc["type"] == "document"
|
||||
assert doc["document"]["name"] == "f.md"
|
||||
assert doc["document"]["data"] == "DAT"
|
||||
# And the attachment is now consumed (not pending)
|
||||
assert get_attachment("a-f1")["message_id"] is not None
|
||||
assert list_pending_attachments(s._ws_id, "u1") == []
|
||||
|
||||
def test_flush_mixed_attachment_and_text_items(self, tmp_db, mock_openai_client):
|
||||
# Text-only items should combine into one turn while
|
||||
# attachment-bearing items flush as separate multipart turns.
|
||||
from turnstone.core.memory import reserve_attachments
|
||||
|
||||
s = _make_session(mock_openai_client)
|
||||
save_attachment("a-mx", s._ws_id, "u1", "x.md", "text/markdown", 1, "text", b"x")
|
||||
s.queue_message("first plain")
|
||||
_c, _p, mid = s.queue_message("with file", attachment_ids=["a-mx"])
|
||||
reserve_attachments(["a-mx"], mid, s._ws_id, "u1")
|
||||
s.queue_message("another plain")
|
||||
s._flush_queued_messages()
|
||||
|
||||
# We expect at least two user messages: one combining the plain
|
||||
# items flanking the multipart turn is allowed, but the
|
||||
# multipart turn must remain its own message.
|
||||
user_msgs = [m for m in s.messages if m.get("role") == "user"]
|
||||
multipart = [m for m in user_msgs if isinstance(m["content"], list)]
|
||||
assert len(multipart) == 1
|
||||
assert "with file" in multipart[0]["content"][0]["text"]
|
||||
|
||||
def test_flush_drops_cross_user_attachment_silently(self, tmp_db, mock_openai_client):
|
||||
# A forged attachment_id belonging to another user must not
|
||||
# produce an attached part — dequeue resolution re-scopes.
|
||||
s = _make_session(mock_openai_client, user_id="u1")
|
||||
save_attachment("a-other", s._ws_id, "u2", "other.md", "text/plain", 1, "text", b"o")
|
||||
s.queue_message("hi", attachment_ids=["a-other"])
|
||||
s._flush_queued_messages()
|
||||
# Flushed as plain text-only turn — the forged id was scope-dropped.
|
||||
msgs = s.messages
|
||||
assert len(msgs) == 1
|
||||
assert msgs[0]["content"] == "hi"
|
||||
|
||||
|
||||
class TestQueueReservationLifecycle:
|
||||
"""session.queue_message + dequeue_message lifecycle with reservations."""
|
||||
|
||||
def test_dequeue_unreserves_attachments(self, tmp_db, mock_openai_client):
|
||||
from turnstone.core.memory import get_attachment, reserve_attachments
|
||||
|
||||
s = _make_session(mock_openai_client)
|
||||
save_attachment("a-deq", s._ws_id, "u1", "x.md", "text/plain", 1, "text", b"x")
|
||||
_cleaned, _priority, msg_id = s.queue_message("queued", attachment_ids=["a-deq"])
|
||||
# Simulate the server reserving after queue_message
|
||||
reserve_attachments(["a-deq"], msg_id, s._ws_id, "u1")
|
||||
assert get_attachment("a-deq")["reserved_for_msg_id"] == msg_id
|
||||
|
||||
# Dequeue (user cancelled the queued send)
|
||||
assert s.dequeue_message(msg_id) is True
|
||||
# Reservation is released — back to pending
|
||||
assert get_attachment("a-deq")["reserved_for_msg_id"] is None
|
||||
assert len(list_pending_attachments(s._ws_id, "u1")) == 1
|
||||
|
||||
def test_flush_consumes_reserved_attachment(self, tmp_db, mock_openai_client):
|
||||
from turnstone.core.memory import get_attachment, reserve_attachments
|
||||
|
||||
s = _make_session(mock_openai_client)
|
||||
save_attachment("a-flush", s._ws_id, "u1", "y.md", "text/plain", 1, "text", b"y")
|
||||
_c, _p, msg_id = s.queue_message("go", attachment_ids=["a-flush"])
|
||||
reserve_attachments(["a-flush"], msg_id, s._ws_id, "u1")
|
||||
|
||||
# Flush — queue drain must accept the reserved-for-this-msg attachment
|
||||
s._flush_queued_messages()
|
||||
row = get_attachment("a-flush")
|
||||
assert row["message_id"] is not None
|
||||
assert row["reserved_for_msg_id"] is None # cleared on consume
|
||||
# And the in-memory message is multipart with the doc attached
|
||||
assert isinstance(s.messages[-1]["content"], list)
|
||||
assert any(p.get("type") == "document" for p in s.messages[-1]["content"])
|
||||
|
||||
def test_resolve_rejects_reservation_for_other_msg(self, tmp_db, mock_openai_client):
|
||||
from turnstone.core.memory import reserve_attachments
|
||||
|
||||
s = _make_session(mock_openai_client)
|
||||
save_attachment("a-other", s._ws_id, "u1", "z.md", "text/plain", 1, "text", b"z")
|
||||
reserve_attachments(["a-other"], "q-OTHER", s._ws_id, "u1")
|
||||
# allow_reserved_for=None (default) → reserved rows are skipped
|
||||
assert s._resolve_attachment_ids(["a-other"]) == []
|
||||
# allow_reserved_for matches → accepted
|
||||
out = s._resolve_attachment_ids(["a-other"], allow_reserved_for="q-OTHER")
|
||||
assert [a.attachment_id for a in out] == ["a-other"]
|
||||
|
||||
|
||||
class TestExplicitAttachmentIdsOrderPreserved:
|
||||
"""session._resolve_attachment_ids must honour request order."""
|
||||
|
||||
def test_resolve_preserves_request_order(self, tmp_db, mock_openai_client):
|
||||
s = _make_session(mock_openai_client)
|
||||
# Insert in one order, request in the reverse order — resolver
|
||||
# must reflect the request, not the DB's INSERT order.
|
||||
save_attachment("a-1", s._ws_id, "u1", "first.md", "text/plain", 1, "text", b"1")
|
||||
save_attachment("a-2", s._ws_id, "u1", "second.md", "text/plain", 1, "text", b"2")
|
||||
save_attachment("a-3", s._ws_id, "u1", "third.md", "text/plain", 1, "text", b"3")
|
||||
|
||||
out = s._resolve_attachment_ids(["a-3", "a-1", "a-2"])
|
||||
assert [a.attachment_id for a in out] == ["a-3", "a-1", "a-2"]
|
||||
|
||||
def test_resolve_skips_unknown_and_keeps_order(self, tmp_db, mock_openai_client):
|
||||
s = _make_session(mock_openai_client)
|
||||
save_attachment("a-k", s._ws_id, "u1", "k.md", "text/plain", 1, "text", b"k")
|
||||
out = s._resolve_attachment_ids(["unknown", "a-k", ""])
|
||||
assert [a.attachment_id for a in out] == ["a-k"]
|
||||
assert s._queued_messages[msg_id] == ("plain text", priority)
|
||||
|
||||
|
||||
class TestTokenAccounting:
|
||||
|
||||
@@ -18,13 +18,22 @@ from __future__ import annotations
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
from datetime import UTC, datetime
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from turnstone.core.session_manager import SessionKindAdapter, SessionManager
|
||||
from turnstone.core.workstream import Workstream, WorkstreamKind, WorkstreamState
|
||||
from turnstone.core.workstream import (
|
||||
BULK_CLOSE_STATE_VALUES,
|
||||
Workstream,
|
||||
WorkstreamKind,
|
||||
WorkstreamState,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test fixtures
|
||||
@@ -92,6 +101,7 @@ class FakeAdapter:
|
||||
self.cleaned_up: list[str] = []
|
||||
self.build_session_calls = 0
|
||||
self.build_session_raises = build_session_raises
|
||||
self.last_build_model: object | None = None
|
||||
# Slow down session build so concurrent tests can race.
|
||||
self.build_session_delay = 0.0
|
||||
|
||||
@@ -135,8 +145,13 @@ class FakeAdapter:
|
||||
def build_ui(self, ws: Workstream) -> Any:
|
||||
return FakeUI()
|
||||
|
||||
def build_session(self, ws: Workstream, **_: object) -> Any:
|
||||
def build_session(self, ws: Workstream, **kwargs: object) -> Any:
|
||||
self.build_session_calls += 1
|
||||
# Record the ``model`` kwarg (None on fresh-create, the saved
|
||||
# alias on rehydrate) so tests can assert SessionManager.open()
|
||||
# threads the persisted alias through to construction instead
|
||||
# of letting the adapter resolve the *current* default alias.
|
||||
self.last_build_model = kwargs.get("model")
|
||||
if self.build_session_delay:
|
||||
time.sleep(self.build_session_delay)
|
||||
if self.build_session_raises:
|
||||
@@ -156,6 +171,8 @@ class _Row:
|
||||
kind: str
|
||||
state: str = "idle"
|
||||
parent_ws_id: str | None = None
|
||||
updated: str = ""
|
||||
node_id: str | None = None
|
||||
|
||||
|
||||
class FakeStorage:
|
||||
@@ -164,8 +181,26 @@ class FakeStorage:
|
||||
def __init__(self) -> None:
|
||||
self.rows: dict[str, _Row] = {}
|
||||
self.state_updates: list[tuple[str, str]] = []
|
||||
self.touch_calls: list[str] = []
|
||||
self.register_raises = False
|
||||
self.lock = threading.Lock()
|
||||
# Live-services lookup target for close_idle pass 2. Map
|
||||
# service_type → list of live service_ids. Tests that exercise
|
||||
# liveness scoping populate this directly; default empty means
|
||||
# "no peers alive" (every row unprotected by liveness).
|
||||
self.live_services: dict[str, list[str]] = {}
|
||||
self.list_services_raises = False
|
||||
# Per-ws config (model_alias, temperature, …). Populated by
|
||||
# tests that exercise the rehydrate-preserves-config path; the
|
||||
# SessionManager.open() rehydrate path reads this through
|
||||
# ``self._storage.load_workstream_config`` so it can pass the
|
||||
# saved alias into ``build_session`` and avoid clobbering the
|
||||
# original on construction.
|
||||
self.ws_config: dict[str, dict[str, str]] = {}
|
||||
|
||||
@staticmethod
|
||||
def _now_iso() -> str:
|
||||
return datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
|
||||
|
||||
def register_workstream(
|
||||
self,
|
||||
@@ -178,6 +213,8 @@ class FakeStorage:
|
||||
parent_ws_id: str | None = None,
|
||||
skill_id: str = "",
|
||||
skill_version: int = 0,
|
||||
state: str = "idle",
|
||||
updated: str | None = None,
|
||||
) -> None:
|
||||
if self.register_raises:
|
||||
raise RuntimeError("register forced failure")
|
||||
@@ -188,14 +225,66 @@ class FakeStorage:
|
||||
user_id=user_id or "",
|
||||
name=name,
|
||||
kind=kind_str,
|
||||
state=state,
|
||||
parent_ws_id=parent_ws_id,
|
||||
updated=updated if updated is not None else self._now_iso(),
|
||||
node_id=node_id,
|
||||
)
|
||||
|
||||
def touch_workstream(self, ws_id: str) -> None:
|
||||
with self.lock:
|
||||
self.touch_calls.append(ws_id)
|
||||
if ws_id in self.rows:
|
||||
self.rows[ws_id].updated = self._now_iso()
|
||||
|
||||
def update_workstream_state(self, ws_id: str, state: str) -> None:
|
||||
with self.lock:
|
||||
self.state_updates.append((ws_id, state))
|
||||
if ws_id in self.rows:
|
||||
self.rows[ws_id].state = state
|
||||
self.rows[ws_id].updated = self._now_iso()
|
||||
|
||||
def bulk_close_stale_orphans(
|
||||
self,
|
||||
kind: WorkstreamKind | str,
|
||||
cutoff: str,
|
||||
exclude_ws_ids: list[str],
|
||||
live_node_ids: list[str] | None = None,
|
||||
) -> list[str]:
|
||||
kind_str = kind.value if isinstance(kind, WorkstreamKind) else str(kind)
|
||||
excluded = set(exclude_ws_ids)
|
||||
live_set = set(live_node_ids) if live_node_ids else set()
|
||||
now = self._now_iso()
|
||||
closed: list[str] = []
|
||||
with self.lock:
|
||||
for ws_id, row in self.rows.items():
|
||||
if (
|
||||
row.kind == kind_str
|
||||
and row.state in BULK_CLOSE_STATE_VALUES
|
||||
and row.updated < cutoff
|
||||
and ws_id not in excluded
|
||||
):
|
||||
# Liveness gate: when live_node_ids was provided AND
|
||||
# non-empty, protect rows whose owner is in the live
|
||||
# set. NULL node_id is always eligible. When
|
||||
# live_node_ids is None or empty, no protection
|
||||
# (mirror of the real backends).
|
||||
if live_node_ids and row.node_id is not None and row.node_id in live_set:
|
||||
continue
|
||||
row.state = "closed"
|
||||
row.updated = now
|
||||
self.state_updates.append((ws_id, "closed"))
|
||||
closed.append(ws_id)
|
||||
return closed
|
||||
|
||||
def list_services(self, service_type: str, max_age_seconds: int = 120) -> list[dict[str, str]]:
|
||||
if self.list_services_raises:
|
||||
raise RuntimeError("list_services forced failure")
|
||||
with self.lock:
|
||||
return [
|
||||
{"service_id": sid, "service_type": service_type}
|
||||
for sid in self.live_services.get(service_type, [])
|
||||
]
|
||||
|
||||
def get_workstream(self, ws_id: str) -> dict[str, Any] | None:
|
||||
with self.lock:
|
||||
@@ -218,6 +307,18 @@ class FakeStorage:
|
||||
def count_skill_versions(self, template_id: str) -> int:
|
||||
return 0
|
||||
|
||||
def load_workstream_config(self, ws_id: str) -> dict[str, str]:
|
||||
with self.lock:
|
||||
return dict(self.ws_config.get(ws_id, {}))
|
||||
|
||||
def save_workstream_config(self, ws_id: str, config: dict[str, str]) -> None:
|
||||
# Mirrors the real backend's INSERT OR REPLACE per-key semantics
|
||||
# — callers expect a partial save to overwrite only the keys
|
||||
# they pass, not the whole row.
|
||||
with self.lock:
|
||||
row = self.ws_config.setdefault(ws_id, {})
|
||||
row.update(config)
|
||||
|
||||
|
||||
_EMITTER_DEFAULT = object()
|
||||
|
||||
@@ -228,6 +329,8 @@ def _make_manager(
|
||||
max_active: int = 5,
|
||||
storage: FakeStorage | None = None,
|
||||
event_emitter: Any = _EMITTER_DEFAULT,
|
||||
node_id: str | None = None,
|
||||
model_validator: Callable[[str], bool] | None = None,
|
||||
) -> tuple[SessionManager, FakeAdapter, FakeStorage]:
|
||||
"""Build a SessionManager wired to a FakeAdapter for both Protocols.
|
||||
|
||||
@@ -246,6 +349,8 @@ def _make_manager(
|
||||
storage=storage,
|
||||
max_active=max_active,
|
||||
event_emitter=emitter,
|
||||
node_id=node_id,
|
||||
model_validator=model_validator,
|
||||
)
|
||||
return mgr, adapter, storage
|
||||
|
||||
@@ -579,6 +684,120 @@ def test_open_resurrects_closed_state() -> None:
|
||||
assert ws_id in [e.ws_id for e in adapter.events_of("rehydrated")]
|
||||
|
||||
|
||||
def test_open_threads_saved_model_alias_into_build_session() -> None:
|
||||
"""Reopening a closed ws must build the session with the *original*
|
||||
model alias, not the current registry default.
|
||||
|
||||
Without this, ``build_session(ws)`` is called with ``model=None`` →
|
||||
the production session_factory resolves ``_effective_default_alias()``
|
||||
→ ChatSession's ``__init__`` writes those defaults to
|
||||
``workstream_config`` (INSERT OR REPLACE) → the subsequent
|
||||
``resume()`` restores what is now the default. Net effect: every
|
||||
persisted knob (model, temperature, reasoning_effort, max_tokens,
|
||||
skill, creative_mode, instructions, …) silently resets on every
|
||||
reopen and on every service restart.
|
||||
"""
|
||||
mgr, adapter, storage = _make_manager()
|
||||
ws = mgr.create(user_id="u1")
|
||||
ws_id = ws.id
|
||||
# Pretend the user set a non-default alias when the ws was created;
|
||||
# the real path goes through ChatSession._save_config but the
|
||||
# FakeSession in this suite doesn't model that, so seed directly.
|
||||
storage.ws_config[ws_id] = {"model_alias": "gpt-5-pro"}
|
||||
mgr.close(ws_id)
|
||||
adapter.last_build_model = "<unset>" # sentinel — must be overwritten
|
||||
|
||||
reopened = mgr.open(ws_id)
|
||||
|
||||
assert reopened is not None
|
||||
assert adapter.last_build_model == "gpt-5-pro"
|
||||
|
||||
|
||||
def test_open_drops_saved_alias_when_validator_rejects() -> None:
|
||||
"""When the persisted alias is no longer in the registry, the
|
||||
manager must drop it before reaching ``build_session``. The
|
||||
factory still raises on unknown aliases on the fresh-create path
|
||||
(so a typo in body.model surfaces as 503), so the rehydrate path
|
||||
has to filter the alias here rather than relying on factory-side
|
||||
fallback. Without this filter, every reopen of a workstream pinned
|
||||
to a since-removed alias 500s."""
|
||||
mgr, adapter, storage = _make_manager(
|
||||
# Validator says "alias is no longer in the registry".
|
||||
model_validator=lambda alias: False,
|
||||
)
|
||||
ws = mgr.create(user_id="u1")
|
||||
ws_id = ws.id
|
||||
storage.ws_config[ws_id] = {"model_alias": "since-removed-alias"}
|
||||
mgr.close(ws_id)
|
||||
adapter.last_build_model = "<unset>"
|
||||
|
||||
reopened = mgr.open(ws_id)
|
||||
|
||||
assert reopened is not None
|
||||
assert adapter.last_build_model is None # alias dropped before reaching build_session
|
||||
|
||||
|
||||
def test_open_keeps_saved_alias_when_validator_accepts() -> None:
|
||||
"""Sanity: an alias that still resolves must be passed through
|
||||
unchanged. Filter only fires for stale aliases."""
|
||||
accepted: list[str] = []
|
||||
|
||||
def validator(alias: str) -> bool:
|
||||
accepted.append(alias)
|
||||
return True
|
||||
|
||||
mgr, adapter, storage = _make_manager(model_validator=validator)
|
||||
ws = mgr.create(user_id="u1")
|
||||
ws_id = ws.id
|
||||
storage.ws_config[ws_id] = {"model_alias": "still-live"}
|
||||
mgr.close(ws_id)
|
||||
adapter.last_build_model = "<unset>"
|
||||
|
||||
reopened = mgr.open(ws_id)
|
||||
|
||||
assert reopened is not None
|
||||
assert accepted == ["still-live"]
|
||||
assert adapter.last_build_model == "still-live"
|
||||
|
||||
|
||||
def test_open_falls_back_to_none_when_no_saved_alias() -> None:
|
||||
"""Reopening a ws with no saved alias must pass ``model=None`` to
|
||||
``build_session`` so the adapter's session_factory can fall back to
|
||||
the current default — matching the user's intent: best effort
|
||||
restore, default when the original is gone."""
|
||||
mgr, adapter, storage = _make_manager()
|
||||
ws = mgr.create(user_id="u1")
|
||||
ws_id = ws.id
|
||||
# No ws_config row — simulates "alias was never saved" or "saved
|
||||
# alias was empty string".
|
||||
assert ws_id not in storage.ws_config
|
||||
mgr.close(ws_id)
|
||||
adapter.last_build_model = "<unset>"
|
||||
|
||||
reopened = mgr.open(ws_id)
|
||||
|
||||
assert reopened is not None
|
||||
assert adapter.last_build_model is None
|
||||
|
||||
|
||||
def test_open_touches_workstream_on_rehydrate() -> None:
|
||||
"""Rehydrating a workstream must bump its ``updated`` so a concurrent
|
||||
close_idle pass-2 in this same process can't clobber the freshly-loaded
|
||||
row to ``closed`` because its DB ``updated`` is older than the cutoff.
|
||||
The touch is best-effort (try/except in open()) but must fire on the
|
||||
happy path."""
|
||||
mgr, _, storage = _make_manager()
|
||||
ws = mgr.create(user_id="u1")
|
||||
ws_id = ws.id
|
||||
mgr.close(ws_id)
|
||||
storage.touch_calls.clear() # only care about touches from rehydrate
|
||||
|
||||
reopened = mgr.open(ws_id)
|
||||
|
||||
assert reopened is not None
|
||||
assert ws_id in storage.touch_calls
|
||||
|
||||
|
||||
def test_open_ignores_owner_mismatch() -> None:
|
||||
# Turnstone is a trusted-team tool; row-level ownership is
|
||||
# metadata, not an access boundary. ``open`` no longer cares
|
||||
@@ -827,6 +1046,197 @@ def test_close_idle_on_empty_manager_returns_empty_list() -> None:
|
||||
assert mgr.close_idle(max_age_seconds=1.0) == []
|
||||
|
||||
|
||||
def test_close_idle_runs_db_orphan_pass() -> None:
|
||||
"""DB rows of this kind that aren't loaded into the manager get
|
||||
bulk-closed when their ``updated`` is older than the cutoff. Catches
|
||||
the orphan-after-process-restart case the original close_idle missed."""
|
||||
mgr, _, storage = _make_manager()
|
||||
# Orphan rows live in storage but were never loaded via mgr.create.
|
||||
storage.register_workstream(
|
||||
"orphan-1",
|
||||
kind=WorkstreamKind.INTERACTIVE,
|
||||
updated="2020-01-01T00:00:00",
|
||||
)
|
||||
storage.register_workstream(
|
||||
"orphan-2",
|
||||
kind=WorkstreamKind.INTERACTIVE,
|
||||
state="thinking",
|
||||
updated="2020-01-01T00:00:00",
|
||||
)
|
||||
|
||||
closed = mgr.close_idle(max_age_seconds=0.0)
|
||||
|
||||
assert set(closed) == {"orphan-1", "orphan-2"}
|
||||
assert ("orphan-1", "closed") in storage.state_updates
|
||||
assert ("orphan-2", "closed") in storage.state_updates
|
||||
assert storage.rows["orphan-1"].state == "closed"
|
||||
assert storage.rows["orphan-2"].state == "closed"
|
||||
|
||||
|
||||
def test_close_idle_excludes_loaded_workstreams_from_db_pass() -> None:
|
||||
"""A workstream loaded into memory must NOT be reaped by the DB
|
||||
orphan pass even when its storage ``updated`` is stale — the
|
||||
in-memory pass owns those. Verifies the exclude_ws_ids plumbing."""
|
||||
mgr, _, storage = _make_manager()
|
||||
ws = mgr.create(user_id="u1")
|
||||
# Force the storage row's ``updated`` to look stale. In practice
|
||||
# ``set_state`` would bump it, but we're simulating a long-running
|
||||
# active workstream whose updated drifted older than the cutoff.
|
||||
storage.rows[ws.id].updated = "2020-01-01T00:00:00"
|
||||
|
||||
# Huge timeout so the in-memory IDLE pass skips it (stays loaded).
|
||||
closed = mgr.close_idle(max_age_seconds=10_000.0)
|
||||
|
||||
assert ws.id not in closed
|
||||
assert mgr.get(ws.id) is not None
|
||||
assert storage.rows[ws.id].state == "idle"
|
||||
|
||||
|
||||
def test_close_idle_filters_db_orphans_by_kind() -> None:
|
||||
"""An interactive manager's close_idle must not touch coordinator
|
||||
rows in storage and vice versa. Without this filter, both managers
|
||||
would race to close each other's rows."""
|
||||
mgr, _, storage = _make_manager() # interactive by default
|
||||
storage.register_workstream(
|
||||
"coord-orphan",
|
||||
kind=WorkstreamKind.COORDINATOR,
|
||||
updated="2020-01-01T00:00:00",
|
||||
)
|
||||
storage.register_workstream(
|
||||
"interactive-orphan",
|
||||
kind=WorkstreamKind.INTERACTIVE,
|
||||
updated="2020-01-01T00:00:00",
|
||||
)
|
||||
|
||||
closed = mgr.close_idle(max_age_seconds=0.0)
|
||||
|
||||
assert "interactive-orphan" in closed
|
||||
assert "coord-orphan" not in closed
|
||||
assert storage.rows["coord-orphan"].state == "idle"
|
||||
assert storage.rows["interactive-orphan"].state == "closed"
|
||||
|
||||
|
||||
def test_close_idle_protects_rows_owned_by_live_services() -> None:
|
||||
"""Multi-node correctness: rows whose ``node_id`` matches a service
|
||||
with a recent heartbeat must NOT be reaped, even when *this* manager
|
||||
is on a different node — the alive peer may legitimately have them
|
||||
loaded. Liveness is the rendezvous router's primitive (post-PR-#384);
|
||||
using it here keeps reap scoping aligned with routing.
|
||||
|
||||
Default ``_make_manager`` uses an INTERACTIVE adapter, which derives
|
||||
``service_type='server'`` — so live_services seeded under "server"
|
||||
are what the manager queries."""
|
||||
mgr, _, storage = _make_manager()
|
||||
storage.live_services["server"] = ["node-b"] # only node-b is alive
|
||||
storage.register_workstream(
|
||||
"ours-from-dead-node",
|
||||
node_id="node-a", # dead pod (not in live_services)
|
||||
kind=WorkstreamKind.INTERACTIVE,
|
||||
updated="2020-01-01T00:00:00",
|
||||
)
|
||||
storage.register_workstream(
|
||||
"theirs-still-alive",
|
||||
node_id="node-b",
|
||||
kind=WorkstreamKind.INTERACTIVE,
|
||||
updated="2020-01-01T00:00:00",
|
||||
)
|
||||
|
||||
closed = mgr.close_idle(max_age_seconds=0.0)
|
||||
|
||||
assert closed == ["ours-from-dead-node"]
|
||||
assert storage.rows["ours-from-dead-node"].state == "closed"
|
||||
assert storage.rows["theirs-still-alive"].state == "idle"
|
||||
|
||||
|
||||
def test_close_idle_protects_live_services_for_coordinator_kind() -> None:
|
||||
"""Coord-side parity: a coordinator manager derives
|
||||
``service_type='console'``, so live_services seeded under "console"
|
||||
are what gets queried. Mirrors the interactive test to ensure both
|
||||
halves of the production wiring are exercised."""
|
||||
coord_adapter = FakeAdapter(kind=WorkstreamKind.COORDINATOR)
|
||||
mgr, _, storage = _make_manager(coord_adapter)
|
||||
storage.live_services["console"] = ["console"] # console is alive
|
||||
storage.register_workstream(
|
||||
"alive-console-coord",
|
||||
node_id="console",
|
||||
kind=WorkstreamKind.COORDINATOR,
|
||||
updated="2020-01-01T00:00:00",
|
||||
)
|
||||
storage.register_workstream(
|
||||
"dead-console-coord",
|
||||
node_id="dead-console-instance", # not in live set
|
||||
kind=WorkstreamKind.COORDINATOR,
|
||||
updated="2020-01-01T00:00:00",
|
||||
)
|
||||
|
||||
closed = mgr.close_idle(max_age_seconds=0.0)
|
||||
|
||||
assert closed == ["dead-console-coord"]
|
||||
assert storage.rows["alive-console-coord"].state == "idle"
|
||||
assert storage.rows["dead-console-coord"].state == "closed"
|
||||
|
||||
|
||||
def test_close_idle_reaps_rows_with_null_node_id() -> None:
|
||||
"""A row with no ``node_id`` has no owner identity — age alone gates
|
||||
the reap. Defends against a NULL silently propagating through ``NOT
|
||||
IN (live)`` and protecting orphans forever."""
|
||||
mgr, _, storage = _make_manager()
|
||||
storage.live_services["server"] = ["node-a"]
|
||||
storage.register_workstream(
|
||||
"no-owner",
|
||||
node_id=None,
|
||||
kind=WorkstreamKind.INTERACTIVE,
|
||||
updated="2020-01-01T00:00:00",
|
||||
)
|
||||
|
||||
closed = mgr.close_idle(max_age_seconds=0.0)
|
||||
|
||||
assert closed == ["no-owner"]
|
||||
|
||||
|
||||
def test_close_idle_reaps_all_orphans_when_no_peers_alive() -> None:
|
||||
"""When ``list_services`` returns an empty list (no heartbeating
|
||||
peers), every stale orphan is unprotected and gets reaped. This is
|
||||
the cold-start / single-process / dead-cluster-recovery case."""
|
||||
mgr, _, storage = _make_manager()
|
||||
# storage.live_services["server"] left empty — no peers heartbeating
|
||||
storage.register_workstream(
|
||||
"any-node-1",
|
||||
node_id="node-a",
|
||||
kind=WorkstreamKind.INTERACTIVE,
|
||||
updated="2020-01-01T00:00:00",
|
||||
)
|
||||
storage.register_workstream(
|
||||
"any-node-2",
|
||||
node_id="node-b",
|
||||
kind=WorkstreamKind.INTERACTIVE,
|
||||
updated="2020-01-01T00:00:00",
|
||||
)
|
||||
|
||||
closed = mgr.close_idle(max_age_seconds=0.0)
|
||||
|
||||
assert set(closed) == {"any-node-1", "any-node-2"}
|
||||
|
||||
|
||||
def test_close_idle_skips_pass_2_when_list_services_fails() -> None:
|
||||
"""Conservative fallback: if list_services fails we can't enumerate
|
||||
live owners safely, so pass 2 must skip rather than reap blind. Pass
|
||||
1 (in-memory IDLE) still runs."""
|
||||
mgr, _, storage = _make_manager()
|
||||
storage.list_services_raises = True
|
||||
storage.register_workstream(
|
||||
"would-be-orphan",
|
||||
node_id="node-a",
|
||||
kind=WorkstreamKind.INTERACTIVE,
|
||||
updated="2020-01-01T00:00:00",
|
||||
)
|
||||
|
||||
closed = mgr.close_idle(max_age_seconds=0.0)
|
||||
|
||||
assert closed == []
|
||||
assert storage.rows["would-be-orphan"].state == "idle"
|
||||
|
||||
|
||||
def test_list_all_returns_creation_order() -> None:
|
||||
mgr, _, _ = _make_manager()
|
||||
a = mgr.create(user_id="u1")
|
||||
@@ -1084,3 +1494,127 @@ class TestSessionManagerWithStateWriter:
|
||||
assert "running" not in ws_writes, (
|
||||
f"set_state after close enqueued through buffer: {ws_writes}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Multi-subscriber observer — subscribe_to_state / unsubscribe_from_state
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestStateSubscribers:
|
||||
"""Multi-subscriber observer for ``set_state``.
|
||||
|
||||
Used by the CLI's background-attention notifier and by
|
||||
``SameNodeChildSource``. Subscribe / unsubscribe must be safe under
|
||||
concurrent dispatch, and dispatch must not skip / repeat callbacks
|
||||
when subscribers register or unregister mid-iteration.
|
||||
"""
|
||||
|
||||
def test_subscribe_fires_on_set_state(self) -> None:
|
||||
mgr, _, _ = _make_manager()
|
||||
ws = mgr.create(user_id="u1", name="ws", skill=None)
|
||||
events: list[tuple[str, str]] = []
|
||||
|
||||
def cb(ws_id: str, state: WorkstreamState) -> None:
|
||||
events.append((ws_id, state.value))
|
||||
|
||||
mgr.subscribe_to_state(cb)
|
||||
mgr.set_state(ws.id, WorkstreamState.RUNNING)
|
||||
assert events == [(ws.id, "running")]
|
||||
|
||||
def test_unsubscribe_stops_firing(self) -> None:
|
||||
mgr, _, _ = _make_manager()
|
||||
ws = mgr.create(user_id="u1", name="ws", skill=None)
|
||||
events: list[str] = []
|
||||
|
||||
def cb(_ws_id: str, state: WorkstreamState) -> None:
|
||||
events.append(state.value)
|
||||
|
||||
mgr.subscribe_to_state(cb)
|
||||
mgr.unsubscribe_from_state(cb)
|
||||
mgr.set_state(ws.id, WorkstreamState.RUNNING)
|
||||
assert events == []
|
||||
|
||||
def test_unsubscribe_unknown_is_noop(self) -> None:
|
||||
mgr, _, _ = _make_manager()
|
||||
# Doesn't raise.
|
||||
mgr.unsubscribe_from_state(lambda *_: None)
|
||||
|
||||
def test_multiple_subscribers_fire_in_registration_order(self) -> None:
|
||||
mgr, _, _ = _make_manager()
|
||||
ws = mgr.create(user_id="u1", name="ws", skill=None)
|
||||
order: list[int] = []
|
||||
|
||||
def make(i: int) -> Callable[[str, WorkstreamState], None]:
|
||||
def cb(_ws_id: str, _state: WorkstreamState) -> None:
|
||||
order.append(i)
|
||||
|
||||
return cb
|
||||
|
||||
mgr.subscribe_to_state(make(1))
|
||||
mgr.subscribe_to_state(make(2))
|
||||
mgr.subscribe_to_state(make(3))
|
||||
mgr.set_state(ws.id, WorkstreamState.IDLE)
|
||||
assert order == [1, 2, 3]
|
||||
|
||||
def test_subscriber_exception_does_not_block_others(self) -> None:
|
||||
mgr, _, _ = _make_manager()
|
||||
ws = mgr.create(user_id="u1", name="ws", skill=None)
|
||||
survived: list[str] = []
|
||||
|
||||
def boom(*_: Any) -> None:
|
||||
raise RuntimeError("subscriber crash")
|
||||
|
||||
def good(_ws_id: str, state: WorkstreamState) -> None:
|
||||
survived.append(state.value)
|
||||
|
||||
mgr.subscribe_to_state(boom)
|
||||
mgr.subscribe_to_state(good)
|
||||
mgr.set_state(ws.id, WorkstreamState.RUNNING)
|
||||
assert survived == ["running"]
|
||||
|
||||
@pytest.mark.parametrize("n_threads", [10, 50])
|
||||
def test_concurrent_subscribe(self, n_threads: int) -> None:
|
||||
"""Subscribe from many threads; all callbacks land in the list.
|
||||
|
||||
Validates the lock around mutation — without it the underlying
|
||||
list.append could lose entries under contention.
|
||||
"""
|
||||
mgr, _, _ = _make_manager()
|
||||
callbacks = [lambda *_, i=i: None for i in range(n_threads)]
|
||||
threads = [threading.Thread(target=mgr.subscribe_to_state, args=(cb,)) for cb in callbacks]
|
||||
for t in threads:
|
||||
t.start()
|
||||
for t in threads:
|
||||
t.join()
|
||||
# Snapshot under the lock to read the count safely.
|
||||
with mgr._state_subscribers_lock:
|
||||
assert len(mgr._state_subscribers) == n_threads
|
||||
|
||||
def test_subscribe_during_dispatch_does_not_corrupt_iteration(self) -> None:
|
||||
"""A subscriber that calls subscribe_to_state during its own
|
||||
callback must not affect the in-flight dispatch (snapshot
|
||||
isolation). This is the bug-1 invariant: mutation during
|
||||
iteration can't shift the iterator's index because dispatch
|
||||
iterates a snapshot, not the live list.
|
||||
"""
|
||||
mgr, _, _ = _make_manager()
|
||||
ws = mgr.create(user_id="u1", name="ws", skill=None)
|
||||
fired: list[str] = []
|
||||
|
||||
def late(_ws_id: str, state: WorkstreamState) -> None:
|
||||
fired.append("late:" + state.value)
|
||||
|
||||
def first(_ws_id: str, state: WorkstreamState) -> None:
|
||||
fired.append("first:" + state.value)
|
||||
mgr.subscribe_to_state(late) # mid-dispatch addition
|
||||
|
||||
mgr.subscribe_to_state(first)
|
||||
mgr.set_state(ws.id, WorkstreamState.RUNNING)
|
||||
# ``late`` was added during dispatch but the snapshot was
|
||||
# already frozen — so it doesn't fire on this round.
|
||||
assert fired == ["first:running"]
|
||||
# Next round it does fire, in registration order after first.
|
||||
fired.clear()
|
||||
mgr.set_state(ws.id, WorkstreamState.IDLE)
|
||||
assert fired == ["first:idle", "late:idle"]
|
||||
|
||||
+108
-5
@@ -523,8 +523,15 @@ class TestWorkstreamConfig:
|
||||
assert session.instructions == "be concise"
|
||||
assert session.creative_mode is True
|
||||
|
||||
def test_resume_restores_model(self, tmp_db):
|
||||
"""ChatSession.resume() should restore the model from workstream config."""
|
||||
def test_resume_keeps_defaults_when_alias_unresolvable(self, tmp_db):
|
||||
"""When the saved alias is empty or no longer in the registry,
|
||||
``resume()`` must NOT copy ``saved_model`` onto the constructor's
|
||||
default provider. Pairing a removed model name with a default
|
||||
provider that doesn't know about it produces a broken session
|
||||
whose next API call fails — the exact regression Copilot flagged
|
||||
on PR #465. The constructor already resolved a coherent default
|
||||
(provider + model + capabilities); resume should leave it intact
|
||||
and just log the unreachable saved values."""
|
||||
client = MagicMock()
|
||||
client.models.list.return_value.data = [MagicMock(id="test-model")]
|
||||
ui = MagicMock()
|
||||
@@ -533,13 +540,14 @@ class TestWorkstreamConfig:
|
||||
ui.on_state_change = MagicMock()
|
||||
ui.on_rename = MagicMock()
|
||||
|
||||
# Create a workstream that was using a specific model
|
||||
register_workstream("model_ws")
|
||||
save_message("model_ws", "user", "hello")
|
||||
save_message("model_ws", "assistant", "hi")
|
||||
# Empty alias + an orphan model name — same shape resume sees
|
||||
# when an operator removes an alias from the registry that the
|
||||
# workstream was originally pinned to.
|
||||
save_workstream_config("model_ws", {"model": "gpt-5", "model_alias": ""})
|
||||
|
||||
# Resume into a session that was created with a different model
|
||||
session = ChatSession(
|
||||
client=client,
|
||||
model="gpt-5-nano",
|
||||
@@ -552,7 +560,102 @@ class TestWorkstreamConfig:
|
||||
assert session.model == "gpt-5-nano"
|
||||
result = session.resume("model_ws")
|
||||
assert result is True
|
||||
assert session.model == "gpt-5"
|
||||
# Constructor's coherent default is preserved — saved orphan
|
||||
# model name is NOT copied over.
|
||||
assert session.model == "gpt-5-nano"
|
||||
|
||||
def test_init_does_not_clobber_existing_config(self, tmp_db):
|
||||
"""ChatSession.__init__ must NOT overwrite existing
|
||||
``workstream_config`` keys when constructing for an already-
|
||||
persisted ws_id.
|
||||
|
||||
This is the fix for the rehydrate bug: ``SessionManager.open()``
|
||||
builds a ChatSession with the persisted ws_id; the legacy
|
||||
``__init__`` unconditionally called ``_save_config()`` which is
|
||||
``INSERT OR REPLACE`` per-key — silently resetting model_alias,
|
||||
temperature, reasoning_effort, max_tokens, skill, creative_mode,
|
||||
and instructions to the constructor defaults *before*
|
||||
``resume()`` got a chance to read them back.
|
||||
"""
|
||||
client = MagicMock()
|
||||
client.models.list.return_value.data = [MagicMock(id="test-model")]
|
||||
ui = MagicMock()
|
||||
ui.on_info = MagicMock()
|
||||
ui.on_error = MagicMock()
|
||||
ui.on_state_change = MagicMock()
|
||||
ui.on_rename = MagicMock()
|
||||
|
||||
register_workstream("rehydrate_ws")
|
||||
save_workstream_config(
|
||||
"rehydrate_ws",
|
||||
{
|
||||
"model": "gpt-5-pro",
|
||||
"model_alias": "gpt-5-pro",
|
||||
"temperature": "0.2",
|
||||
"reasoning_effort": "high",
|
||||
"max_tokens": "8192",
|
||||
"creative_mode": "True",
|
||||
"instructions": "preserve me",
|
||||
},
|
||||
)
|
||||
|
||||
ChatSession(
|
||||
client=client,
|
||||
model="some-default-model",
|
||||
ui=ui,
|
||||
instructions=None,
|
||||
temperature=0.7,
|
||||
max_tokens=4096,
|
||||
tool_timeout=30,
|
||||
reasoning_effort="medium",
|
||||
ws_id="rehydrate_ws",
|
||||
)
|
||||
|
||||
loaded = load_workstream_config("rehydrate_ws")
|
||||
assert loaded["model"] == "gpt-5-pro"
|
||||
assert loaded["model_alias"] == "gpt-5-pro"
|
||||
assert loaded["temperature"] == "0.2"
|
||||
assert loaded["reasoning_effort"] == "high"
|
||||
assert loaded["max_tokens"] == "8192"
|
||||
assert loaded["creative_mode"] == "True"
|
||||
assert loaded["instructions"] == "preserve me"
|
||||
|
||||
def test_init_writes_config_on_fresh_create(self, tmp_db):
|
||||
"""The opposite half of the contract: when no config row exists
|
||||
yet, ``__init__`` must still persist the constructor's values so
|
||||
a later resume can find them. This is the path that previously
|
||||
worked — the fix must not break it."""
|
||||
client = MagicMock()
|
||||
client.models.list.return_value.data = [MagicMock(id="test-model")]
|
||||
ui = MagicMock()
|
||||
ui.on_info = MagicMock()
|
||||
ui.on_error = MagicMock()
|
||||
ui.on_state_change = MagicMock()
|
||||
ui.on_rename = MagicMock()
|
||||
|
||||
# No save_workstream_config() before ChatSession() — this is
|
||||
# the fresh-create path the SessionManager.create() flow takes.
|
||||
register_workstream("fresh_ws")
|
||||
assert load_workstream_config("fresh_ws") == {}
|
||||
|
||||
ChatSession(
|
||||
client=client,
|
||||
model="gpt-5-mini",
|
||||
ui=ui,
|
||||
instructions="be terse",
|
||||
temperature=0.4,
|
||||
max_tokens=2048,
|
||||
tool_timeout=30,
|
||||
reasoning_effort="low",
|
||||
ws_id="fresh_ws",
|
||||
)
|
||||
|
||||
loaded = load_workstream_config("fresh_ws")
|
||||
assert loaded["model"] == "gpt-5-mini"
|
||||
assert loaded["temperature"] == "0.4"
|
||||
assert loaded["reasoning_effort"] == "low"
|
||||
assert loaded["max_tokens"] == "2048"
|
||||
assert loaded["instructions"] == "be terse"
|
||||
|
||||
|
||||
# ── Prune workstreams ─────────────────────────────────────────────────
|
||||
|
||||
@@ -4,6 +4,10 @@ from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from turnstone.core.storage._schema import workstreams
|
||||
|
||||
# -- Workstream registration ---------------------------------------------------
|
||||
|
||||
|
||||
@@ -664,6 +668,277 @@ class TestBatchPrimitives:
|
||||
assert result == {"never-seen": 0}
|
||||
|
||||
|
||||
# -- bulk_close_stale_orphans --------------------------------------------------
|
||||
|
||||
|
||||
def _force_updated(backend: Any, ws_id: str, updated: str) -> None:
|
||||
"""Stamp a workstream row's ``updated`` column directly.
|
||||
|
||||
The public surface only sets ``updated`` to ``now``, which makes it
|
||||
impossible to fabricate a stale row through register/update calls.
|
||||
Reaches into ``backend._engine`` — same access pattern conftest uses
|
||||
for cross-backend cleanup.
|
||||
"""
|
||||
with backend._engine.connect() as conn:
|
||||
conn.execute(
|
||||
sa.update(workstreams).where(workstreams.c.ws_id == ws_id).values(updated=updated)
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
class TestBulkCloseStaleOrphans:
|
||||
def test_closes_stale_non_terminal_rows_of_kind(self, backend):
|
||||
backend.register_workstream("stale-idle", kind="interactive")
|
||||
backend.register_workstream("stale-thinking", kind="interactive")
|
||||
backend.update_workstream_state("stale-thinking", "thinking")
|
||||
backend.register_workstream("fresh-idle", kind="interactive")
|
||||
_force_updated(backend, "stale-idle", "2020-01-01T00:00:00")
|
||||
_force_updated(backend, "stale-thinking", "2020-01-01T00:00:00")
|
||||
# fresh-idle stays at registration time (effectively now)
|
||||
|
||||
closed = backend.bulk_close_stale_orphans(
|
||||
"interactive", cutoff="2024-01-01T00:00:00", exclude_ws_ids=[]
|
||||
)
|
||||
|
||||
assert set(closed) == {"stale-idle", "stale-thinking"}
|
||||
rows = backend.get_workstreams_batch(["stale-idle", "stale-thinking", "fresh-idle"])
|
||||
assert rows["stale-idle"]["state"] == "closed"
|
||||
assert rows["stale-thinking"]["state"] == "closed"
|
||||
assert rows["fresh-idle"]["state"] == "idle"
|
||||
|
||||
def test_skips_already_closed(self, backend):
|
||||
backend.register_workstream("already-closed", kind="interactive")
|
||||
backend.update_workstream_state("already-closed", "closed")
|
||||
_force_updated(backend, "already-closed", "2020-01-01T00:00:00")
|
||||
|
||||
closed = backend.bulk_close_stale_orphans(
|
||||
"interactive", cutoff="2024-01-01T00:00:00", exclude_ws_ids=[]
|
||||
)
|
||||
|
||||
assert closed == []
|
||||
|
||||
def test_filters_by_kind(self, backend):
|
||||
backend.register_workstream("interactive-stale", kind="interactive")
|
||||
backend.register_workstream("coord-stale", kind="coordinator")
|
||||
_force_updated(backend, "interactive-stale", "2020-01-01T00:00:00")
|
||||
_force_updated(backend, "coord-stale", "2020-01-01T00:00:00")
|
||||
|
||||
closed = backend.bulk_close_stale_orphans(
|
||||
"interactive", cutoff="2024-01-01T00:00:00", exclude_ws_ids=[]
|
||||
)
|
||||
|
||||
assert closed == ["interactive-stale"]
|
||||
rows = backend.get_workstreams_batch(["interactive-stale", "coord-stale"])
|
||||
assert rows["interactive-stale"]["state"] == "closed"
|
||||
assert rows["coord-stale"]["state"] == "idle"
|
||||
|
||||
def test_excludes_loaded_ws_ids(self, backend):
|
||||
backend.register_workstream("ws-keep", kind="interactive")
|
||||
backend.register_workstream("ws-close", kind="interactive")
|
||||
_force_updated(backend, "ws-keep", "2020-01-01T00:00:00")
|
||||
_force_updated(backend, "ws-close", "2020-01-01T00:00:00")
|
||||
|
||||
closed = backend.bulk_close_stale_orphans(
|
||||
"interactive", cutoff="2024-01-01T00:00:00", exclude_ws_ids=["ws-keep"]
|
||||
)
|
||||
|
||||
assert closed == ["ws-close"]
|
||||
rows = backend.get_workstreams_batch(["ws-keep", "ws-close"])
|
||||
assert rows["ws-keep"]["state"] == "idle"
|
||||
assert rows["ws-close"]["state"] == "closed"
|
||||
|
||||
def test_empty_exclude_list_does_not_break_sql(self, backend):
|
||||
backend.register_workstream("orphan", kind="interactive")
|
||||
_force_updated(backend, "orphan", "2020-01-01T00:00:00")
|
||||
|
||||
closed = backend.bulk_close_stale_orphans(
|
||||
"interactive", cutoff="2024-01-01T00:00:00", exclude_ws_ids=[]
|
||||
)
|
||||
|
||||
assert closed == ["orphan"]
|
||||
|
||||
def test_no_orphans_returns_empty(self, backend):
|
||||
backend.register_workstream("fresh", kind="interactive")
|
||||
|
||||
closed = backend.bulk_close_stale_orphans(
|
||||
"interactive", cutoff="2024-01-01T00:00:00", exclude_ws_ids=[]
|
||||
)
|
||||
|
||||
assert closed == []
|
||||
|
||||
def test_closes_all_non_terminal_states(self, backend):
|
||||
for ws_id, state in [
|
||||
("o-idle", "idle"),
|
||||
("o-thinking", "thinking"),
|
||||
("o-attention", "attention"),
|
||||
("o-running", "running"),
|
||||
]:
|
||||
backend.register_workstream(ws_id, kind="interactive")
|
||||
if state != "idle":
|
||||
backend.update_workstream_state(ws_id, state)
|
||||
_force_updated(backend, ws_id, "2020-01-01T00:00:00")
|
||||
|
||||
closed = backend.bulk_close_stale_orphans(
|
||||
"interactive", cutoff="2024-01-01T00:00:00", exclude_ws_ids=[]
|
||||
)
|
||||
|
||||
assert set(closed) == {"o-idle", "o-thinking", "o-attention", "o-running"}
|
||||
|
||||
def test_bumps_updated_on_close(self, backend):
|
||||
stale_updated = "2020-01-01T00:00:00"
|
||||
backend.register_workstream("orphan", kind="interactive")
|
||||
_force_updated(backend, "orphan", stale_updated)
|
||||
|
||||
backend.bulk_close_stale_orphans(
|
||||
"interactive", cutoff="2024-01-01T00:00:00", exclude_ws_ids=[]
|
||||
)
|
||||
|
||||
# ``updated`` must change away from the forced stale value. Asserting
|
||||
# inequality from the seed (rather than ``> "2024-01-01..."``) keeps
|
||||
# the test independent of wall-clock date.
|
||||
with backend._engine.connect() as conn:
|
||||
row = conn.execute(
|
||||
sa.select(workstreams.c.updated).where(workstreams.c.ws_id == "orphan")
|
||||
).one()
|
||||
assert row[0] != stale_updated
|
||||
|
||||
def test_protects_rows_owned_by_live_services(self, backend):
|
||||
"""Liveness scoping (post-#384 rendezvous-routing world): rows
|
||||
whose ``node_id`` matches a heartbeating service must NOT be
|
||||
reaped, because that owner may legitimately have them loaded on
|
||||
another worker. Rows whose ``node_id`` matches a dead service
|
||||
ARE eligible — that's how dead-pod orphans get reclaimed in
|
||||
containerized deployments with dynamic hostnames."""
|
||||
backend.register_workstream("dead-node", node_id="dead-pod-x4k2", kind="interactive")
|
||||
backend.register_workstream("alive-node", node_id="alive-pod-y9p3", kind="interactive")
|
||||
_force_updated(backend, "dead-node", "2020-01-01T00:00:00")
|
||||
_force_updated(backend, "alive-node", "2020-01-01T00:00:00")
|
||||
|
||||
closed = backend.bulk_close_stale_orphans(
|
||||
"interactive",
|
||||
cutoff="2024-01-01T00:00:00",
|
||||
exclude_ws_ids=[],
|
||||
live_node_ids=["alive-pod-y9p3"],
|
||||
)
|
||||
|
||||
assert closed == ["dead-node"]
|
||||
rows = backend.get_workstreams_batch(["dead-node", "alive-node"])
|
||||
assert rows["dead-node"]["state"] == "closed"
|
||||
assert rows["alive-node"]["state"] == "idle"
|
||||
|
||||
def test_null_node_id_always_eligible(self, backend):
|
||||
"""A row with NULL ``node_id`` has no owner identity — age alone
|
||||
gates the reap. Belt-and-suspenders against ``NULL NOT IN (...)``
|
||||
evaluating to NULL (not TRUE) and silently protecting orphans
|
||||
forever."""
|
||||
backend.register_workstream("no-owner", node_id=None, kind="interactive")
|
||||
_force_updated(backend, "no-owner", "2020-01-01T00:00:00")
|
||||
|
||||
closed = backend.bulk_close_stale_orphans(
|
||||
"interactive",
|
||||
cutoff="2024-01-01T00:00:00",
|
||||
exclude_ws_ids=[],
|
||||
live_node_ids=["some-other-node"],
|
||||
)
|
||||
|
||||
assert closed == ["no-owner"]
|
||||
|
||||
def test_live_node_ids_none_skips_filter(self, backend):
|
||||
"""``live_node_ids=None`` is the single-process / operator-backfill
|
||||
mode — all rows of *kind* are eligible regardless of node_id."""
|
||||
backend.register_workstream("node-a", node_id="node-a", kind="interactive")
|
||||
backend.register_workstream("node-b", node_id="node-b", kind="interactive")
|
||||
_force_updated(backend, "node-a", "2020-01-01T00:00:00")
|
||||
_force_updated(backend, "node-b", "2020-01-01T00:00:00")
|
||||
|
||||
closed = backend.bulk_close_stale_orphans(
|
||||
"interactive", cutoff="2024-01-01T00:00:00", exclude_ws_ids=[]
|
||||
)
|
||||
|
||||
assert set(closed) == {"node-a", "node-b"}
|
||||
|
||||
def test_empty_live_node_ids_treats_all_as_dead(self, backend):
|
||||
"""Empty list ``live_node_ids=[]`` means "no nodes alive" — every
|
||||
row's owner is unprotected. Useful for operator scripts that
|
||||
want to reap regardless of liveness."""
|
||||
backend.register_workstream("any", node_id="node-a", kind="interactive")
|
||||
_force_updated(backend, "any", "2020-01-01T00:00:00")
|
||||
|
||||
closed = backend.bulk_close_stale_orphans(
|
||||
"interactive",
|
||||
cutoff="2024-01-01T00:00:00",
|
||||
exclude_ws_ids=[],
|
||||
live_node_ids=[],
|
||||
)
|
||||
|
||||
assert closed == ["any"]
|
||||
|
||||
def test_combines_live_node_ids_and_exclude_ws_ids(self, backend):
|
||||
"""Both filters stack as AND clauses on the UPDATE. Covers the
|
||||
full 2x2 matrix to catch a future edit that replaces an AND with
|
||||
an OR or drops one of the filters: only the (orphan + dead-node)
|
||||
cell should be reaped."""
|
||||
# All four registered with the same stale ``updated``.
|
||||
for ws_id, node in [
|
||||
("loaded-alive", "alive-node"),
|
||||
("loaded-dead", "dead-node"),
|
||||
("orphan-alive", "alive-node"),
|
||||
("orphan-dead", "dead-node"),
|
||||
]:
|
||||
backend.register_workstream(ws_id, node_id=node, kind="interactive")
|
||||
_force_updated(backend, ws_id, "2020-01-01T00:00:00")
|
||||
|
||||
closed = backend.bulk_close_stale_orphans(
|
||||
"interactive",
|
||||
cutoff="2024-01-01T00:00:00",
|
||||
exclude_ws_ids=["loaded-alive", "loaded-dead"],
|
||||
live_node_ids=["alive-node"],
|
||||
)
|
||||
|
||||
# Only orphan-dead is unprotected by both filters.
|
||||
assert closed == ["orphan-dead"]
|
||||
rows = backend.get_workstreams_batch(
|
||||
["loaded-alive", "loaded-dead", "orphan-alive", "orphan-dead"]
|
||||
)
|
||||
assert rows["loaded-alive"]["state"] == "idle"
|
||||
assert rows["loaded-dead"]["state"] == "idle"
|
||||
assert rows["orphan-alive"]["state"] == "idle"
|
||||
assert rows["orphan-dead"]["state"] == "closed"
|
||||
|
||||
|
||||
# -- touch_workstream ----------------------------------------------------------
|
||||
|
||||
|
||||
class TestTouchWorkstream:
|
||||
def test_bumps_updated_only(self, backend):
|
||||
"""Used by ``open()`` on rehydrate to defend against the orphan
|
||||
reaper clobbering a freshly-loaded row. Must not change ``state``
|
||||
(the open() path explicitly avoids state writes to dodge a race
|
||||
with concurrent close())."""
|
||||
stale_updated = "2020-01-01T00:00:00"
|
||||
backend.register_workstream("ws-touch", kind="interactive")
|
||||
backend.update_workstream_state("ws-touch", "closed") # simulate prior close
|
||||
_force_updated(backend, "ws-touch", stale_updated)
|
||||
|
||||
backend.touch_workstream("ws-touch")
|
||||
|
||||
with backend._engine.connect() as conn:
|
||||
row = conn.execute(
|
||||
sa.select(workstreams.c.state, workstreams.c.updated).where(
|
||||
workstreams.c.ws_id == "ws-touch"
|
||||
)
|
||||
).one()
|
||||
assert row[0] == "closed", "state must not be modified by touch"
|
||||
# Compare against the forced stale value rather than a fixed calendar
|
||||
# date so the test is independent of wall-clock time.
|
||||
assert row[1] != stale_updated, "updated must be bumped"
|
||||
|
||||
def test_unknown_id_is_noop(self, backend):
|
||||
"""Touch on a missing id must not raise — open()'s exception
|
||||
handler is best-effort."""
|
||||
backend.touch_workstream("nonexistent") # must not raise
|
||||
|
||||
|
||||
# -- Lifecycle -----------------------------------------------------------------
|
||||
|
||||
|
||||
|
||||
@@ -67,6 +67,42 @@ class TestSearchStructuredMemories:
|
||||
assert len(results) >= 1
|
||||
assert any(r["name"] == "db_host" for r in results)
|
||||
|
||||
def test_multiword_or_matches_partial(self, tmp_db):
|
||||
"""OR-of-terms: memory matching only 1 of 3 query terms is returned."""
|
||||
save_structured_memory("postgres_config", "host=localhost port=5432")
|
||||
save_structured_memory("redis_config", "host=redis port=6379")
|
||||
save_structured_memory("unrelated", "nothing relevant here")
|
||||
|
||||
# "postgres missing_word_a missing_word_b": only postgres_config matches "postgres"
|
||||
results = search_structured_memories("postgres missing_word_a missing_word_b")
|
||||
names = {r["name"] for r in results}
|
||||
assert "postgres_config" in names
|
||||
assert "unrelated" not in names
|
||||
|
||||
def test_multiword_or_multiple_partial_matches(self, tmp_db):
|
||||
"""Multiple memories each matching different terms are all returned."""
|
||||
save_structured_memory("key_alpha", "alpha content here")
|
||||
save_structured_memory("key_beta", "beta content here")
|
||||
save_structured_memory("key_other", "completely different")
|
||||
|
||||
results = search_structured_memories("alpha beta")
|
||||
names = {r["name"] for r in results}
|
||||
assert "key_alpha" in names
|
||||
assert "key_beta" in names
|
||||
assert "key_other" not in names
|
||||
|
||||
def test_search_scope_filtering_preserved(self, tmp_db):
|
||||
"""Search with scope filter only returns memories in that scope."""
|
||||
save_structured_memory("ws1_fact", "alpha info", scope="workstream", scope_id="ws1")
|
||||
save_structured_memory("ws2_fact", "alpha info", scope="workstream", scope_id="ws2")
|
||||
save_structured_memory("global_fact", "alpha info", scope="global")
|
||||
|
||||
results = search_structured_memories("alpha", scope="workstream", scope_id="ws1")
|
||||
names = {r["name"] for r in results}
|
||||
assert "ws1_fact" in names
|
||||
assert "ws2_fact" not in names
|
||||
assert "global_fact" not in names
|
||||
|
||||
|
||||
class TestGetStructuredMemoryByName:
|
||||
def test_get_existing(self, tmp_db):
|
||||
|
||||
@@ -126,3 +126,148 @@ class TestCount:
|
||||
backend.create_structured_memory("m2", "b", "", "project", "workstream", "ws1", "2")
|
||||
assert backend.count_structured_memories(scope="global") == 1
|
||||
assert backend.count_structured_memories(scope="workstream") == 1
|
||||
|
||||
|
||||
class TestSearchOrOfTerms:
|
||||
"""Verify that multi-word search uses OR-of-terms (any term matches → row included)."""
|
||||
|
||||
def test_single_matching_term_in_multi_word_query(self, backend):
|
||||
"""Memory with content 'apple' found when query is 'apple banana cherry'."""
|
||||
backend.create_structured_memory("m1", "apple_mem", "", "project", "global", "", "apple")
|
||||
backend.create_structured_memory("m2", "other_mem", "", "project", "global", "", "grape")
|
||||
|
||||
results = backend.search_structured_memories("apple banana cherry")
|
||||
names = {r["name"] for r in results}
|
||||
assert "apple_mem" in names # matches "apple" — OR-of-terms keeps it
|
||||
assert "other_mem" not in names # "grape" matches nothing in the query
|
||||
|
||||
def test_partial_overlap_across_memories(self, backend):
|
||||
"""Each memory matches one of three terms; all three are returned."""
|
||||
backend.create_structured_memory("m1", "alpha_doc", "", "project", "global", "", "alpha")
|
||||
backend.create_structured_memory("m2", "beta_doc", "", "project", "global", "", "beta")
|
||||
backend.create_structured_memory("m3", "gamma_doc", "", "project", "global", "", "gamma")
|
||||
backend.create_structured_memory("m4", "unrelated", "", "project", "global", "", "delta")
|
||||
|
||||
results = backend.search_structured_memories("alpha beta gamma")
|
||||
names = {r["name"] for r in results}
|
||||
assert "alpha_doc" in names
|
||||
assert "beta_doc" in names
|
||||
assert "gamma_doc" in names
|
||||
assert "unrelated" not in names # "delta" doesn't appear in the query
|
||||
|
||||
def test_scope_filter_preserved(self, backend):
|
||||
"""OR-of-terms search still respects scope / scope_id filters."""
|
||||
backend.create_structured_memory(
|
||||
"m1", "ws1_note", "", "project", "workstream", "ws1", "info"
|
||||
)
|
||||
backend.create_structured_memory(
|
||||
"m2", "ws2_note", "", "project", "workstream", "ws2", "info"
|
||||
)
|
||||
backend.create_structured_memory("m3", "global_note", "", "project", "global", "", "info")
|
||||
|
||||
results = backend.search_structured_memories("info", scope="workstream", scope_id="ws1")
|
||||
names = {r["name"] for r in results}
|
||||
assert "ws1_note" in names
|
||||
assert "ws2_note" not in names
|
||||
assert "global_note" not in names
|
||||
|
||||
def test_term_cap_normalizes_unbounded_query(self, backend):
|
||||
"""A multi-KB query collapses to <= MAX terms (de-dupe + length filter)."""
|
||||
backend.create_structured_memory("m1", "alpha_doc", "", "project", "global", "", "alpha")
|
||||
backend.create_structured_memory(
|
||||
"m2", "other_doc", "", "project", "global", "", "irrelevant"
|
||||
)
|
||||
|
||||
# Build a noisy query: same word repeated, plus 1-char tokens that
|
||||
# the normalizer drops, plus the actual signal "alpha".
|
||||
noisy = " ".join(["x"] * 100 + ["alpha"] * 50)
|
||||
results = backend.search_structured_memories(noisy)
|
||||
names = {r["name"] for r in results}
|
||||
assert "alpha_doc" in names
|
||||
|
||||
|
||||
class TestVisibleStructuredMemories:
|
||||
"""Single-query union helpers used by the composition path."""
|
||||
|
||||
def test_list_visible_unions_global_workstream_user(self, backend):
|
||||
backend.create_structured_memory("m1", "g_note", "", "project", "global", "", "g")
|
||||
backend.create_structured_memory("m2", "ws_note", "", "project", "workstream", "ws1", "w")
|
||||
backend.create_structured_memory("m3", "u_note", "", "project", "user", "u1", "u")
|
||||
backend.create_structured_memory("m4", "other_ws", "", "project", "workstream", "ws2", "x")
|
||||
|
||||
scopes = [("global", ""), ("workstream", "ws1"), ("user", "u1")]
|
||||
rows = backend.list_visible_structured_memories(scopes)
|
||||
names = {r["name"] for r in rows}
|
||||
assert names == {"g_note", "ws_note", "u_note"} # ws2 excluded
|
||||
|
||||
def test_search_visible_unions_scopes_and_terms(self, backend):
|
||||
backend.create_structured_memory("m1", "g_alpha", "", "project", "global", "", "alpha")
|
||||
backend.create_structured_memory(
|
||||
"m2", "ws_beta", "", "project", "workstream", "ws1", "beta"
|
||||
)
|
||||
backend.create_structured_memory(
|
||||
"m3", "ws_other", "", "project", "workstream", "ws2", "alpha"
|
||||
)
|
||||
|
||||
scopes = [("global", ""), ("workstream", "ws1")]
|
||||
rows = backend.search_visible_structured_memories("alpha beta", scopes)
|
||||
names = {r["name"] for r in rows}
|
||||
assert "g_alpha" in names # global, matches "alpha"
|
||||
assert "ws_beta" in names # ws1, matches "beta"
|
||||
assert "ws_other" not in names # ws2 -> outside visibility
|
||||
|
||||
def test_visible_helpers_handle_empty_scopes(self, backend):
|
||||
backend.create_structured_memory("m1", "anything", "", "project", "global", "", "x")
|
||||
assert backend.list_visible_structured_memories([]) == []
|
||||
assert backend.search_visible_structured_memories("x", []) == []
|
||||
|
||||
|
||||
class TestStableOrderingOnTimestampTies:
|
||||
"""When two memories share an `updated` timestamp, secondary sort on
|
||||
memory_id keeps the order deterministic across calls.
|
||||
|
||||
`updated` is second-precision, and touch_structured_memories() can bump
|
||||
a batch to identical timestamps — without a tie-breaker BM25 input
|
||||
order shuffles run-to-run, busting the LLM-side prompt cache.
|
||||
"""
|
||||
|
||||
def _seed_with_shared_timestamp(self, backend):
|
||||
# Create three memories then force their `updated` columns equal —
|
||||
# mirrors the real-world case where a touch_structured_memories
|
||||
# batch lands them in the same second.
|
||||
for mid in ("zebra_id", "apple_id", "mango_id"):
|
||||
backend.create_structured_memory(
|
||||
mid, f"name_{mid}", "", "project", "global", "", "shared content"
|
||||
)
|
||||
import sqlalchemy as sa
|
||||
|
||||
with backend._conn() as conn:
|
||||
conn.execute(sa.text("UPDATE structured_memories SET updated = '2024-01-01T00:00:00'"))
|
||||
conn.commit()
|
||||
|
||||
def test_list_stable_order_under_tied_updated(self, backend):
|
||||
self._seed_with_shared_timestamp(backend)
|
||||
first = [r["memory_id"] for r in backend.list_structured_memories()]
|
||||
second = [r["memory_id"] for r in backend.list_structured_memories()]
|
||||
# Deterministic across calls AND sorted by memory_id ASC for ties
|
||||
assert first == second
|
||||
assert first == ["apple_id", "mango_id", "zebra_id"]
|
||||
|
||||
def test_search_stable_order_under_tied_updated(self, backend):
|
||||
self._seed_with_shared_timestamp(backend)
|
||||
first = [r["memory_id"] for r in backend.search_structured_memories("shared")]
|
||||
second = [r["memory_id"] for r in backend.search_structured_memories("shared")]
|
||||
assert first == second
|
||||
assert first == ["apple_id", "mango_id", "zebra_id"]
|
||||
|
||||
def test_visible_search_stable_order_under_tied_updated(self, backend):
|
||||
self._seed_with_shared_timestamp(backend)
|
||||
scopes = [("global", "")]
|
||||
first = [
|
||||
r["memory_id"] for r in backend.search_visible_structured_memories("shared", scopes)
|
||||
]
|
||||
second = [
|
||||
r["memory_id"] for r in backend.search_visible_structured_memories("shared", scopes)
|
||||
]
|
||||
assert first == second
|
||||
assert first == ["apple_id", "mango_id", "zebra_id"]
|
||||
|
||||
@@ -155,3 +155,179 @@ class TestContentAccumulation:
|
||||
assert len(idle_events) == 1
|
||||
# Content should be capped, not contain everything
|
||||
assert len(idle_events[0]["content"]) <= _MAX_TURN_CONTENT_CHARS + 1024
|
||||
|
||||
|
||||
class TestPendingApprovalDetailNotPiggybacked:
|
||||
"""Stage 3 cleanup — ``pending_approval_detail`` is no longer
|
||||
piggybacked on ``ws_state`` events. Approval items now arrive via
|
||||
bulk fetch when the coord tree's reducer sees the
|
||||
``activity_state="approval"`` transition; verdicts via the explicit
|
||||
``intent_verdict`` event class; resolution via
|
||||
``approval_resolved``. These tests lock the no-piggyback contract
|
||||
down so a future regression doesn't silently re-introduce the
|
||||
duplicated path."""
|
||||
|
||||
def test_state_broadcast_omits_field_when_no_approval_pending(self):
|
||||
ui = _make_ui()
|
||||
assert ui._pending_approval is None
|
||||
ui._broadcast_state("running")
|
||||
|
||||
events = _drain_global()
|
||||
running_events = [e for e in events if e.get("state") == "running"]
|
||||
assert len(running_events) == 1
|
||||
assert "pending_approval_detail" not in running_events[0]
|
||||
|
||||
def test_state_broadcast_omits_field_even_when_approval_pending(self):
|
||||
"""The piggyback is gone: even when ``_pending_approval`` is set,
|
||||
the state broadcast must NOT carry ``pending_approval_detail``.
|
||||
The browser triggers a bulk fetch off the
|
||||
``activity_state="approval"`` transition to get the items."""
|
||||
ui = _make_ui()
|
||||
ui._pending_approval = {
|
||||
"type": "approve_request",
|
||||
"items": [
|
||||
{
|
||||
"call_id": "c1",
|
||||
"header": "tool x",
|
||||
"func_args": "{}",
|
||||
"intent_summary": "do x",
|
||||
"needs_approval": True,
|
||||
}
|
||||
],
|
||||
"judge_pending": False,
|
||||
}
|
||||
ui._broadcast_state("attention")
|
||||
|
||||
events = _drain_global()
|
||||
attn = [e for e in events if e.get("state") == "attention"]
|
||||
assert len(attn) == 1
|
||||
assert "pending_approval_detail" not in attn[0]
|
||||
|
||||
def test_field_stays_absent_after_approval_resolves(self):
|
||||
ui = _make_ui()
|
||||
ui._pending_approval = {
|
||||
"type": "approve_request",
|
||||
"items": [{"call_id": "c1", "header": "x"}],
|
||||
"judge_pending": False,
|
||||
}
|
||||
ui._broadcast_state("attention")
|
||||
_drain_global()
|
||||
|
||||
ui._pending_approval = None
|
||||
ui._broadcast_state("running")
|
||||
events = _drain_global()
|
||||
running = [e for e in events if e.get("state") == "running"]
|
||||
assert len(running) == 1
|
||||
assert "pending_approval_detail" not in running[0]
|
||||
|
||||
|
||||
class TestBroadcastIntentVerdict:
|
||||
"""Producer-side coverage for ``WebUI._broadcast_intent_verdict``.
|
||||
|
||||
The collector-side test (``test_apply_delta_intent_verdict_*`` in
|
||||
test_console.py) covers consumption; this pins the event shape the
|
||||
producer puts on the global queue. A field rename or missed key
|
||||
here would slip past the consumer test because the consumer reads
|
||||
via ``data.get(...)``.
|
||||
"""
|
||||
|
||||
def test_pushes_intent_verdict_event_to_global_queue(self):
|
||||
ui = _make_ui()
|
||||
verdict = {
|
||||
"call_id": "c1",
|
||||
"risk_level": "low",
|
||||
"confidence": 0.92,
|
||||
"recommendation": "approve",
|
||||
"reasoning": "tool reads only",
|
||||
}
|
||||
ui._broadcast_intent_verdict(verdict)
|
||||
|
||||
events = _drain_global()
|
||||
assert len(events) == 1
|
||||
ev = events[0]
|
||||
assert ev["type"] == "intent_verdict"
|
||||
assert ev["ws_id"] == "ws-test"
|
||||
assert ev["verdict"] == verdict
|
||||
|
||||
def test_no_op_when_global_queue_unset(self):
|
||||
WebUI._global_queue = None
|
||||
ui = _make_ui()
|
||||
# Doesn't raise.
|
||||
ui._broadcast_intent_verdict({"call_id": "c1"})
|
||||
|
||||
def test_queue_full_swallowed(self):
|
||||
# Force a tiny queue then fill it so the next put_nowait
|
||||
# raises queue.Full — the broadcast must absorb it without
|
||||
# propagating (matches _broadcast_state's queue.Full handling).
|
||||
WebUI._global_queue = queue.Queue(maxsize=1)
|
||||
WebUI._global_queue.put_nowait({"sentinel": True})
|
||||
ui = _make_ui()
|
||||
# Doesn't raise.
|
||||
ui._broadcast_intent_verdict({"call_id": "c1"})
|
||||
|
||||
|
||||
class TestBroadcastApprovalResolved:
|
||||
"""Producer-side coverage for ``WebUI._broadcast_approval_resolved``."""
|
||||
|
||||
def test_pushes_approval_resolved_event_to_global_queue(self):
|
||||
ui = _make_ui()
|
||||
ui._broadcast_approval_resolved(True, "lgtm", always=False)
|
||||
|
||||
events = _drain_global()
|
||||
assert len(events) == 1
|
||||
ev = events[0]
|
||||
assert ev["type"] == "approval_resolved"
|
||||
assert ev["ws_id"] == "ws-test"
|
||||
assert ev["approved"] is True
|
||||
assert ev["feedback"] == "lgtm"
|
||||
assert ev["always"] is False
|
||||
|
||||
def test_normalises_none_feedback_to_empty_string(self):
|
||||
ui = _make_ui()
|
||||
ui._broadcast_approval_resolved(False, None)
|
||||
|
||||
events = _drain_global()
|
||||
assert events[0]["feedback"] == ""
|
||||
assert events[0]["approved"] is False
|
||||
assert events[0]["always"] is False
|
||||
|
||||
def test_always_kwarg_propagates(self):
|
||||
ui = _make_ui()
|
||||
ui._broadcast_approval_resolved(True, "ok", always=True)
|
||||
events = _drain_global()
|
||||
assert events[0]["always"] is True
|
||||
|
||||
def test_no_op_when_global_queue_unset(self):
|
||||
WebUI._global_queue = None
|
||||
ui = _make_ui()
|
||||
# Doesn't raise.
|
||||
ui._broadcast_approval_resolved(True, None)
|
||||
|
||||
|
||||
class TestBroadcastApproveRequest:
|
||||
"""Producer-side coverage for ``WebUI._broadcast_approve_request`` —
|
||||
push path for the initial approval items so a coord parent's tree
|
||||
UI can render the inline approve/deny block immediately without
|
||||
waiting for a bulk-fetch round-trip."""
|
||||
|
||||
def test_pushes_approve_request_event_to_global_queue(self):
|
||||
ui = _make_ui()
|
||||
detail = {
|
||||
"type": "approve_request",
|
||||
"items": [{"call_id": "c1", "header": "tool x"}],
|
||||
"judge_pending": True,
|
||||
}
|
||||
ui._broadcast_approve_request(detail)
|
||||
|
||||
events = _drain_global()
|
||||
assert len(events) == 1
|
||||
ev = events[0]
|
||||
assert ev["type"] == "approve_request"
|
||||
assert ev["ws_id"] == "ws-test"
|
||||
assert ev["detail"] == detail
|
||||
|
||||
def test_no_op_when_global_queue_unset(self):
|
||||
WebUI._global_queue = None
|
||||
ui = _make_ui()
|
||||
# Doesn't raise.
|
||||
ui._broadcast_approve_request({"items": []})
|
||||
|
||||
@@ -899,6 +899,138 @@ class TestHistoryInteractive:
|
||||
assert client.get(base, params={"limit": 999}).status_code == 200
|
||||
|
||||
|
||||
class TestBuildHistoryReminderPropagation:
|
||||
"""``_build_history`` must surface the ``_reminders`` side-channel on
|
||||
each entry so a tab reconnecting via ``/history`` renders the same
|
||||
metacognitive nudge bubble the originating tab saw via the live
|
||||
``user_reminder`` SSE event.
|
||||
"""
|
||||
|
||||
def _session_with_messages(self, messages: list[dict]) -> MagicMock:
|
||||
session = MagicMock()
|
||||
session.messages = messages
|
||||
return session
|
||||
|
||||
def test_reminders_sidechannel_surfaces_on_entry(self):
|
||||
from turnstone.server import _build_history
|
||||
|
||||
session = self._session_with_messages(
|
||||
[
|
||||
{
|
||||
"role": "user",
|
||||
"content": "ah no",
|
||||
"_reminders": [{"type": "correction", "text": "watch out"}],
|
||||
}
|
||||
]
|
||||
)
|
||||
history = _build_history(session)
|
||||
assert history[0]["content"] == "ah no"
|
||||
assert history[0]["reminders"] == [{"type": "correction", "text": "watch out"}]
|
||||
|
||||
def test_no_reminders_key_when_sidechannel_absent(self):
|
||||
from turnstone.server import _build_history
|
||||
|
||||
session = self._session_with_messages([{"role": "user", "content": "just a message"}])
|
||||
history = _build_history(session)
|
||||
assert "reminders" not in history[0]
|
||||
|
||||
def test_no_reminders_key_when_sidechannel_empty(self):
|
||||
from turnstone.server import _build_history
|
||||
|
||||
session = self._session_with_messages([{"role": "user", "content": "hi", "_reminders": []}])
|
||||
history = _build_history(session)
|
||||
assert "reminders" not in history[0]
|
||||
|
||||
def test_multiple_reminders_preserved_in_order(self):
|
||||
from turnstone.server import _build_history
|
||||
|
||||
session = self._session_with_messages(
|
||||
[
|
||||
{
|
||||
"role": "user",
|
||||
"content": "x",
|
||||
"_reminders": [
|
||||
{"type": "denial", "text": "FIRST"},
|
||||
{"type": "correction", "text": "SECOND"},
|
||||
],
|
||||
}
|
||||
]
|
||||
)
|
||||
history = _build_history(session)
|
||||
assert history[0]["reminders"] == [
|
||||
{"type": "denial", "text": "FIRST"},
|
||||
{"type": "correction", "text": "SECOND"},
|
||||
]
|
||||
|
||||
def test_reminders_coexist_with_attachments(self):
|
||||
from turnstone.server import _build_history
|
||||
|
||||
session = self._session_with_messages(
|
||||
[
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": "look"},
|
||||
{"type": "image_url", "image_url": {"url": "data:..."}},
|
||||
],
|
||||
"_reminders": [{"type": "correction", "text": "watch"}],
|
||||
}
|
||||
]
|
||||
)
|
||||
history = _build_history(session)
|
||||
assert history[0]["content"] == "look"
|
||||
assert history[0]["attachments"] == [{"kind": "image", "filename": "", "mime_type": ""}]
|
||||
assert history[0]["reminders"] == [{"type": "correction", "text": "watch"}]
|
||||
|
||||
def test_malformed_reminders_filtered_out(self):
|
||||
"""Defensive: a non-dict element in the list (corruption / bug)
|
||||
is dropped rather than crashing the history serialisation."""
|
||||
from turnstone.server import _build_history
|
||||
|
||||
session = self._session_with_messages(
|
||||
[
|
||||
{
|
||||
"role": "user",
|
||||
"content": "x",
|
||||
"_reminders": [
|
||||
{"type": "correction", "text": "ok"},
|
||||
"not-a-dict",
|
||||
{"type": "denial"}, # missing text
|
||||
],
|
||||
}
|
||||
]
|
||||
)
|
||||
history = _build_history(session)
|
||||
# Non-dicts dropped; missing-text fills with empty string.
|
||||
assert history[0]["reminders"] == [
|
||||
{"type": "correction", "text": "ok"},
|
||||
{"type": "denial", "text": ""},
|
||||
]
|
||||
|
||||
def test_clean_message_passes_through_unchanged(self):
|
||||
"""No reminders, plain content — _build_history is a no-op for the
|
||||
reminder field and ``content`` rides through verbatim."""
|
||||
from turnstone.server import _build_history
|
||||
|
||||
session = self._session_with_messages(
|
||||
[{"role": "user", "content": "just a normal message"}]
|
||||
)
|
||||
history = _build_history(session)
|
||||
assert history[0]["content"] == "just a normal message"
|
||||
assert "reminders" not in history[0]
|
||||
|
||||
def test_assistant_content_with_literal_reminder_tag_unchanged(self):
|
||||
"""Assistant output may legitimately reference the tag (e.g. when
|
||||
the model is explaining the reminder system itself). No
|
||||
transformation should ever apply to assistant content."""
|
||||
from turnstone.server import _build_history
|
||||
|
||||
content = "Here is a <system-reminder> tag in assistant output."
|
||||
session = self._session_with_messages([{"role": "assistant", "content": content}])
|
||||
history = _build_history(session)
|
||||
assert history[0]["content"] == content
|
||||
|
||||
|
||||
class TestDetailInteractive:
|
||||
"""Interactive parity for the lifted ``GET /v1/api/workstreams/{ws_id}``.
|
||||
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
"""turnstone - Multi-node AI orchestration platform with tool use, agent routing, and cluster simulation."""
|
||||
|
||||
__version__ = "1.5.0"
|
||||
__version__ = "1.5.7"
|
||||
|
||||
+24
-1
@@ -312,6 +312,29 @@ class TerminalUI(SessionUI):
|
||||
sys.stdout.write(f"{RED}{message}{RESET}\n")
|
||||
sys.stdout.flush()
|
||||
|
||||
def _print_reminder(self, reminders: list[dict[str, str]]) -> None:
|
||||
"""Render a metacognitive reminder list as ``[metacognition · type] text``
|
||||
lines in the terminal — the CLI's equivalent of the web UI's
|
||||
yellow themed bubble. Used by both ``on_user_reminder`` and
|
||||
``on_tool_reminder``; the rendering is identical because
|
||||
terminal output is anchor-by-flow rather than DOM-by-anchor.
|
||||
"""
|
||||
for r in reminders:
|
||||
nt = str(r.get("type", "") or "")
|
||||
text = str(r.get("text", "") or "")
|
||||
label = "metacognition" + (f" · {nt}" if nt else "")
|
||||
sys.stdout.write(f"{YELLOW}[{label}]{RESET} {text}\n")
|
||||
sys.stdout.flush()
|
||||
|
||||
def on_user_reminder(self, reminders: list[dict[str, str]]) -> None:
|
||||
self._print_reminder(reminders)
|
||||
|
||||
def on_tool_reminder(self, reminders: list[dict[str, str]], tool_call_id: str) -> None:
|
||||
# tool_call_id ignored — the CLI anchors by output sequence
|
||||
# (the line lands directly after the tool result that
|
||||
# triggered the batch's reminder).
|
||||
self._print_reminder(reminders)
|
||||
|
||||
def on_state_change(self, state: str) -> None:
|
||||
pass # base TerminalUI ignores state changes
|
||||
|
||||
@@ -1219,7 +1242,7 @@ def main() -> None:
|
||||
)
|
||||
sys.stderr.flush()
|
||||
|
||||
manager._on_state_change = _bg_attention_notify
|
||||
manager.subscribe_to_state(_bg_attention_notify)
|
||||
|
||||
# Print banner
|
||||
print(f"\n{bold('Chat')} with {cyan(model)}")
|
||||
|
||||
@@ -634,6 +634,60 @@ class ClusterCollector:
|
||||
ws["name"] = name
|
||||
pending_events.append({"type": "ws_rename", "ws_id": ws_id, "name": name})
|
||||
|
||||
elif etype == "intent_verdict":
|
||||
# Pure pass-through for LLM judge verdicts. The node's
|
||||
# WebUI._broadcast_intent_verdict pushes onto the
|
||||
# global queue (Stage 3 Step 5); the cluster fan-out
|
||||
# forwards to subscribers (e.g. CoordinatorAdapter,
|
||||
# which dispatches to the parent's coord SSE as
|
||||
# ``child_ws_intent_verdict``).
|
||||
ws_id = data.get("ws_id", "")
|
||||
if ws_id:
|
||||
pending_events.append(
|
||||
{
|
||||
"type": "intent_verdict",
|
||||
"ws_id": ws_id,
|
||||
"node_id": node_id,
|
||||
"verdict": data.get("verdict") or {},
|
||||
}
|
||||
)
|
||||
|
||||
elif etype == "approval_resolved":
|
||||
# Pure pass-through for approve/deny resolutions. Pairs
|
||||
# with ``intent_verdict`` above so the parent
|
||||
# coordinator's tree UI can clear the pending-approval
|
||||
# pill the moment the user decides, rather than waiting
|
||||
# for the subsequent state-change piggyback.
|
||||
ws_id = data.get("ws_id", "")
|
||||
if ws_id:
|
||||
pending_events.append(
|
||||
{
|
||||
"type": "approval_resolved",
|
||||
"ws_id": ws_id,
|
||||
"node_id": node_id,
|
||||
"approved": bool(data.get("approved", False)),
|
||||
"feedback": data.get("feedback", "") or "",
|
||||
"always": bool(data.get("always", False)),
|
||||
}
|
||||
)
|
||||
|
||||
elif etype == "approve_request":
|
||||
# Push path for the initial approval items. Eliminates
|
||||
# the bulk-fetch race that otherwise leaves the coord
|
||||
# tree stuck on a loading placeholder when the bulk
|
||||
# fetch lands in the gap between the state transition
|
||||
# to ATTENTION and ``_pending_approval`` being set.
|
||||
ws_id = data.get("ws_id", "")
|
||||
if ws_id:
|
||||
pending_events.append(
|
||||
{
|
||||
"type": "approve_request",
|
||||
"ws_id": ws_id,
|
||||
"node_id": node_id,
|
||||
"detail": data.get("detail") or {},
|
||||
}
|
||||
)
|
||||
|
||||
elif etype == "health_changed":
|
||||
# Update the health dict's backend status in-place
|
||||
bstatus = data.get("backend_status", "")
|
||||
@@ -1203,3 +1257,81 @@ class ClusterCollector:
|
||||
return
|
||||
entry["name"] = name
|
||||
self._fanout({"type": "ws_rename", "ws_id": ws_id, "name": name})
|
||||
|
||||
def emit_models_changed(self) -> None:
|
||||
"""Fan out a ``models_changed`` notice to all SSE listeners.
|
||||
|
||||
Browsers re-fetch :http:get:`/v1/api/models` on receipt so the
|
||||
coordinator-composer model dropdown + the admin Models tab
|
||||
reflect alias / underlying-model edits without a manual reload.
|
||||
Body is intentionally empty — listeners refetch authoritative
|
||||
state rather than diffing the event payload.
|
||||
"""
|
||||
self._fanout({"type": "models_changed"})
|
||||
|
||||
def emit_console_ws_intent_verdict(self, ws_id: str, verdict: dict[str, Any]) -> None:
|
||||
"""Fan an LLM intent-judge verdict for a console-pseudo-node ws.
|
||||
|
||||
Gives coord-spawned approval flows a first-class cluster-bus
|
||||
event so the parent's tree UI can render the risk pill +
|
||||
verdict result without polling.
|
||||
``CoordinatorAdapter._dispatch_child_event`` re-emits these as
|
||||
``child_ws_intent_verdict`` for the parent coordinator's SSE
|
||||
stream. The dispatch path filters by registry membership;
|
||||
downstream subscribers tolerate verdicts for ws_ids they don't
|
||||
own (silently drop), so we skip the membership pre-check that
|
||||
would otherwise add a lock acquisition per emit on a path
|
||||
that fires once per tool-call during heuristic+LLM judging.
|
||||
"""
|
||||
self._fanout(
|
||||
{
|
||||
"type": "intent_verdict",
|
||||
"ws_id": ws_id,
|
||||
"node_id": self.CONSOLE_PSEUDO_NODE_ID,
|
||||
"verdict": verdict,
|
||||
}
|
||||
)
|
||||
|
||||
def emit_console_ws_approval_resolved(
|
||||
self,
|
||||
ws_id: str,
|
||||
*,
|
||||
approved: bool,
|
||||
feedback: str = "",
|
||||
always: bool = False,
|
||||
) -> None:
|
||||
"""Fan an ``approval_resolved`` decision for a console-pseudo-node ws.
|
||||
|
||||
Paired with :meth:`emit_console_ws_intent_verdict` so the
|
||||
coord tree UI clears the pending-approval pill in lockstep
|
||||
with the actual decision. Same lock-skip rationale as the
|
||||
intent-verdict emit above.
|
||||
"""
|
||||
self._fanout(
|
||||
{
|
||||
"type": "approval_resolved",
|
||||
"ws_id": ws_id,
|
||||
"node_id": self.CONSOLE_PSEUDO_NODE_ID,
|
||||
"approved": approved,
|
||||
"feedback": feedback,
|
||||
"always": always,
|
||||
}
|
||||
)
|
||||
|
||||
def emit_console_ws_approve_request(self, ws_id: str, detail: dict[str, Any]) -> None:
|
||||
"""Fan an ``approve_request`` payload for a console-pseudo-node ws.
|
||||
|
||||
Push path for the initial approval items so a coord parent's
|
||||
tree UI can render the inline approve/deny block immediately
|
||||
without a bulk-fetch round-trip. ``CoordinatorAdapter._dispatch_child_event``
|
||||
re-emits as ``child_ws_approve_request`` for the parent
|
||||
coordinator's SSE stream.
|
||||
"""
|
||||
self._fanout(
|
||||
{
|
||||
"type": "approve_request",
|
||||
"ws_id": ws_id,
|
||||
"node_id": self.CONSOLE_PSEUDO_NODE_ID,
|
||||
"detail": detail,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -15,17 +15,18 @@ for the storage-seeded children rebuild.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import queue
|
||||
import threading
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from turnstone.core import session_worker
|
||||
from turnstone.core.adapters._ui_cleanup import cleanup_session_ui
|
||||
from turnstone.core.child_source import ClusterChildSource
|
||||
from turnstone.core.children_registry import ChildrenRegistry
|
||||
from turnstone.core.log import get_logger
|
||||
from turnstone.core.session import AttachmentsNotQueueableError
|
||||
from turnstone.core.workstream import Workstream, WorkstreamKind, WorkstreamState
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable, Iterable
|
||||
from collections.abc import Callable
|
||||
|
||||
from turnstone.console.collector import ClusterCollector
|
||||
from turnstone.console.coordinator_ui import ConsoleCoordinatorUI
|
||||
@@ -56,38 +57,25 @@ class CoordinatorAdapter:
|
||||
# the manager to the adapter's ``__init__`` — break the cycle
|
||||
# with a setter called from console startup.
|
||||
self._manager: SessionManager | None = None
|
||||
# Per-coordinator known-child ws_id set. Populated lazily on
|
||||
# create/open from storage and updated live as the cluster fan-out
|
||||
# thread sees ws_created events with matching parent_ws_id.
|
||||
# Closed / deleted children stay in the registry so the tree UI
|
||||
# can keep rendering them grayed out; the authoritative render
|
||||
# path reads storage for state. Bounded by eventual coordinator
|
||||
# close/eviction.
|
||||
self._children: dict[str, set[str]] = {}
|
||||
# Reverse index for O(1) child → coord lookup on every cluster
|
||||
# event. Without this, every cluster event incurs a linear scan
|
||||
# over every coordinator's child set while holding the fan-out
|
||||
# lock — a hot-path tax that scales with both active coordinators
|
||||
# and their retained-history depth.
|
||||
self._child_to_coord: dict[str, str] = {}
|
||||
self._children_lock = threading.Lock()
|
||||
# Cluster-event fan-out: subscribes to the ClusterCollector's
|
||||
# listener channel, filters by known child ws_ids, and re-emits
|
||||
# child_ws_* events on the matching coordinator's UI. Configured
|
||||
# lazily via ``start_child_event_fanout(collector)`` from the
|
||||
# console lifespan once both the manager and collector exist.
|
||||
self._collector_queue: queue.Queue[dict[str, Any]] | None = None
|
||||
self._fanout_thread: threading.Thread | None = None
|
||||
self._fanout_stop = threading.Event()
|
||||
# Coord-ws-id → UI map for the fan-out dispatch path. Read
|
||||
# and written under ``self._children_lock`` alongside the
|
||||
# forward/reverse child maps. (Previously the value was
|
||||
# ``(user_id, ui)`` with a copy-on-write dict swap so the
|
||||
# dispatch could read it lock-free — but _dispatch_child_event
|
||||
# already re-validates the parent under _children_lock anyway,
|
||||
# so the lock-free snapshot was premature. The user_id half is
|
||||
# also dead after a46dab1 dropped row-level ownership gates.)
|
||||
self._active_coords: dict[str, Any] = {}
|
||||
# Children registry — universal parent → children + reverse
|
||||
# lookup primitive. Lifted from inline data on this adapter to
|
||||
# ``turnstone.core.children_registry.ChildrenRegistry`` (Stage 3
|
||||
# Step 1) so the same primitive can serve interactive
|
||||
# workstreams when they gain spawn capability. The dispatch
|
||||
# path (`_dispatch_child_event`) calls into the registry
|
||||
# atomically; everything else delegates through the legacy
|
||||
# method names which still exist as thin shims for the
|
||||
# cluster-routing + cleanup callers.
|
||||
self._registry = ChildrenRegistry()
|
||||
# Cross-node child events arrive via ``ClusterChildSource``
|
||||
# (Stage 3 Step 2): a strategy that subscribes to the
|
||||
# collector's listener channel and runs a daemon thread that
|
||||
# drains the queue, pushing each event to the sink. The sink
|
||||
# is :meth:`_dispatch_child_event` so the existing translation
|
||||
# logic (cluster_state → child_ws_state, etc.) stays in one
|
||||
# place. Constructed lazily by ``start_child_event_fanout`` so
|
||||
# the collector reference is available.
|
||||
self._child_source: ClusterChildSource | None = None
|
||||
|
||||
def attach(self, manager: SessionManager) -> None:
|
||||
"""Late-bind the owning :class:`SessionManager`.
|
||||
@@ -109,7 +97,7 @@ class CoordinatorAdapter:
|
||||
# needs the empty forward/presence entries so
|
||||
# ``_dispatch_child_event`` recognises this coordinator when
|
||||
# its first child is spawned.
|
||||
self._install_coord_registry(ws)
|
||||
self._registry.install(ws.id, ws.ui)
|
||||
self._fanout_console_ws_created(ws)
|
||||
|
||||
def emit_rehydrated(self, ws: Workstream) -> None:
|
||||
@@ -117,21 +105,10 @@ class CoordinatorAdapter:
|
||||
# it from storage after the registry seed so a ``ws_created``
|
||||
# for an already-spawned child that fires mid-rebuild merges
|
||||
# cleanly.
|
||||
self._install_coord_registry(ws)
|
||||
self._registry.install(ws.id, ws.ui)
|
||||
self._rebuild_children_registry(ws.id)
|
||||
self._fanout_console_ws_created(ws)
|
||||
|
||||
def _install_coord_registry(self, ws: Workstream) -> None:
|
||||
"""Seed the children registry + presence map for ``ws``.
|
||||
|
||||
Shared by ``emit_created`` and ``emit_rehydrated`` — the
|
||||
difference between the two is purely whether we then rebuild
|
||||
from storage.
|
||||
"""
|
||||
with self._children_lock:
|
||||
self._children.setdefault(ws.id, set())
|
||||
self._active_coords[ws.id] = ws.ui
|
||||
|
||||
def _fanout_console_ws_created(self, ws: Workstream) -> None:
|
||||
try:
|
||||
self._collector.emit_console_ws_created(
|
||||
@@ -197,14 +174,11 @@ class CoordinatorAdapter:
|
||||
# toast only fire for real-node (interactive) ws_closed events.
|
||||
del reason, name
|
||||
# Drop the coordinator's children-registry entries AND its
|
||||
# presence slot. Mirrors the eviction/close paths from the old
|
||||
# CoordinatorManager (which did the same under _children_lock
|
||||
# + _lock respectively). A plain _children.pop without clearing
|
||||
# the reverse index would leak every evicted coordinator's
|
||||
# child→parent pointers forever.
|
||||
with self._children_lock:
|
||||
self._pop_coord_registry_locked(ws_id)
|
||||
self._active_coords.pop(ws_id, None)
|
||||
# presence slot. A plain pop without clearing the reverse
|
||||
# index would leak every evicted coordinator's child→parent
|
||||
# pointers forever — :meth:`ChildrenRegistry.uninstall`
|
||||
# handles forward set + reverse entries + presence atomically.
|
||||
self._registry.uninstall(ws_id)
|
||||
try:
|
||||
self._collector.emit_console_ws_closed(ws_id)
|
||||
except Exception:
|
||||
@@ -337,13 +311,34 @@ class CoordinatorAdapter:
|
||||
# logging) above.
|
||||
|
||||
def _enqueue() -> None:
|
||||
# ``queue_message`` takes attachment *ids* + ``queue_msg_id``
|
||||
# (which doubles as the cross-table reservation token); the
|
||||
# send_id we hold IS that token. Convert Attachment objects
|
||||
# to id list at enqueue time so the queued turn picks the
|
||||
# files up at dequeue.
|
||||
# Queued user turns can't carry attachments (see
|
||||
# ``AttachmentsNotQueueableError``). The route handler's
|
||||
# _enqueue catches the rejection and surfaces an
|
||||
# ``attachments_busy`` status to the caller; the coord
|
||||
# adapter's caller has no equivalent return channel, so
|
||||
# we mirror the cleanup (release the reservation taken
|
||||
# for ``_send_id``) and let session_worker.send return
|
||||
# False — the only call site today
|
||||
# (``_coord_create_post_install``) hits the spawn branch
|
||||
# on a fresh workstream so the catch is defense-in-depth.
|
||||
att_ids = [a.attachment_id for a in _attachments] if _attachments else None
|
||||
session.queue_message(message, attachment_ids=att_ids, queue_msg_id=_send_id)
|
||||
try:
|
||||
session.queue_message(message, attachment_ids=att_ids, queue_msg_id=_send_id)
|
||||
except AttachmentsNotQueueableError:
|
||||
if _attachments and _send_id:
|
||||
from turnstone.core.memory import (
|
||||
unreserve_attachments as _unreserve,
|
||||
)
|
||||
|
||||
try:
|
||||
_unreserve(_send_id, ws_ref.id, _user_id)
|
||||
except Exception:
|
||||
log.debug(
|
||||
"coord_adapter.attachment_unreserve_failed ws=%s",
|
||||
ws_ref.id[:8],
|
||||
exc_info=True,
|
||||
)
|
||||
raise
|
||||
|
||||
return session_worker.send(
|
||||
ws,
|
||||
@@ -356,25 +351,6 @@ class CoordinatorAdapter:
|
||||
# Children registry
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _merge_child_ids_locked(self, coord_ws_id: str, child_ids: Iterable[str]) -> None:
|
||||
"""Merge ``child_ids`` into ``coord_ws_id``'s forward + reverse maps.
|
||||
|
||||
Caller MUST hold ``self._children_lock``. Idempotent — re-adding
|
||||
an existing child is a no-op (the reverse-index pointer is
|
||||
already correct). Empty / falsy entries in ``child_ids`` are
|
||||
skipped.
|
||||
|
||||
Sole write-path for bulk registry updates so
|
||||
``_rebuild_children_registry`` (storage-seeded) and
|
||||
``_prime_children_from_snapshot`` (collector-seeded) agree on
|
||||
ordering and reverse-index invariants.
|
||||
"""
|
||||
existing = self._children.setdefault(coord_ws_id, set())
|
||||
for cid in child_ids:
|
||||
if cid and cid not in existing:
|
||||
existing.add(cid)
|
||||
self._child_to_coord[cid] = coord_ws_id
|
||||
|
||||
def _rebuild_children_registry(self, coord_ws_id: str) -> None:
|
||||
"""Populate ``self._children[coord_ws_id]`` from storage.
|
||||
|
||||
@@ -445,174 +421,88 @@ class CoordinatorAdapter:
|
||||
if not child_id:
|
||||
continue
|
||||
child_ids.append(child_id)
|
||||
with self._children_lock:
|
||||
self._merge_child_ids_locked(coord_ws_id, child_ids)
|
||||
|
||||
def _coord_for_child(self, child_ws_id: str) -> str | None:
|
||||
"""Reverse-lookup: which coordinator owns this child ws_id?
|
||||
|
||||
O(1) via the ``_child_to_coord`` reverse index. Cluster events
|
||||
fire on every token tick across the cluster; a linear scan here
|
||||
turned into a hot-path tax as the retained-history set grew.
|
||||
"""
|
||||
with self._children_lock:
|
||||
return self._child_to_coord.get(child_ws_id)
|
||||
self._registry.merge_children(coord_ws_id, child_ids)
|
||||
|
||||
def children_snapshot(self, coord_ws_id: str) -> list[str]:
|
||||
"""Return a snapshot of the coordinator's direct child ws_ids.
|
||||
|
||||
Used by ``stop_cascade`` to iterate children without holding the
|
||||
registry lock during the per-child HTTP dispatch. A mutation
|
||||
racing with the snapshot (child spawned mid-cascade) either
|
||||
lands before the snapshot and gets cancelled, or lands after
|
||||
and is out of scope for this batch — both outcomes are safe.
|
||||
Returns an empty list for unknown coordinators.
|
||||
Used by ``stop_cascade`` to iterate children without holding
|
||||
the registry lock during the per-child HTTP dispatch. A
|
||||
mutation racing with the snapshot (child spawned mid-cascade)
|
||||
either lands before (cancelled) or after (out of scope for
|
||||
this batch) — both safe. Returns an empty list for unknown
|
||||
coordinators.
|
||||
"""
|
||||
with self._children_lock:
|
||||
child_set = self._children.get(coord_ws_id)
|
||||
return list(child_set) if child_set else []
|
||||
|
||||
def _pop_coord_registry_locked(self, coord_ws_id: str) -> None:
|
||||
"""Remove a coordinator's forward set + reverse-index entries.
|
||||
|
||||
Caller MUST hold ``self._children_lock``. Used by close /
|
||||
eviction paths so stale coordinators don't leak registry
|
||||
entries. No-op if the coordinator is unknown.
|
||||
"""
|
||||
child_set = self._children.pop(coord_ws_id, None)
|
||||
if child_set is None:
|
||||
return
|
||||
for cid in child_set:
|
||||
# Defensive: only clear the reverse entry if it still points
|
||||
# at THIS coordinator. If a child has since been reassigned
|
||||
# (unusual but possible on schema changes), we don't want to
|
||||
# orphan the new owner's entry.
|
||||
if self._child_to_coord.get(cid) == coord_ws_id:
|
||||
self._child_to_coord.pop(cid, None)
|
||||
return self._registry.children_of(coord_ws_id)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Cluster-event fan-out thread
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def start_child_event_fanout(self, collector: ClusterCollector) -> None:
|
||||
"""Subscribe to cluster events and start the filter + re-emit thread.
|
||||
"""Subscribe to cluster events via :class:`ClusterChildSource`.
|
||||
|
||||
Idempotent — calling twice is a no-op (already-started fan-out
|
||||
thread stays). Called once from the console lifespan after both
|
||||
the collector and the session manager are constructed.
|
||||
Idempotent — already-started ChildSource stays. Called once
|
||||
from the console lifespan after both the collector and the
|
||||
session manager are constructed.
|
||||
"""
|
||||
if self._fanout_thread is not None and self._fanout_thread.is_alive():
|
||||
if self._child_source is not None:
|
||||
return
|
||||
self._collector = collector
|
||||
self._collector_queue = queue.Queue(maxsize=1000)
|
||||
# Ensure the "console" pseudo-node exists in the snapshot map so
|
||||
# emit_console_ws_* calls from create / close / open land on a
|
||||
# real node entry the snapshot will surface.
|
||||
# Coord-specific transport setup: the "console" pseudo-node
|
||||
# must exist in the snapshot map BEFORE any
|
||||
# ``emit_console_ws_*`` calls (and before the snapshot is
|
||||
# taken inside ``ChildSource.start``) so those emits land on
|
||||
# a real node entry the snapshot surfaces.
|
||||
collector.ensure_console_pseudo_node()
|
||||
# Register with the collector — use the existing listener channel
|
||||
# the browser SSE fan-out uses; the collector treats our queue as
|
||||
# just another subscriber.
|
||||
snapshot = collector.get_snapshot_and_register(self._collector_queue)
|
||||
# Prime the child registry from the snapshot so a coordinator
|
||||
# that opens right after a console restart sees already-live
|
||||
# children without waiting for the next ``ws_state`` tick to
|
||||
# discover them via the fan-out path.
|
||||
self._prime_children_from_snapshot(snapshot)
|
||||
# Seed the pseudo-node with any coordinators already loaded in
|
||||
# memory when the collector binds. Prevents a race where early
|
||||
# creates happened before the collector was wired up and their
|
||||
# rows never showed on the snapshot.
|
||||
mgr = self._manager
|
||||
if mgr is not None:
|
||||
for ws in mgr.list_all():
|
||||
try:
|
||||
collector.emit_console_ws_created(
|
||||
ws.id,
|
||||
name=ws.name,
|
||||
user_id=ws.user_id or "",
|
||||
kind=WorkstreamKind.COORDINATOR.value,
|
||||
state=ws.state.value,
|
||||
parent_ws_id=None,
|
||||
)
|
||||
except Exception:
|
||||
log.debug(
|
||||
"coord_adapter.collector_seed_failed ws=%s",
|
||||
ws.id[:8],
|
||||
exc_info=True,
|
||||
)
|
||||
self._fanout_stop.clear()
|
||||
t = threading.Thread(
|
||||
target=self._fanout_loop,
|
||||
name="coord-adapter-child-fanout",
|
||||
daemon=True,
|
||||
)
|
||||
self._fanout_thread = t
|
||||
t.start()
|
||||
|
||||
def _prime_children_from_snapshot(self, snapshot: dict[str, Any]) -> None:
|
||||
"""Populate ``_children`` + ``_child_to_coord`` from a collector snapshot.
|
||||
|
||||
The snapshot's per-node workstreams carry ``parent_ws_id``. For
|
||||
every workstream whose parent is an in-memory coordinator,
|
||||
record the child so the fan-out filter sees it immediately.
|
||||
"""
|
||||
nodes = snapshot.get("nodes", []) if isinstance(snapshot, dict) else []
|
||||
if not nodes:
|
||||
return
|
||||
mgr = self._manager
|
||||
if mgr is None:
|
||||
raise RuntimeError(
|
||||
"CoordinatorAdapter: manager not attached — call attach(mgr) after construction"
|
||||
)
|
||||
by_parent: dict[str, list[str]] = {}
|
||||
known = {ws.id for ws in mgr.list_all()}
|
||||
for node in nodes:
|
||||
for entry in node.get("workstreams", []) or []:
|
||||
parent = entry.get("parent_ws_id") or ""
|
||||
child_id = entry.get("id") or ""
|
||||
if not parent or not child_id or parent not in known:
|
||||
continue
|
||||
by_parent.setdefault(parent, []).append(child_id)
|
||||
if not by_parent:
|
||||
return
|
||||
with self._children_lock:
|
||||
for parent, kids in by_parent.items():
|
||||
self._merge_child_ids_locked(parent, kids)
|
||||
# Build + start the strategy. The sink is
|
||||
# :meth:`_dispatch_child_event` so cluster events flow through
|
||||
# the same translation path that synthesises ``child_ws_*``
|
||||
# payloads for the parent's UI.
|
||||
source = ClusterChildSource(
|
||||
collector=collector,
|
||||
registry=self._registry,
|
||||
parents_provider=lambda: [ws.id for ws in mgr.list_all()],
|
||||
)
|
||||
source.start(sink=self._dispatch_child_event)
|
||||
self._child_source = source
|
||||
# Seed the pseudo-node with any coordinators already loaded in
|
||||
# memory when the collector binds. Prevents a race where early
|
||||
# creates happened before the collector was wired up and their
|
||||
# rows never showed on the snapshot. (Coord-specific — interactive
|
||||
# has no analogous pseudo-node.)
|
||||
for ws in mgr.list_all():
|
||||
try:
|
||||
collector.emit_console_ws_created(
|
||||
ws.id,
|
||||
name=ws.name,
|
||||
user_id=ws.user_id or "",
|
||||
kind=WorkstreamKind.COORDINATOR.value,
|
||||
state=ws.state.value,
|
||||
parent_ws_id=None,
|
||||
)
|
||||
except Exception:
|
||||
log.debug(
|
||||
"coord_adapter.collector_seed_failed ws=%s",
|
||||
ws.id[:8],
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
def shutdown(self) -> None:
|
||||
"""Stop the fan-out thread and unregister from the collector.
|
||||
"""Stop the ChildSource and unregister from the collector.
|
||||
|
||||
Safe to call multiple times; idempotent. Invoked from the
|
||||
console lifespan teardown so SSE listener queues don't leak.
|
||||
"""
|
||||
self._fanout_stop.set()
|
||||
t = self._fanout_thread
|
||||
q = self._collector_queue
|
||||
coll = self._collector
|
||||
self._fanout_thread = None
|
||||
self._collector_queue = None
|
||||
if coll is not None and q is not None:
|
||||
try:
|
||||
coll.unregister_listener(q)
|
||||
except Exception:
|
||||
log.debug("coord_adapter.unregister_listener_failed", exc_info=True)
|
||||
if t is not None:
|
||||
t.join(timeout=2.0)
|
||||
|
||||
def _fanout_loop(self) -> None:
|
||||
"""Drain collector events, filter by known children, dispatch."""
|
||||
q = self._collector_queue
|
||||
if q is None:
|
||||
return
|
||||
while not self._fanout_stop.is_set():
|
||||
try:
|
||||
event = q.get(timeout=1.0)
|
||||
except queue.Empty:
|
||||
continue
|
||||
try:
|
||||
self._dispatch_child_event(event)
|
||||
except Exception:
|
||||
log.debug("coord_adapter.fanout.dispatch_failed", exc_info=True)
|
||||
src = self._child_source
|
||||
self._child_source = None
|
||||
if src is not None:
|
||||
src.shutdown()
|
||||
|
||||
def _dispatch_child_event(self, event: dict[str, Any]) -> None:
|
||||
"""Match a cluster event to a coordinator and re-emit on its UI.
|
||||
@@ -622,10 +512,12 @@ class CoordinatorAdapter:
|
||||
- ``ws_created`` with ``parent_ws_id`` matching an in-memory
|
||||
coordinator → add to registry + re-emit as
|
||||
``child_ws_created``.
|
||||
- ``cluster_state`` / ``ws_closed`` / ``ws_rename`` whose
|
||||
``ws_id`` is in any coordinator's known-children registry →
|
||||
re-emit as ``child_ws_state`` / ``child_ws_closed`` /
|
||||
``child_ws_rename``.
|
||||
- ``cluster_state`` / ``ws_closed`` / ``ws_rename`` /
|
||||
``intent_verdict`` / ``approval_resolved`` whose ``ws_id``
|
||||
is in any coordinator's known-children registry → re-emit as
|
||||
``child_ws_state`` / ``child_ws_closed`` /
|
||||
``child_ws_rename`` / ``child_ws_intent_verdict`` /
|
||||
``child_ws_approval_resolved``.
|
||||
|
||||
Events for ws_ids we don't own silently drop — the filter lives
|
||||
on the server so each coordinator's SSE stream stays small.
|
||||
@@ -639,23 +531,17 @@ class CoordinatorAdapter:
|
||||
parent = event.get("parent_ws_id") or ""
|
||||
if not parent:
|
||||
return
|
||||
# Presence check + registry mutation under the same lock:
|
||||
# a concurrent close()/eviction can pop the entry between
|
||||
# the check and the mutation, after which a bare setdefault
|
||||
# would resurrect the entry — leaking the registry key and
|
||||
# enqueuing onto the closed coordinator's UI. Trusted-team
|
||||
# posture (#400 / a46dab1) means no per-event tenant gate
|
||||
# here; scope-level auth at the SSE endpoint is the only
|
||||
# boundary.
|
||||
with self._children_lock:
|
||||
coord_ui = self._active_coords.get(parent)
|
||||
if coord_ui is None:
|
||||
return
|
||||
existing = self._children.setdefault(parent, set())
|
||||
if ws_id in existing:
|
||||
return
|
||||
existing.add(ws_id)
|
||||
self._child_to_coord[ws_id] = parent
|
||||
# Atomic check-and-route under the registry's lock: a
|
||||
# concurrent close()/eviction can pop the parent's entry
|
||||
# between the presence check and the mutation, so the two
|
||||
# must happen together. ``add_child`` returns the parent's
|
||||
# UI on success or None on (a) parent not installed, or
|
||||
# (b) duplicate child. Trusted-team posture (#400 / a46dab1)
|
||||
# means no per-event tenant gate here; scope-level auth at
|
||||
# the SSE endpoint is the only boundary.
|
||||
coord_ui = self._registry.add_child(parent, ws_id)
|
||||
if coord_ui is None:
|
||||
return
|
||||
payload = {
|
||||
"type": "child_ws_created",
|
||||
"ws_id": ws_id,
|
||||
@@ -668,8 +554,15 @@ class CoordinatorAdapter:
|
||||
_enqueue_on_ui(coord_ui, parent, payload)
|
||||
return
|
||||
|
||||
if etype in ("cluster_state", "ws_closed", "ws_rename"):
|
||||
coord_id = self._coord_for_child(ws_id)
|
||||
if etype in (
|
||||
"cluster_state",
|
||||
"ws_closed",
|
||||
"ws_rename",
|
||||
"intent_verdict",
|
||||
"approval_resolved",
|
||||
"approve_request",
|
||||
):
|
||||
coord_id = self._registry.parent_for(ws_id)
|
||||
if coord_id is None:
|
||||
return
|
||||
mgr = self._manager
|
||||
@@ -684,11 +577,12 @@ class CoordinatorAdapter:
|
||||
"state": event.get("state", ""),
|
||||
"tokens": event.get("tokens", 0),
|
||||
"node_id": event.get("node_id", ""),
|
||||
# activity_state lets the JS detect approval-state
|
||||
# transitions and fire urgent live-bulk fetches so
|
||||
# inline approve/deny buttons render in lockstep
|
||||
# with the child entering attention (instead of
|
||||
# waiting up to 5s for the next TTL window).
|
||||
# ``activity_state`` lets the JS detect approval
|
||||
# transitions and trigger a bulk fetch for the
|
||||
# initial detail. The detail itself no longer
|
||||
# piggybacks here (Stage 3 cleanup) — verdicts
|
||||
# arrive via the explicit ``intent_verdict`` event
|
||||
# class and resolution via ``approval_resolved``.
|
||||
"activity_state": event.get("activity_state", ""),
|
||||
}
|
||||
elif etype == "ws_closed":
|
||||
@@ -698,13 +592,52 @@ class CoordinatorAdapter:
|
||||
"parent_ws_id": coord_id,
|
||||
"reason": event.get("reason", ""),
|
||||
}
|
||||
else: # ws_rename
|
||||
elif etype == "ws_rename":
|
||||
child_event = {
|
||||
"type": "child_ws_rename",
|
||||
"child_ws_id": ws_id,
|
||||
"parent_ws_id": coord_id,
|
||||
"name": event.get("name", ""),
|
||||
}
|
||||
elif etype == "intent_verdict":
|
||||
# Per-coord re-emit of an explicit verdict event so
|
||||
# the tree UI can render the risk pill + verdict
|
||||
# result without polling. The corresponding
|
||||
# ``cluster_state`` event no longer carries the
|
||||
# detail (the piggyback was removed end-to-end);
|
||||
# the bulk fetch on initial approval entry plus this
|
||||
# explicit event class are the only carriers.
|
||||
child_event = {
|
||||
"type": "child_ws_intent_verdict",
|
||||
"child_ws_id": ws_id,
|
||||
"parent_ws_id": coord_id,
|
||||
"node_id": event.get("node_id", ""),
|
||||
"verdict": event.get("verdict") or {},
|
||||
}
|
||||
elif etype == "approval_resolved":
|
||||
child_event = {
|
||||
"type": "child_ws_approval_resolved",
|
||||
"child_ws_id": ws_id,
|
||||
"parent_ws_id": coord_id,
|
||||
"node_id": event.get("node_id", ""),
|
||||
"approved": bool(event.get("approved", False)),
|
||||
"feedback": event.get("feedback", "") or "",
|
||||
"always": bool(event.get("always", False)),
|
||||
}
|
||||
else: # approve_request
|
||||
# Push path for the initial approval items —
|
||||
# eliminates the bulk-fetch race that previously left
|
||||
# the coord row stuck on a loading placeholder when
|
||||
# the bulk fetch landed in the gap between the state
|
||||
# transition to ATTENTION and ``_pending_approval``
|
||||
# being set inside ``approve_tools``.
|
||||
child_event = {
|
||||
"type": "child_ws_approve_request",
|
||||
"child_ws_id": ws_id,
|
||||
"parent_ws_id": coord_id,
|
||||
"node_id": event.get("node_id", ""),
|
||||
"detail": event.get("detail") or {},
|
||||
}
|
||||
_enqueue_on_ui(owning_ws.ui, coord_id, child_event)
|
||||
|
||||
|
||||
|
||||
@@ -81,13 +81,13 @@ WAIT_MAX_TIMEOUT: float = 600.0
|
||||
WAIT_POLL_INTERVAL: float = 0.5
|
||||
|
||||
# Per-ws cap on the inline ``message`` field bundled into wait_for_workstream
|
||||
# results. Sized so a fan-out of 32 children at the cap is ~192 KiB of
|
||||
# results. Sized so a fan-out of 32 children at the cap is ~320 KiB of
|
||||
# tool output — large but not catastrophic on commercial models, and
|
||||
# typical waits run with a handful of children. Truncation is from the
|
||||
# END (the lead is usually more informative than the tail) and sets a
|
||||
# ``truncated=True`` flag so the model can opt into a follow-up read if
|
||||
# the trailing bytes matter.
|
||||
WAIT_MESSAGE_MAX_BYTES: int = 6 * 1024
|
||||
WAIT_MESSAGE_MAX_BYTES: int = 10 * 1024
|
||||
|
||||
# How many tail messages ``wait_for_workstream`` reads when extracting a
|
||||
# child's last assistant turn. The conversation tail almost always
|
||||
@@ -1081,7 +1081,38 @@ class CoordinatorClient:
|
||||
"value": decoded,
|
||||
"source": str(r.get("source", "")),
|
||||
}
|
||||
nodes.append({"node_id": nid, "metadata": meta})
|
||||
# Project ``metadata.models`` (a list of
|
||||
# ``{alias, provider, healthy}`` written by the node's
|
||||
# heartbeat loop — see ``_collect_node_models_metadata``
|
||||
# in ``turnstone/server.py``) down to the healthy-alias
|
||||
# shortlist the coordinator passes back as ``model=`` to
|
||||
# ``spawn_workstream`` / ``spawn_batch``. The top-level
|
||||
# field is named ``model_aliases`` (not ``models``) so it
|
||||
# doesn't collide with ``metadata.models`` — the two
|
||||
# carry different shapes (list of strings vs list of
|
||||
# dicts) and a coord that conflates them gets a runtime
|
||||
# error. Empty list when the node hasn't published a
|
||||
# models entry yet — older nodes without the heartbeat-
|
||||
# side projection, or a brand new node mid-startup before
|
||||
# the first metadata write.
|
||||
models_entry = meta.get("models", {}).get("value")
|
||||
healthy_aliases: list[str] = []
|
||||
if isinstance(models_entry, list):
|
||||
for row in models_entry:
|
||||
if not isinstance(row, dict):
|
||||
continue
|
||||
if not row.get("healthy", False):
|
||||
continue
|
||||
alias = row.get("alias")
|
||||
if isinstance(alias, str) and alias:
|
||||
healthy_aliases.append(alias)
|
||||
nodes.append(
|
||||
{
|
||||
"node_id": nid,
|
||||
"metadata": meta,
|
||||
"model_aliases": healthy_aliases,
|
||||
}
|
||||
)
|
||||
return {"nodes": nodes, "truncated": truncated}
|
||||
|
||||
def list_skills(
|
||||
|
||||
@@ -181,6 +181,81 @@ class ConsoleCoordinatorUI(SessionUIBase):
|
||||
with self._ws_lock:
|
||||
self._last_broadcast_activity = current
|
||||
|
||||
def _broadcast_intent_verdict(self, verdict: dict[str, Any]) -> None:
|
||||
"""Fan an LLM intent-judge verdict to the cluster collector.
|
||||
|
||||
Stage 3 Step 5 — overrides the no-op base hook so a coord
|
||||
workstream that produces its own verdict (rare — coord agents
|
||||
don't typically run the LLM judge) gets a first-class
|
||||
cluster-bus event for the parent's tree UI. The far more
|
||||
common path is a CHILD workstream firing its verdict on a
|
||||
real node; that path goes through ``WebUI._broadcast_intent_verdict``
|
||||
→ global SSE → collector ``_apply_delta``.
|
||||
"""
|
||||
collector = ConsoleCoordinatorUI._collector
|
||||
if collector is None:
|
||||
return
|
||||
try:
|
||||
collector.emit_console_ws_intent_verdict(self.ws_id, verdict)
|
||||
except Exception:
|
||||
log.debug(
|
||||
"coord_ui.intent_verdict_fanout_failed ws=%s",
|
||||
self.ws_id,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
def _broadcast_approval_resolved(
|
||||
self,
|
||||
approved: bool,
|
||||
feedback: str | None = None,
|
||||
*,
|
||||
always: bool = False,
|
||||
) -> None:
|
||||
"""Fan an ``approval_resolved`` decision to the cluster collector.
|
||||
|
||||
Stage 3 Step 5 — paired with :meth:`_broadcast_intent_verdict`.
|
||||
Same rationale: coord-direct approvals are rare; the typical
|
||||
path is a child workstream resolving on its node, with that
|
||||
node's WebUI broadcasting through the global queue.
|
||||
"""
|
||||
collector = ConsoleCoordinatorUI._collector
|
||||
if collector is None:
|
||||
return
|
||||
try:
|
||||
collector.emit_console_ws_approval_resolved(
|
||||
self.ws_id,
|
||||
approved=approved,
|
||||
feedback=feedback or "",
|
||||
always=always,
|
||||
)
|
||||
except Exception:
|
||||
log.debug(
|
||||
"coord_ui.approval_resolved_fanout_failed ws=%s",
|
||||
self.ws_id,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
def _broadcast_approve_request(self, detail: dict[str, Any]) -> None:
|
||||
"""Fan an ``approve_request`` payload to the cluster collector.
|
||||
|
||||
Push path for the initial approval items. Same rationale as
|
||||
the other two broadcast hooks: coord-self approvals are rare
|
||||
(the LLM judge isn't wired on the console coord today), but
|
||||
the override exists for symmetry and lights up the same path
|
||||
a future coord-self gate would use.
|
||||
"""
|
||||
collector = ConsoleCoordinatorUI._collector
|
||||
if collector is None:
|
||||
return
|
||||
try:
|
||||
collector.emit_console_ws_approve_request(self.ws_id, detail)
|
||||
except Exception:
|
||||
log.debug(
|
||||
"coord_ui.approve_request_fanout_failed ws=%s",
|
||||
self.ws_id,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
def on_state_change(self, state: str) -> None:
|
||||
# Flow state transitions through the unified SessionManager so
|
||||
# the storage write + adapter emit_state fan-out stay in lockstep
|
||||
|
||||
+720
-69
File diff suppressed because it is too large
Load Diff
@@ -2625,11 +2625,22 @@ function loadSettings() {
|
||||
schemaMap[schemaArr[i].key] = schemaArr[i];
|
||||
}
|
||||
|
||||
// Merge values + schema
|
||||
// Merge values + schema. Skip role-assignment settings owned by
|
||||
// the Models → Roles sub-tab (judge.* settings still live on the
|
||||
// Judge tab; the four model-tab roles render only there).
|
||||
var merged = {};
|
||||
var roleKeys = {
|
||||
"coordinator.model_alias": 1,
|
||||
"coordinator.reasoning_effort": 1,
|
||||
"model.plan_alias": 1,
|
||||
"model.plan_effort": 1,
|
||||
"model.task_alias": 1,
|
||||
"model.task_effort": 1,
|
||||
};
|
||||
for (var j = 0; j < valuesArr.length; j++) {
|
||||
var v = valuesArr[j];
|
||||
if (v.key.startsWith("judge.")) continue;
|
||||
if (roleKeys[v.key]) continue;
|
||||
var s = schemaMap[v.key] || {};
|
||||
merged[v.key] = {
|
||||
key: v.key,
|
||||
@@ -4440,7 +4451,69 @@ var _modelDefaultAlias = "";
|
||||
var _modelCreateTrap = null;
|
||||
var _modelCreateTrigger = null;
|
||||
|
||||
// Roles surfaced in the Models → Roles sub-tab. Each entry maps a
|
||||
// settings-registry key onto a UX label. ``effortKey`` is optional —
|
||||
// roles whose registry entry has a paired ``*.reasoning_effort``
|
||||
// setting render a second selector inline. Adding a new role (e.g.
|
||||
// ``perception.audio.model``) is purely additive: drop a row here once
|
||||
// the SettingDef lands in turnstone/core/settings_registry.py.
|
||||
var MODEL_ROLES = [
|
||||
{
|
||||
label: "Coordinator",
|
||||
description:
|
||||
"Console-hosted coordinator sessions that drive child workstreams.",
|
||||
aliasKey: "coordinator.model_alias",
|
||||
effortKey: "coordinator.reasoning_effort",
|
||||
},
|
||||
{
|
||||
label: "Judge",
|
||||
description:
|
||||
"Intent-validation judge that scores tool calls before approval.",
|
||||
aliasKey: "judge.model",
|
||||
},
|
||||
{
|
||||
label: "Plan agent",
|
||||
description:
|
||||
"plan_agent sub-agent — produces high-level plans before task dispatch.",
|
||||
aliasKey: "model.plan_alias",
|
||||
effortKey: "model.plan_effort",
|
||||
},
|
||||
{
|
||||
label: "Task agent",
|
||||
description:
|
||||
"task_agent sub-agent — runs autonomous subtasks dispatched by the parent.",
|
||||
aliasKey: "model.task_alias",
|
||||
effortKey: "model.task_effort",
|
||||
},
|
||||
];
|
||||
|
||||
// Roles sub-tab reads/writes via ``/v1/api/admin/settings`` which
|
||||
// requires ``admin.settings`` — different from the ``admin.models``
|
||||
// permission gating the Models tab itself. When the user has Models
|
||||
// access but not Settings, hide the sub-tab button + force the
|
||||
// Definitions panel visible so they don't see a perpetual 403 loader.
|
||||
function _modelRolesAccessible() {
|
||||
var perms = sessionStorage.getItem("turnstone_permissions") || "";
|
||||
return perms.split(",").indexOf("admin.settings") !== -1;
|
||||
}
|
||||
|
||||
function _applyModelRolesPermission() {
|
||||
var btn = document.getElementById("models-tab-roles");
|
||||
if (!btn) return;
|
||||
if (_modelRolesAccessible()) {
|
||||
btn.style.display = "";
|
||||
return;
|
||||
}
|
||||
btn.style.display = "none";
|
||||
// If Roles was the active sub-tab, snap back to Definitions so the
|
||||
// user isn't staring at a hidden panel.
|
||||
if (btn.classList.contains("active")) {
|
||||
switchModelsSection("models-list");
|
||||
}
|
||||
}
|
||||
|
||||
function loadAdminModels() {
|
||||
_applyModelRolesPermission();
|
||||
authFetch("/v1/api/admin/model-definitions")
|
||||
.then(function (r) {
|
||||
if (!r.ok) throw new Error("Failed");
|
||||
@@ -4450,6 +4523,12 @@ function loadAdminModels() {
|
||||
_modelDefs = data.models || [];
|
||||
_modelDefaultAlias = data.default_alias || "";
|
||||
_renderModels(_modelDefs);
|
||||
// Roles sub-tab piggybacks on the model list; skip it when the
|
||||
// user has no settings permission since the underlying API will
|
||||
// 403 anyway.
|
||||
if (_modelRolesAccessible()) {
|
||||
loadAdminModelRoles();
|
||||
}
|
||||
})
|
||||
.catch(function () {
|
||||
var el = document.getElementById("admin-models-table");
|
||||
@@ -4461,6 +4540,223 @@ function loadAdminModels() {
|
||||
});
|
||||
}
|
||||
|
||||
function switchModelsSection(section) {
|
||||
var sections = document.querySelectorAll("#admin-models .models-section");
|
||||
for (var i = 0; i < sections.length; i++) sections[i].style.display = "none";
|
||||
var switcher = document.querySelector("#admin-models .admin-subtab-switcher");
|
||||
var btns = switcher ? switcher.querySelectorAll(".admin-subtab-btn") : [];
|
||||
for (var k = 0; k < btns.length; k++) {
|
||||
var isActive = btns[k].getAttribute("data-section") === section;
|
||||
btns[k].classList.toggle("active", isActive);
|
||||
btns[k].setAttribute("aria-selected", isActive ? "true" : "false");
|
||||
btns[k].setAttribute("tabindex", isActive ? "0" : "-1");
|
||||
}
|
||||
var target = document.getElementById(section + "-section");
|
||||
if (target) target.style.display = "";
|
||||
}
|
||||
|
||||
// Arrow key navigation for Models sub-tabs (matches the Judge tab).
|
||||
(function () {
|
||||
var switcher = document.querySelector("#admin-models .admin-subtab-switcher");
|
||||
if (!switcher) return;
|
||||
switcher.addEventListener("keydown", function (e) {
|
||||
if (e.key !== "ArrowLeft" && e.key !== "ArrowRight") return;
|
||||
var btns = switcher.querySelectorAll(".admin-subtab-btn");
|
||||
var secs = [];
|
||||
for (var i = 0; i < btns.length; i++)
|
||||
secs.push(btns[i].getAttribute("data-section"));
|
||||
var current = switcher.querySelector(".admin-subtab-btn.active");
|
||||
var idx = secs.indexOf(current ? current.getAttribute("data-section") : "");
|
||||
if (e.key === "ArrowRight") idx = (idx + 1) % secs.length;
|
||||
else idx = (idx - 1 + secs.length) % secs.length;
|
||||
e.preventDefault();
|
||||
switchModelsSection(secs[idx]);
|
||||
btns[idx].focus();
|
||||
});
|
||||
})();
|
||||
|
||||
function _modelRolesError(container, msg) {
|
||||
while (container.firstChild) container.removeChild(container.firstChild);
|
||||
var d = document.createElement("div");
|
||||
d.className = "dashboard-empty";
|
||||
d.textContent = msg;
|
||||
container.appendChild(d);
|
||||
}
|
||||
|
||||
function loadAdminModelRoles() {
|
||||
var c = document.getElementById("admin-models-roles-container");
|
||||
if (!c) return;
|
||||
// Reads ``_modelDefs`` / ``_modelDefaultAlias`` populated by the most
|
||||
// recent ``loadAdminModels`` — both entry points into the Models tab
|
||||
// (initial open + ``models_changed`` SSE refresh) go through
|
||||
// ``loadAdminModels`` first, so the cached snapshot is fresh. Role
|
||||
// saves don't change model definitions, so the snapshot stays
|
||||
// accurate after ``_saveModelRole`` chains back here.
|
||||
Promise.all([
|
||||
authFetch("/v1/api/admin/settings").then(function (r) {
|
||||
if (!r.ok) throw new Error("settings " + r.status);
|
||||
return r.json();
|
||||
}),
|
||||
authFetch("/v1/api/admin/settings/schema").then(function (r) {
|
||||
if (!r.ok) throw new Error("schema " + r.status);
|
||||
return r.json();
|
||||
}),
|
||||
])
|
||||
.then(function (results) {
|
||||
var values = {};
|
||||
var arr = results[0].settings || [];
|
||||
for (var i = 0; i < arr.length; i++) values[arr[i].key] = arr[i];
|
||||
var schema = {};
|
||||
var sa = results[1].schema || [];
|
||||
for (var j = 0; j < sa.length; j++) schema[sa[j].key] = sa[j];
|
||||
_renderModelRoles(c, values, schema);
|
||||
})
|
||||
.catch(function () {
|
||||
_modelRolesError(c, "Failed to load roles");
|
||||
});
|
||||
}
|
||||
|
||||
function _renderModelRoles(container, values, schema) {
|
||||
var enabledAliases = [];
|
||||
for (var i = 0; i < _modelDefs.length; i++) {
|
||||
if (_modelDefs[i].enabled) enabledAliases.push(_modelDefs[i]);
|
||||
}
|
||||
container.textContent = "";
|
||||
for (var r = 0; r < MODEL_ROLES.length; r++) {
|
||||
var role = MODEL_ROLES[r];
|
||||
var aliasInfo = values[role.aliasKey];
|
||||
if (!aliasInfo) continue; // setting not registered (e.g. older server)
|
||||
|
||||
var row = document.createElement("div");
|
||||
row.className = "model-role-row";
|
||||
|
||||
// The dropdown's selected-option text is the single source of
|
||||
// truth for default vs override — when nothing is set it shows
|
||||
// "(default — <alias>)", otherwise it shows the chosen alias. No
|
||||
// separate badge: redundant with the select, and prone to
|
||||
// confusing color contrasts on freshly-rendered rows.
|
||||
var head = document.createElement("div");
|
||||
head.className = "model-role-head";
|
||||
var nameEl = document.createElement("span");
|
||||
nameEl.className = "model-role-label";
|
||||
nameEl.textContent = role.label;
|
||||
head.appendChild(nameEl);
|
||||
row.appendChild(head);
|
||||
|
||||
if (role.description) {
|
||||
var desc = document.createElement("div");
|
||||
desc.className = "model-role-desc";
|
||||
desc.textContent = role.description;
|
||||
row.appendChild(desc);
|
||||
}
|
||||
|
||||
var controls = document.createElement("div");
|
||||
controls.className = "model-role-controls";
|
||||
|
||||
// Alias dropdown
|
||||
var aliasWrap = document.createElement("label");
|
||||
aliasWrap.className = "model-role-control";
|
||||
var aliasLabel = document.createElement("span");
|
||||
aliasLabel.className = "model-role-control-label";
|
||||
aliasLabel.textContent = "Model";
|
||||
aliasWrap.appendChild(aliasLabel);
|
||||
var aliasSel = document.createElement("select");
|
||||
aliasSel.setAttribute("data-role-key", role.aliasKey);
|
||||
aliasSel.setAttribute(
|
||||
"aria-label",
|
||||
role.label + " model (empty = default)",
|
||||
);
|
||||
var blank = document.createElement("option");
|
||||
blank.value = "";
|
||||
blank.textContent = _modelDefaultAlias
|
||||
? "(default — " + _modelDefaultAlias + ")"
|
||||
: "(default)";
|
||||
aliasSel.appendChild(blank);
|
||||
var currentAlias = aliasInfo.value || "";
|
||||
var matched = false;
|
||||
for (var m = 0; m < enabledAliases.length; m++) {
|
||||
var md = enabledAliases[m];
|
||||
var opt = document.createElement("option");
|
||||
opt.value = md.alias;
|
||||
opt.textContent =
|
||||
md.alias === md.model ? md.alias : md.alias + " (" + md.model + ")";
|
||||
if (currentAlias && currentAlias === md.alias) {
|
||||
opt.selected = true;
|
||||
matched = true;
|
||||
}
|
||||
aliasSel.appendChild(opt);
|
||||
}
|
||||
if (currentAlias && !matched) {
|
||||
var manual = document.createElement("option");
|
||||
manual.value = currentAlias;
|
||||
manual.textContent = currentAlias + " (manual)";
|
||||
manual.selected = true;
|
||||
aliasSel.appendChild(manual);
|
||||
}
|
||||
aliasSel.addEventListener("change", function () {
|
||||
_saveModelRole(this.getAttribute("data-role-key"), this.value);
|
||||
});
|
||||
aliasWrap.appendChild(aliasSel);
|
||||
controls.appendChild(aliasWrap);
|
||||
|
||||
// Optional reasoning effort dropdown
|
||||
if (role.effortKey && values[role.effortKey] && schema[role.effortKey]) {
|
||||
var effortWrap = document.createElement("label");
|
||||
effortWrap.className = "model-role-control";
|
||||
var effortLabel = document.createElement("span");
|
||||
effortLabel.className = "model-role-control-label";
|
||||
effortLabel.textContent = "Reasoning effort";
|
||||
effortWrap.appendChild(effortLabel);
|
||||
var effortSel = document.createElement("select");
|
||||
effortSel.setAttribute("data-role-key", role.effortKey);
|
||||
effortSel.setAttribute("aria-label", role.label + " reasoning effort");
|
||||
var choices = schema[role.effortKey].choices || [];
|
||||
var currentEffort = values[role.effortKey].value;
|
||||
for (var c2 = 0; c2 < choices.length; c2++) {
|
||||
var eo = document.createElement("option");
|
||||
eo.value = choices[c2];
|
||||
eo.textContent = choices[c2] === "" ? "(inherit)" : choices[c2];
|
||||
if (currentEffort === choices[c2]) eo.selected = true;
|
||||
effortSel.appendChild(eo);
|
||||
}
|
||||
effortSel.addEventListener("change", function () {
|
||||
_saveModelRole(this.getAttribute("data-role-key"), this.value);
|
||||
});
|
||||
effortWrap.appendChild(effortSel);
|
||||
controls.appendChild(effortWrap);
|
||||
}
|
||||
|
||||
row.appendChild(controls);
|
||||
container.appendChild(row);
|
||||
}
|
||||
|
||||
if (!container.children.length) {
|
||||
_modelRolesError(container, "No model roles configured");
|
||||
}
|
||||
}
|
||||
|
||||
function _saveModelRole(key, value) {
|
||||
authFetch("/v1/api/admin/settings/" + encodeURIComponent(key), {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ value: value }),
|
||||
})
|
||||
.then(function (r) {
|
||||
if (!r.ok)
|
||||
return r.json().then(function (d) {
|
||||
throw new Error(d.error || "Failed");
|
||||
});
|
||||
return r.json();
|
||||
})
|
||||
.then(function () {
|
||||
showToast("Saved");
|
||||
loadAdminModelRoles();
|
||||
})
|
||||
.catch(function (e) {
|
||||
showToast("Error: " + (e && e.message ? e.message : "save failed"));
|
||||
});
|
||||
}
|
||||
|
||||
function _renderModels(items) {
|
||||
var el = document.getElementById("admin-models-table");
|
||||
// Clear previous content
|
||||
@@ -4705,6 +5001,7 @@ function showCreateModelModal() {
|
||||
document.getElementById("model-max-tokens").value = "";
|
||||
document.getElementById("model-reasoning-effort").value = "";
|
||||
document.getElementById("model-server-type").value = "";
|
||||
document.getElementById("model-api-surface").value = "";
|
||||
document.getElementById("model-thinking-mode").value = "";
|
||||
document.getElementById("model-thinking-param").value = "";
|
||||
document.getElementById("model-thinking-param-row").style.display = "none";
|
||||
@@ -4781,8 +5078,9 @@ function showEditModelModal(definitionId) {
|
||||
document.getElementById("model-thinking-param").value = "";
|
||||
}
|
||||
_toggleThinkingParam();
|
||||
// Server compat: server_type and extra_body workarounds
|
||||
// Server compat: server_type, api_surface, and extra_body workarounds
|
||||
document.getElementById("model-server-type").value = sc.server_type || "";
|
||||
document.getElementById("model-api-surface").value = sc.api_surface || "";
|
||||
var eb = sc.extra_body || {};
|
||||
var ebText = JSON.stringify(eb, null, 2);
|
||||
document.getElementById("model-extra-body").value =
|
||||
@@ -4864,26 +5162,34 @@ function submitCreateModel() {
|
||||
if (savedParam) caps.thinking_param = savedParam;
|
||||
}
|
||||
|
||||
// Build server_compat from structured fields
|
||||
// Build server_compat from structured fields. Only meaningful for
|
||||
// openai-compatible aliases — for other providers the section is hidden
|
||||
// but the form values can linger after a provider switch, so gate the
|
||||
// whole block on the active provider to keep persisted state honest.
|
||||
var serverCompat = {};
|
||||
var serverType = document.getElementById("model-server-type").value;
|
||||
if (serverType) serverCompat.server_type = serverType;
|
||||
var providerVal = document.getElementById("model-provider").value;
|
||||
var ebEl = document.getElementById("model-extra-body");
|
||||
var ebText = ebEl.value.trim();
|
||||
ebEl.removeAttribute("aria-invalid");
|
||||
ebEl.style.borderColor = "";
|
||||
if (ebText) {
|
||||
try {
|
||||
var ebParsed = JSON.parse(ebText);
|
||||
if (!_isPlainObject(ebParsed)) {
|
||||
throw new Error("not an object");
|
||||
if (providerVal === "openai-compatible") {
|
||||
var serverType = document.getElementById("model-server-type").value;
|
||||
if (serverType) serverCompat.server_type = serverType;
|
||||
var apiSurface = document.getElementById("model-api-surface").value;
|
||||
if (apiSurface) serverCompat.api_surface = apiSurface;
|
||||
var ebText = ebEl.value.trim();
|
||||
if (ebText) {
|
||||
try {
|
||||
var ebParsed = JSON.parse(ebText);
|
||||
if (!_isPlainObject(ebParsed)) {
|
||||
throw new Error("not an object");
|
||||
}
|
||||
serverCompat.extra_body = ebParsed;
|
||||
} catch (e) {
|
||||
ebEl.setAttribute("aria-invalid", "true");
|
||||
ebEl.style.borderColor = "var(--red)";
|
||||
_showModelError("Extra body params must be a JSON object");
|
||||
return;
|
||||
}
|
||||
serverCompat.extra_body = ebParsed;
|
||||
} catch (e) {
|
||||
ebEl.setAttribute("aria-invalid", "true");
|
||||
ebEl.style.borderColor = "var(--red)";
|
||||
_showModelError("Extra body params must be a JSON object");
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (Object.keys(serverCompat).length > 0) {
|
||||
@@ -5114,6 +5420,13 @@ function detectModel() {
|
||||
stOpts2.indexOf(ssc.server_type) !== -1
|
||||
)
|
||||
stEl2.value = ssc.server_type;
|
||||
// Restrict to the known set so a hostile detect response can't
|
||||
// smuggle a non-listed value into the form.
|
||||
var _SURFACE_SUGGESTABLE = { chat: 1, responses: 1 };
|
||||
if (ssc.api_surface && _SURFACE_SUGGESTABLE[ssc.api_surface]) {
|
||||
var asEl = document.getElementById("model-api-surface");
|
||||
if (!asEl.value) asEl.value = ssc.api_surface;
|
||||
}
|
||||
if (ssc.extra_body) {
|
||||
var ebEl2 = document.getElementById("model-extra-body");
|
||||
if (!ebEl2.value.trim()) {
|
||||
|
||||
+389
-84
@@ -5,17 +5,13 @@ window.onLoginSuccess = function () {
|
||||
if (typeof _refreshHomeComposerVisibility === "function") {
|
||||
_refreshHomeComposerVisibility();
|
||||
}
|
||||
// Re-populate the home-composer skill dropdown and re-probe the
|
||||
// coordinator subsystem now that auth has landed. The initial
|
||||
// page-load pass runs before login completes, so /v1/api/skills
|
||||
// and /v1/api/workstreams both 401; without this re-run the
|
||||
// dropdown stays empty and the 503 banner never flips correctly.
|
||||
// Re-populate the home-composer skill dropdown now that auth has
|
||||
// landed. The initial page-load pass runs before login completes,
|
||||
// so /v1/api/skills 401s; without this re-run the dropdown stays
|
||||
// empty.
|
||||
if (typeof _populateHomeSkillDropdown === "function") {
|
||||
_populateHomeSkillDropdown();
|
||||
}
|
||||
if (typeof _probeCoordSubsystem === "function") {
|
||||
_probeCoordSubsystem();
|
||||
}
|
||||
// Active-coordinators list is SSE-driven via the console pseudo-node
|
||||
// (#9) — no poller to restart after login. The home-view renderer
|
||||
// reads from clusterState.nodes["console"].workstreams on every SSE
|
||||
@@ -427,6 +423,22 @@ function handleClusterEvent(data) {
|
||||
if (data.type === "ws_closed" && data.reason === "evicted") {
|
||||
showToast("Evicted" + (data.name ? ": " + data.name : "") + " (capacity)");
|
||||
}
|
||||
if (data.type === "models_changed") {
|
||||
// Server emits this when a model definition or a role-assignment
|
||||
// setting (model.default_alias, judge.model, coordinator.model_alias,
|
||||
// coordinator.reasoning_effort) changes. Refresh anything that
|
||||
// renders model aliases so labels stay accurate without a reload.
|
||||
if (typeof _populateHomeModelDropdowns === "function") {
|
||||
_populateHomeModelDropdowns();
|
||||
}
|
||||
if (
|
||||
typeof _adminTab !== "undefined" &&
|
||||
_adminTab === "models" &&
|
||||
typeof loadAdminModels === "function"
|
||||
) {
|
||||
loadAdminModels();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Home View ---
|
||||
@@ -1445,8 +1457,8 @@ function _hasCoordPermission() {
|
||||
// POST /v1/api/workstreams/new. Accepts the three request fields
|
||||
// directly + an errEl / setBusy callback so the caller owns the
|
||||
// loading-state UX (button label swap, composer disabled flag, etc.).
|
||||
// On success redirects to /coordinator/{ws_id}; on 503 invokes on503
|
||||
// so the caller can surface the "subsystem not configured" banner.
|
||||
// On success redirects to /coordinator/{ws_id}; on failure surfaces
|
||||
// the server's error text inline through errEl.
|
||||
function _createCoordinator(opts) {
|
||||
var name = (opts.name || "").trim();
|
||||
var skill = opts.skill || "";
|
||||
@@ -1455,10 +1467,12 @@ function _createCoordinator(opts) {
|
||||
var task = (opts.task || "").trim();
|
||||
var errEl = opts.errEl;
|
||||
var setBusy = opts.setBusy || function () {};
|
||||
var on503 = opts.on503 || function () {};
|
||||
var onSuccess = opts.onSuccess || function () {};
|
||||
|
||||
errEl.style.display = "none";
|
||||
// Error region is always rendered with reserved min-height (see
|
||||
// .home-composer-error in style.css) so toggling validation messages
|
||||
// doesn't reflow the active-coordinators list below — clear the
|
||||
// textContent only, no display toggle.
|
||||
errEl.textContent = "";
|
||||
setBusy(true);
|
||||
|
||||
@@ -1469,11 +1483,30 @@ function _createCoordinator(opts) {
|
||||
if (judgeModel) body.judge_model = judgeModel;
|
||||
if (task) body.initial_message = task;
|
||||
|
||||
authFetch("/v1/api/workstreams/new", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
// Multipart when files are staged — the coord create endpoint
|
||||
// accepts a `meta` JSON field plus zero-or-more `file` parts and
|
||||
// reserves attachments for the very first turn (same flow the
|
||||
// interactive UI's new-ws modal uses against the server). Plain
|
||||
// JSON stays the default when no files are attached.
|
||||
var files = Array.isArray(opts.files) ? opts.files : [];
|
||||
var fetchOpts;
|
||||
if (files.length > 0) {
|
||||
var form = new FormData();
|
||||
form.append("meta", JSON.stringify(body));
|
||||
for (var i = 0; i < files.length; i++) {
|
||||
form.append("file", files[i], files[i].name);
|
||||
}
|
||||
// Don't set Content-Type — the browser adds the correct boundary.
|
||||
fetchOpts = { method: "POST", body: form };
|
||||
} else {
|
||||
fetchOpts = {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
};
|
||||
}
|
||||
|
||||
authFetch("/v1/api/workstreams/new", fetchOpts)
|
||||
.then(function (r) {
|
||||
return r.json().then(function (data) {
|
||||
return { ok: r.ok, status: r.status, data: data };
|
||||
@@ -1481,14 +1514,9 @@ function _createCoordinator(opts) {
|
||||
})
|
||||
.then(function (res) {
|
||||
setBusy(false);
|
||||
if (res.status === 503) {
|
||||
on503(res);
|
||||
return;
|
||||
}
|
||||
if (!res.ok || !res.data || !res.data.ws_id) {
|
||||
errEl.textContent =
|
||||
(res.data && res.data.error) || "HTTP " + res.status;
|
||||
errEl.style.display = "block";
|
||||
return;
|
||||
}
|
||||
onSuccess(res);
|
||||
@@ -1498,7 +1526,6 @@ function _createCoordinator(opts) {
|
||||
.catch(function () {
|
||||
setBusy(false);
|
||||
errEl.textContent = "Request failed";
|
||||
errEl.style.display = "block";
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1511,19 +1538,166 @@ function _createCoordinator(opts) {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
var _homeComposerInit = false;
|
||||
var _homeCoordReady = null; // tri-state: null = unknown, true = ready, false = 503
|
||||
var _homeCoordComposer = null; // shared Composer instance
|
||||
var _homeCoordBusy = false;
|
||||
|
||||
// Single owner for sendBtn.disabled: disabled if EITHER busy OR the
|
||||
// subsystem probe flipped to 503. Every setter for _homeCoordBusy /
|
||||
// _homeCoordReady ends with a call here so the two inputs can't drift
|
||||
// out of sync (and a probe resolving mid-submit can't re-enable the
|
||||
// button under an in-flight request).
|
||||
// Attachment staging for the home coord composer. The coord ws_id
|
||||
// doesn't exist until the create POST resolves, so we hold File
|
||||
// objects in memory and ship them as multipart parts on submit (same
|
||||
// pattern interactive uses for its new-ws modal + dashboard composer).
|
||||
var _homeStagedFiles = [];
|
||||
|
||||
// Per-kind size caps + allowlist mirrored from turnstone/core/attachments.py
|
||||
// so the browser can fail fast. Keep in sync with the interactive
|
||||
// UI's _ATTACH_* constants in turnstone/ui/static/app.js.
|
||||
var _HOME_IMAGE_CAP = 4 * 1024 * 1024;
|
||||
var _HOME_TEXT_CAP = 512 * 1024;
|
||||
var _HOME_MAX_FILES = 10;
|
||||
var _HOME_IMAGE_MIMES = ["image/png", "image/jpeg", "image/gif", "image/webp"];
|
||||
var _HOME_TEXT_APP_MIMES = [
|
||||
"application/json",
|
||||
"application/xml",
|
||||
"application/x-yaml",
|
||||
"application/yaml",
|
||||
"application/toml",
|
||||
];
|
||||
var _HOME_TEXT_EXTENSIONS = [
|
||||
".c",
|
||||
".conf",
|
||||
".cpp",
|
||||
".css",
|
||||
".go",
|
||||
".h",
|
||||
".hpp",
|
||||
".html",
|
||||
".ini",
|
||||
".java",
|
||||
".js",
|
||||
".json",
|
||||
".jsx",
|
||||
".md",
|
||||
".py",
|
||||
".rs",
|
||||
".sh",
|
||||
".sql",
|
||||
".toml",
|
||||
".ts",
|
||||
".tsx",
|
||||
".txt",
|
||||
".xml",
|
||||
".yaml",
|
||||
".yml",
|
||||
];
|
||||
|
||||
function _homeFormatSize(n) {
|
||||
if (n < 1024) return n + " B";
|
||||
if (n < 1024 * 1024) return (n / 1024).toFixed(1) + " KB";
|
||||
return (n / (1024 * 1024)).toFixed(1) + " MB";
|
||||
}
|
||||
|
||||
function _homeIsAttachmentAllowed(file) {
|
||||
var mime = (file.type || "").toLowerCase();
|
||||
if (_HOME_IMAGE_MIMES.indexOf(mime) !== -1) return true;
|
||||
if (mime.indexOf("text/") === 0) return true;
|
||||
if (_HOME_TEXT_APP_MIMES.indexOf(mime) !== -1) return true;
|
||||
var name = (file.name || "").toLowerCase();
|
||||
var dot = name.lastIndexOf(".");
|
||||
if (dot >= 0 && _HOME_TEXT_EXTENSIONS.indexOf(name.substr(dot)) !== -1) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function _homeShowError(msg) {
|
||||
var errEl = document.getElementById("home-coord-error");
|
||||
if (!errEl) return;
|
||||
// Element is always rendered (min-height reserves the row); just
|
||||
// toggle the message text so layout doesn't shift on validation.
|
||||
errEl.textContent = msg || "";
|
||||
}
|
||||
|
||||
function _homeRenderChips() {
|
||||
if (!_homeCoordComposer || !_homeCoordComposer.chipsEl) return;
|
||||
var chipsEl = _homeCoordComposer.chipsEl;
|
||||
chipsEl.textContent = "";
|
||||
for (var i = 0; i < _homeStagedFiles.length; i++) {
|
||||
(function (idx) {
|
||||
var f = _homeStagedFiles[idx];
|
||||
var isImage = (f.type || "").indexOf("image/") === 0;
|
||||
var chip = document.createElement("span");
|
||||
chip.className =
|
||||
"composer-chip composer-chip-" + (isImage ? "image" : "text");
|
||||
chip.setAttribute("role", "listitem");
|
||||
|
||||
var icon = document.createElement("span");
|
||||
icon.className = "composer-chip-icon";
|
||||
icon.setAttribute("aria-hidden", "true");
|
||||
icon.textContent = isImage ? "🖼" : "📄";
|
||||
chip.appendChild(icon);
|
||||
|
||||
var name = document.createElement("span");
|
||||
name.className = "composer-chip-name";
|
||||
name.textContent = f.name;
|
||||
name.title = f.name + " (" + f.size + " bytes)";
|
||||
chip.appendChild(name);
|
||||
|
||||
var size = document.createElement("span");
|
||||
size.className = "composer-chip-size";
|
||||
size.textContent = _homeFormatSize(f.size);
|
||||
chip.appendChild(size);
|
||||
|
||||
var rm = document.createElement("button");
|
||||
rm.type = "button";
|
||||
rm.className = "composer-chip-remove";
|
||||
rm.setAttribute("aria-label", "Remove " + f.name);
|
||||
rm.title = "Remove";
|
||||
rm.textContent = "×";
|
||||
rm.onclick = function () {
|
||||
_homeStagedFiles.splice(idx, 1);
|
||||
_homeRenderChips();
|
||||
};
|
||||
chip.appendChild(rm);
|
||||
chipsEl.appendChild(chip);
|
||||
})(i);
|
||||
}
|
||||
}
|
||||
|
||||
function _homeStageFile(file) {
|
||||
if (!file) return;
|
||||
if (_homeStagedFiles.length >= _HOME_MAX_FILES) {
|
||||
_homeShowError(
|
||||
"At most " + _HOME_MAX_FILES + " attachments per coordinator",
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!_homeIsAttachmentAllowed(file)) {
|
||||
_homeShowError(
|
||||
"Unsupported file type: " +
|
||||
file.name +
|
||||
" (allowed: png/jpeg/gif/webp images, text)",
|
||||
);
|
||||
return;
|
||||
}
|
||||
var isImage = (file.type || "").indexOf("image/") === 0;
|
||||
var cap = isImage ? _HOME_IMAGE_CAP : _HOME_TEXT_CAP;
|
||||
if (file.size > cap) {
|
||||
_homeShowError(file.name + " exceeds the " + _homeFormatSize(cap) + " cap");
|
||||
return;
|
||||
}
|
||||
_homeShowError("");
|
||||
_homeStagedFiles.push(file);
|
||||
_homeRenderChips();
|
||||
}
|
||||
|
||||
function _homeClearStagedFiles() {
|
||||
_homeStagedFiles = [];
|
||||
_homeRenderChips();
|
||||
}
|
||||
|
||||
// Sole owner of sendBtn.disabled: disables while a submit is in flight.
|
||||
function _refreshHomeCoordSubmitEnabled() {
|
||||
if (!_homeCoordComposer) return;
|
||||
_homeCoordComposer.sendBtn.disabled =
|
||||
_homeCoordBusy || _homeCoordReady === false;
|
||||
_homeCoordComposer.sendBtn.disabled = _homeCoordBusy;
|
||||
}
|
||||
|
||||
function _ensureHomeComposerInit() {
|
||||
@@ -1532,7 +1706,6 @@ function _ensureHomeComposerInit() {
|
||||
_mountHomeCoordComposer();
|
||||
_populateHomeSkillDropdown();
|
||||
_populateHomeModelDropdowns();
|
||||
_probeCoordSubsystem();
|
||||
_refreshHomeComposerVisibility();
|
||||
}
|
||||
|
||||
@@ -1596,6 +1769,12 @@ function _mountHomeCoordComposer() {
|
||||
},
|
||||
],
|
||||
},
|
||||
attachments: {
|
||||
onAttach: function (file) {
|
||||
_homeStageFile(file);
|
||||
},
|
||||
},
|
||||
dragDrop: { targetEl: mount, dropClass: "home-coord-drop" },
|
||||
onSend: function (text) {
|
||||
submitHomeCoord(text);
|
||||
},
|
||||
@@ -1646,40 +1825,6 @@ function _populateHomeModelDropdowns() {
|
||||
});
|
||||
}
|
||||
|
||||
// Probe GET /v1/api/workstreams — 200 = subsystem ready; 503 = no model
|
||||
// alias resolvable, show remediation banner. 4xx (auth / permission) is
|
||||
// treated as "unknown, don't flip the banner" because the probe cannot
|
||||
// actually tell us anything about subsystem readiness in that case —
|
||||
// the caller is expected to re-invoke this after login lands so a real
|
||||
// answer can arrive. Leaving the submit button enabled on unknown
|
||||
// keeps first-paint usable; a subsequent 503 from the actual submit
|
||||
// flips the banner via _createCoordinator's on503 hook.
|
||||
//
|
||||
// Skip the probe entirely for users without admin.coordinator — they
|
||||
// can't see the composer anyway (see _refreshHomeComposerVisibility),
|
||||
// and the endpoint returns 403 for them, producing a useless network
|
||||
// round-trip on every login.
|
||||
function _probeCoordSubsystem() {
|
||||
if (!_hasCoordPermission()) return;
|
||||
authFetch("/v1/api/workstreams")
|
||||
.then(function (r) {
|
||||
if (r.status === 503) {
|
||||
_homeCoordReady = false;
|
||||
} else if (r.ok) {
|
||||
_homeCoordReady = true;
|
||||
} else {
|
||||
_homeCoordReady = null;
|
||||
return;
|
||||
}
|
||||
var banner = document.getElementById("coord-composer-503");
|
||||
if (banner) banner.style.display = _homeCoordReady ? "none" : "";
|
||||
_refreshHomeCoordSubmitEnabled();
|
||||
})
|
||||
.catch(function () {
|
||||
/* network error — leave banner hidden; submit will surface a retryable error */
|
||||
});
|
||||
}
|
||||
|
||||
function _refreshHomeComposerVisibility() {
|
||||
var panel = document.getElementById("coord-composer-panel");
|
||||
if (!panel) return;
|
||||
@@ -1698,23 +1843,36 @@ function submitHomeCoord(textFromComposer) {
|
||||
var task =
|
||||
textFromComposer != null ? textFromComposer : _homeCoordComposer.value;
|
||||
var opts = _homeCoordComposer.getOptionValues();
|
||||
// Snapshot at submit time so a chip remove mid-request can't race
|
||||
// the multipart payload (the actual reset only fires on the success
|
||||
// branch, after the response lands).
|
||||
var files = _homeStagedFiles.slice();
|
||||
// Files-without-text would upload pending attachment rows but the
|
||||
// server's _coord_create_post_install only reserves+dispatches when
|
||||
// initial_message is non-empty — uploaded files would orphan as
|
||||
// pending storage rows until the GC sweep. Require text whenever
|
||||
// attachments are staged so the first turn always picks them up.
|
||||
if (files.length > 0 && !(task || "").trim()) {
|
||||
_homeShowError(
|
||||
"Add a task message — attachments need an initial turn to dispatch on.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
_createCoordinator({
|
||||
name: opts.name || "",
|
||||
skill: opts.skill || "",
|
||||
model: opts.model || "",
|
||||
judge_model: opts.judge_model || "",
|
||||
task: task,
|
||||
files: files,
|
||||
errEl: document.getElementById("home-coord-error"),
|
||||
setBusy: function (b) {
|
||||
_homeCoordBusy = b;
|
||||
if (_homeCoordComposer) _homeCoordComposer.setBusy(b);
|
||||
_refreshHomeCoordSubmitEnabled();
|
||||
},
|
||||
on503: function () {
|
||||
_homeCoordReady = false;
|
||||
var banner = document.getElementById("coord-composer-503");
|
||||
if (banner) banner.style.display = "";
|
||||
_refreshHomeCoordSubmitEnabled();
|
||||
onSuccess: function () {
|
||||
_homeClearStagedFiles();
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1730,9 +1888,6 @@ document.addEventListener("keydown", function (e) {
|
||||
var mount = document.getElementById("home-coord-composer-mount");
|
||||
if (!mount || !mount.contains(e.target)) return;
|
||||
e.preventDefault();
|
||||
// sendBtn.disabled is the single reconciler of busy + 503-ready —
|
||||
// checking it here is enough to avoid double-submits or submits
|
||||
// while the subsystem is down.
|
||||
if (!_homeCoordComposer.sendBtn.disabled) submitHomeCoord();
|
||||
});
|
||||
|
||||
@@ -1851,6 +2006,16 @@ var _savedCoordsRetry = false;
|
||||
|
||||
function loadSavedCoordinators() {
|
||||
if (!_hasCoordPermission()) return;
|
||||
// Freeze the list while the user is multi-selecting — re-rendering
|
||||
// mid-mode would shuffle the visible page out from under them. The
|
||||
// delete-mode wrapper drains the retry flag on cancel/onClose.
|
||||
if (
|
||||
typeof _coordDeleteController !== "undefined" &&
|
||||
_coordDeleteController.inMode()
|
||||
) {
|
||||
_savedCoordsRetry = true;
|
||||
return;
|
||||
}
|
||||
if (_savedCoordsInFlight) {
|
||||
_savedCoordsRetry = true;
|
||||
return;
|
||||
@@ -1861,6 +2026,16 @@ function loadSavedCoordinators() {
|
||||
return r.ok ? r.json() : { workstreams: [] };
|
||||
})
|
||||
.then(function (data) {
|
||||
// Belt-and-braces: if the user entered delete mode while this
|
||||
// fetch was already in flight, defer the render — re-rendering
|
||||
// mid-selection would shuffle visible cards and reshape selections.
|
||||
if (
|
||||
typeof _coordDeleteController !== "undefined" &&
|
||||
_coordDeleteController.inMode()
|
||||
) {
|
||||
_savedCoordsRetry = true;
|
||||
return;
|
||||
}
|
||||
renderSavedCoordinators(data.workstreams || []);
|
||||
})
|
||||
.catch(function () {
|
||||
@@ -1878,7 +2053,52 @@ function loadSavedCoordinators() {
|
||||
});
|
||||
}
|
||||
|
||||
// Saved Coordinators: paginated card list + multi-select delete.
|
||||
// The shared controller (createSavedCardsController in /shared/cards.js)
|
||||
// owns mode state, checkbox decoration, the toolbar, and the modal.
|
||||
// Pagination caps Select-All fan-out at COORD_PAGE_SIZE — the controller
|
||||
// only ever sees the visible page, so a confirm-all batch is bounded to
|
||||
// COORD_PAGE_SIZE parallel POSTs against the routing proxy.
|
||||
var COORD_PAGE_SIZE = 24;
|
||||
var _coordPage = 0;
|
||||
var _coordSavedItems = [];
|
||||
var _coordDeleteController = createSavedCardsController({
|
||||
idPrefix: "coord-delete",
|
||||
buttonId: "coord-delete-btn",
|
||||
noun: "coordinator",
|
||||
activateLabel: function (s) {
|
||||
return "Resume coordinator: " + (s.alias || s.title || s.name || s.ws_id);
|
||||
},
|
||||
// Coordinators live on whichever node owns the ws_id, so we can't fire
|
||||
// a path-keyed delete the way ui/static does. The router proxy reads
|
||||
// ws_id from the body, resolves the owning node via the consistent-
|
||||
// hash ring, and forwards to that node's POST workstreams/{ws_id}/delete.
|
||||
buildDeleteRequest: function (wsId) {
|
||||
return {
|
||||
url: "/v1/api/route/workstreams/delete",
|
||||
options: {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ ws_id: wsId }),
|
||||
},
|
||||
};
|
||||
},
|
||||
render: function () {
|
||||
renderSavedCoordinators(_coordSavedItems);
|
||||
},
|
||||
onClose: function () {
|
||||
// Drain queued retries before the explicit reload — without this,
|
||||
// _savedCoordsRetry is still true from SSE events that arrived
|
||||
// during the freeze, so loadSavedCoordinators's .finally() would
|
||||
// re-fire a second fetch immediately after the first resolves.
|
||||
// Same idiom as cancelCoordDeleteMode below.
|
||||
_savedCoordsRetry = false;
|
||||
loadSavedCoordinators();
|
||||
},
|
||||
});
|
||||
|
||||
function renderSavedCoordinators(items) {
|
||||
_coordSavedItems = items;
|
||||
var section = document.getElementById("saved-coordinators");
|
||||
var cards = document.getElementById("saved-coord-cards");
|
||||
var countEl = document.getElementById("saved-coord-count");
|
||||
@@ -1887,24 +2107,31 @@ function renderSavedCoordinators(items) {
|
||||
section.style.display = "none";
|
||||
cards.replaceChildren();
|
||||
if (countEl) countEl.textContent = "";
|
||||
_coordPage = 0;
|
||||
if (_coordDeleteController.inMode()) _coordDeleteController.cancel();
|
||||
_renderCoordPagination();
|
||||
return;
|
||||
}
|
||||
// Clamp the page index after deletes (or upstream churn) shrink the list.
|
||||
var pages = Math.max(1, Math.ceil(items.length / COORD_PAGE_SIZE));
|
||||
if (_coordPage > pages - 1) _coordPage = pages - 1;
|
||||
if (_coordPage < 0) _coordPage = 0;
|
||||
var visible = items.slice(
|
||||
_coordPage * COORD_PAGE_SIZE,
|
||||
(_coordPage + 1) * COORD_PAGE_SIZE,
|
||||
);
|
||||
_coordDeleteController.setItems(visible);
|
||||
|
||||
section.style.display = "";
|
||||
if (countEl) countEl.textContent = "(" + items.length + ")";
|
||||
cards.replaceChildren();
|
||||
items.forEach(function (sess) {
|
||||
visible.forEach(function (sess) {
|
||||
var card = renderSessionCard(sess, {
|
||||
ariaLabel: function (s) {
|
||||
return (
|
||||
"Resume coordinator: " + (s.alias || s.title || s.name || s.ws_id)
|
||||
);
|
||||
},
|
||||
ariaLabel: _coordDeleteController.ariaLabel,
|
||||
onActivate: function (s, cardEl) {
|
||||
if (_coordDeleteController.blockActivate()) return;
|
||||
// POST /open BEFORE navigating so capacity issues surface as a
|
||||
// toast instead of a broken-looking detail page. The /open
|
||||
// endpoint calls the same lazy-rehydrate path the GET would,
|
||||
// but we get the status code synchronously so the user learns
|
||||
// "all slots in use" instead of staring at a 404.
|
||||
// toast instead of a broken-looking detail page.
|
||||
cardEl.classList.add("is-busy");
|
||||
authFetch(
|
||||
"/v1/api/workstreams/" + encodeURIComponent(s.ws_id) + "/open",
|
||||
@@ -1936,8 +2163,86 @@ function renderSavedCoordinators(items) {
|
||||
});
|
||||
},
|
||||
});
|
||||
_coordDeleteController.decorateCard(card, sess);
|
||||
cards.appendChild(card);
|
||||
});
|
||||
if (_coordDeleteController.inMode()) _coordDeleteController.refreshBar();
|
||||
_renderCoordPagination();
|
||||
}
|
||||
|
||||
function _renderCoordPagination() {
|
||||
var pag = document.getElementById("coord-pagination");
|
||||
if (!pag) return;
|
||||
var total = _coordSavedItems.length;
|
||||
var pages = Math.max(1, Math.ceil(total / COORD_PAGE_SIZE));
|
||||
// Single-page lists and delete-mode hide the controls — page changes
|
||||
// would invalidate the user's checkbox selections, so we lock them out.
|
||||
if (pages <= 1 || _coordDeleteController.inMode()) {
|
||||
pag.style.display = "none";
|
||||
return;
|
||||
}
|
||||
pag.style.display = "";
|
||||
var label = document.getElementById("coord-page-label");
|
||||
if (label) {
|
||||
/* Visible text uses the terse "X / Y" form to match the
|
||||
filtered-pagination control elsewhere in the console; the long
|
||||
form sits on the parent's aria-label so screen readers still get
|
||||
a full sentence. */
|
||||
label.textContent = _coordPage + 1 + " / " + pages;
|
||||
pag.setAttribute(
|
||||
"aria-label",
|
||||
"Saved coordinators pagination — page " +
|
||||
(_coordPage + 1) +
|
||||
" of " +
|
||||
pages,
|
||||
);
|
||||
}
|
||||
var prev = document.getElementById("coord-page-prev");
|
||||
if (prev) prev.disabled = _coordPage <= 0;
|
||||
var next = document.getElementById("coord-page-next");
|
||||
if (next) next.disabled = _coordPage >= pages - 1;
|
||||
}
|
||||
|
||||
function coordPagePrev() {
|
||||
if (_coordPage > 0) {
|
||||
_coordPage--;
|
||||
renderSavedCoordinators(_coordSavedItems);
|
||||
}
|
||||
}
|
||||
|
||||
function coordPageNext() {
|
||||
var pages = Math.max(1, Math.ceil(_coordSavedItems.length / COORD_PAGE_SIZE));
|
||||
if (_coordPage < pages - 1) {
|
||||
_coordPage++;
|
||||
renderSavedCoordinators(_coordSavedItems);
|
||||
}
|
||||
}
|
||||
|
||||
// HTML inline-onclick wrappers — keep the global names the markup binds
|
||||
// to and forward to the shared controller.
|
||||
function startCoordDeleteMode() {
|
||||
_coordDeleteController.start();
|
||||
}
|
||||
function cancelCoordDeleteMode() {
|
||||
_coordDeleteController.cancel();
|
||||
// The freeze gate (see loadSavedCoordinators) may have queued retries
|
||||
// while we were multi-selecting; drain them now that we're idle again.
|
||||
if (_savedCoordsRetry) {
|
||||
_savedCoordsRetry = false;
|
||||
loadSavedCoordinators();
|
||||
}
|
||||
}
|
||||
function toggleCoordSelectAll() {
|
||||
_coordDeleteController.toggleAll();
|
||||
}
|
||||
function confirmCoordDeleteSelection() {
|
||||
_coordDeleteController.confirmSelection();
|
||||
}
|
||||
function cancelCoordDelete() {
|
||||
_coordDeleteController.closeModal();
|
||||
}
|
||||
function confirmCoordDelete() {
|
||||
_coordDeleteController.confirm();
|
||||
}
|
||||
|
||||
// --- Init ---
|
||||
|
||||
@@ -396,6 +396,87 @@
|
||||
background: color-mix(in srgb, var(--err) 12%, var(--panel-2));
|
||||
}
|
||||
|
||||
/* Output-guard finding rendered under its specific .coord-tool-row.
|
||||
Stays anchored to the call that tripped the guard rather than
|
||||
floating into the chat log as a generic "[output guard]" line —
|
||||
matches interactive's `.output-warning` placement convention.
|
||||
Severity drives the hue (matches .verdict-badge.verdict-* palette
|
||||
so an operator scanning a workstream reads risk consistently
|
||||
across both surfaces). */
|
||||
.coord-tool-row-warning {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-top: 4px;
|
||||
padding: 2px 8px;
|
||||
font-size: 11px;
|
||||
font-family: var(--font-mono);
|
||||
border: 1px solid var(--hair);
|
||||
border-radius: 3px;
|
||||
border-left-width: 3px;
|
||||
background: var(--panel-2);
|
||||
color: var(--ink-2);
|
||||
max-width: max-content;
|
||||
}
|
||||
.coord-tool-row-warning--low {
|
||||
color: color-mix(in srgb, var(--ok) 70%, var(--ink-2));
|
||||
border-left-color: var(--ok);
|
||||
background: color-mix(in srgb, var(--ok) 12%, var(--panel-2));
|
||||
}
|
||||
.coord-tool-row-warning--medium {
|
||||
color: color-mix(in srgb, var(--warn) 70%, var(--ink-2));
|
||||
border-left-color: var(--warn);
|
||||
background: var(--warn-tint);
|
||||
}
|
||||
.coord-tool-row-warning--high,
|
||||
.coord-tool-row-warning--critical {
|
||||
color: color-mix(in srgb, var(--err) 70%, var(--ink-2));
|
||||
border-left-color: var(--err);
|
||||
background: color-mix(in srgb, var(--err) 12%, var(--panel-2));
|
||||
}
|
||||
.coord-tool-row-warning-redacted {
|
||||
color: var(--ink-3);
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
/* Storage-truncation indicator — same convention as the interactive
|
||||
UI's `.tool-output-truncated` pill (transparent bg, dim border,
|
||||
small font) so the operator reads the affordance the same way on
|
||||
both surfaces. Sibling node next to .coord-tool-row-result rather
|
||||
than text-in-content so a future "best-effort JSON repair" pass
|
||||
on the result body doesn't have to strip a marker string. */
|
||||
.coord-tool-truncated {
|
||||
display: inline-block;
|
||||
margin-top: 4px;
|
||||
margin-left: 6px;
|
||||
padding: 1px 6px;
|
||||
font-size: 10px;
|
||||
font-family: var(--font-mono);
|
||||
color: var(--ink-3);
|
||||
background: transparent;
|
||||
border: 1px solid var(--ink-3);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
/* memory/recall calls are background metadata — the audit trail is
|
||||
useful but they crowd the tree on workstreams with heavy memory
|
||||
usage. Dim the row by default; full opacity on hover so they
|
||||
stay inspectable. Mirrors the interactive UI's metacog dim rule
|
||||
(style.css `.ts-approval-tool[data-func-name="memory"]`). The
|
||||
row stamps `data-tool-name` from item.func_name in coordinator.js
|
||||
so this selector has something to match. */
|
||||
.coord-tool-row[data-tool-name="memory"],
|
||||
.coord-tool-row[data-tool-name="recall"] {
|
||||
opacity: 0.55;
|
||||
transition: opacity 120ms ease-out;
|
||||
}
|
||||
.coord-tool-row[data-tool-name="memory"]:hover,
|
||||
.coord-tool-row[data-tool-name="memory"]:focus-within,
|
||||
.coord-tool-row[data-tool-name="recall"]:hover,
|
||||
.coord-tool-row[data-tool-name="recall"]:focus-within {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Tool result block — paired under its row, mono pre-block. Capped
|
||||
at 240px with internal scroll so a long tool output doesn't push
|
||||
the rest of the chat off-screen. The interactive UI uses a
|
||||
@@ -537,6 +618,43 @@
|
||||
color: var(--ink-3);
|
||||
}
|
||||
|
||||
/* User-message attachment pills — rendered beneath the bubble for
|
||||
live sends and on history replay. Mirrors the .msg-user-attach*
|
||||
rules in turnstone/ui/static/style.css so coord and interactive
|
||||
surfaces show the same affordance for attached files. */
|
||||
.msg-user-attach {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
.msg-user-attach-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
/* --panel (not --panel-2) — the .msg bubble is already --panel-2,
|
||||
so pulling the pill onto the alternate surface keeps it visible
|
||||
against the bubble in both themes (WCAG 1.4.11 non-text contrast).
|
||||
Mirrors the interactive UI's --bg-surface vs .msg --panel-2 split. */
|
||||
background: var(--panel);
|
||||
color: var(--ink-2);
|
||||
border: 1px solid var(--hair);
|
||||
border-radius: 4px;
|
||||
padding: 2px 6px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
}
|
||||
.msg-user-attach-icon {
|
||||
font-size: 11px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
.msg-user-attach-name {
|
||||
max-width: 200px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Mobile (<700px) — keep action targets ≥44px for WCAG 2.5.5. */
|
||||
@media (max-width: 700px) {
|
||||
.coord-tool-actions {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -182,6 +182,36 @@
|
||||
font-size: 11px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
/* Loading-state placeholder rendered while the bulk fetch is
|
||||
in-flight. Same outer container as the real block so the row
|
||||
height is stable when the real content swaps in. */
|
||||
.ch-row .approval-block-loading {
|
||||
color: var(--ink-3);
|
||||
}
|
||||
.ch-row .approval-loading-spin {
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
border: 1.5px solid var(--accent);
|
||||
border-top-color: transparent;
|
||||
margin-right: 5px;
|
||||
animation: ts-spin 0.9s linear infinite;
|
||||
vertical-align: middle;
|
||||
}
|
||||
/* Inline status note shown when the operator clicks Approve/Deny
|
||||
but the call_id is stale (already resolved on another channel).
|
||||
Quieter than a toast — the row is about to be replaced wholesale
|
||||
by the refresh, so this only needs to bridge ~350ms. */
|
||||
.ch-row .approval-stale-note {
|
||||
font-size: 11px;
|
||||
color: var(--ink-3);
|
||||
font-style: italic;
|
||||
margin-top: 4px;
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.ch-row .approval-loading-spin { animation: none; }
|
||||
}
|
||||
/* Auto-approved pill — same 22px indent as .approval-block / .meta
|
||||
so a child row showing both stacks cleanly. Lower visual weight
|
||||
than the live approve/deny block (this is informational, not
|
||||
|
||||
@@ -2791,7 +2791,8 @@ var _eogpTriggerEl = null;
|
||||
function switchJudgeSection(section) {
|
||||
var sections = document.querySelectorAll(".judge-section");
|
||||
for (var i = 0; i < sections.length; i++) sections[i].style.display = "none";
|
||||
var btns = document.querySelectorAll(".judge-section-btn");
|
||||
var switcher = document.querySelector("#admin-judge .admin-subtab-switcher");
|
||||
var btns = switcher ? switcher.querySelectorAll(".admin-subtab-btn") : [];
|
||||
for (var i = 0; i < btns.length; i++) {
|
||||
var isActive = btns[i].getAttribute("data-section") === section;
|
||||
btns[i].classList.toggle("active", isActive);
|
||||
@@ -2804,15 +2805,15 @@ function switchJudgeSection(section) {
|
||||
|
||||
// Arrow key navigation for judge sub-section tabs
|
||||
(function () {
|
||||
var switcher = document.querySelector(".judge-section-switcher");
|
||||
var switcher = document.querySelector("#admin-judge .admin-subtab-switcher");
|
||||
if (!switcher) return;
|
||||
switcher.addEventListener("keydown", function (e) {
|
||||
if (e.key !== "ArrowLeft" && e.key !== "ArrowRight") return;
|
||||
var btns = switcher.querySelectorAll(".judge-section-btn");
|
||||
var btns = switcher.querySelectorAll(".admin-subtab-btn");
|
||||
var secs = [];
|
||||
for (var i = 0; i < btns.length; i++)
|
||||
secs.push(btns[i].getAttribute("data-section"));
|
||||
var current = switcher.querySelector(".judge-section-btn.active");
|
||||
var current = switcher.querySelector(".admin-subtab-btn.active");
|
||||
var idx = secs.indexOf(current ? current.getAttribute("data-section") : "");
|
||||
if (e.key === "ArrowRight") idx = (idx + 1) % secs.length;
|
||||
else idx = (idx - 1 + secs.length) % secs.length;
|
||||
@@ -2874,6 +2875,7 @@ function renderJudgeSettings() {
|
||||
var html = "";
|
||||
for (var i = 0; i < _judgeSettings.length; i++) {
|
||||
var s = _judgeSettings[i];
|
||||
if (s.key === "judge.model") continue;
|
||||
var shortKey = s.key.replace("judge.", "");
|
||||
var inputHtml = "";
|
||||
var currentVal = s.value;
|
||||
|
||||
@@ -87,33 +87,16 @@
|
||||
<div id="view-home">
|
||||
<!-- Persistent "start a new coordinator task" composer. Visibility is
|
||||
gated on the admin.coordinator permission (same rule the existing
|
||||
+coordinator header button + modal use). Shows a remediation
|
||||
banner when the create endpoint would return 503 (no coordinator
|
||||
model alias resolves). -->
|
||||
+coordinator header button + modal use). Submission errors surface
|
||||
inline via #home-coord-error; the create endpoint falls back to
|
||||
the registry default model when ``coordinator.model_alias`` is
|
||||
unset, so no proactive readiness probe is needed. -->
|
||||
<section
|
||||
id="coord-composer-panel"
|
||||
class="home-panel"
|
||||
style="display: none"
|
||||
aria-label="Start a new orchestration task"
|
||||
>
|
||||
<div
|
||||
id="coord-composer-503"
|
||||
class="home-composer-banner"
|
||||
role="status"
|
||||
style="display: none"
|
||||
>
|
||||
Coordinator subsystem not configured —
|
||||
<a
|
||||
href="#"
|
||||
onclick="
|
||||
showAdmin();
|
||||
switchAdminTab('models');
|
||||
return false;
|
||||
"
|
||||
>open Admin → Models</a
|
||||
>
|
||||
to set <code>coordinator.model_alias</code> or a registry default.
|
||||
</div>
|
||||
<!-- Composer DOM is built by shared_static/composer.js into this
|
||||
mount — stacked layout (textarea above, options toggle +
|
||||
Start button below) with Name + Skill in an Options
|
||||
@@ -122,9 +105,8 @@
|
||||
<div
|
||||
id="home-coord-error"
|
||||
class="home-composer-error"
|
||||
role="alert"
|
||||
aria-live="assertive"
|
||||
style="display: none"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
></div>
|
||||
</section>
|
||||
|
||||
@@ -165,6 +147,14 @@
|
||||
<h2 class="home-section-title">
|
||||
<span>Saved Coordinators</span>
|
||||
<span id="saved-coord-count" class="home-section-count"></span>
|
||||
<button
|
||||
id="coord-delete-btn"
|
||||
class="ws-delete-btn home-section-action"
|
||||
onclick="startCoordDeleteMode()"
|
||||
title="Delete coordinators"
|
||||
>
|
||||
<span aria-hidden="true">🗑</span> Delete
|
||||
</button>
|
||||
</h2>
|
||||
<div
|
||||
id="saved-coord-cards"
|
||||
@@ -172,6 +162,61 @@
|
||||
role="list"
|
||||
aria-live="polite"
|
||||
></div>
|
||||
<div
|
||||
id="coord-pagination"
|
||||
class="pagination"
|
||||
style="display: none"
|
||||
role="navigation"
|
||||
aria-label="Saved coordinators pagination"
|
||||
>
|
||||
<button
|
||||
id="coord-page-prev"
|
||||
type="button"
|
||||
onclick="coordPagePrev()"
|
||||
>
|
||||
◄ Prev
|
||||
</button>
|
||||
<span id="coord-page-label" aria-live="polite" aria-atomic="true">
|
||||
</span>
|
||||
<button
|
||||
id="coord-page-next"
|
||||
type="button"
|
||||
onclick="coordPageNext()"
|
||||
>
|
||||
Next ►
|
||||
</button>
|
||||
</div>
|
||||
<div id="coord-delete-bar" class="ws-delete-bar">
|
||||
<span
|
||||
class="ws-delete-count-label"
|
||||
id="coord-delete-bar-count"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
aria-atomic="true"
|
||||
>0 selected</span
|
||||
>
|
||||
<button
|
||||
class="ws-delete-cancel-btn"
|
||||
onclick="cancelCoordDeleteMode()"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
class="ws-delete-selectall-btn"
|
||||
id="coord-delete-bar-select-all"
|
||||
onclick="toggleCoordSelectAll()"
|
||||
>
|
||||
Select All
|
||||
</button>
|
||||
<button
|
||||
class="ws-delete-bar-btn"
|
||||
id="coord-delete-bar-delete"
|
||||
onclick="confirmCoordDeleteSelection()"
|
||||
disabled
|
||||
>
|
||||
Delete Selected
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Cluster details — node list, always visible. The list is
|
||||
@@ -820,13 +865,13 @@
|
||||
|
||||
<!-- Sub-panel switcher -->
|
||||
<div
|
||||
class="judge-section-switcher"
|
||||
class="admin-subtab-switcher"
|
||||
role="tablist"
|
||||
aria-label="Judge sections"
|
||||
>
|
||||
<button
|
||||
id="judge-tab-settings"
|
||||
class="judge-section-btn active"
|
||||
class="admin-subtab-btn active"
|
||||
role="tab"
|
||||
aria-selected="true"
|
||||
aria-controls="judge-settings-section"
|
||||
@@ -838,7 +883,7 @@
|
||||
</button>
|
||||
<button
|
||||
id="judge-tab-heuristic"
|
||||
class="judge-section-btn"
|
||||
class="admin-subtab-btn"
|
||||
role="tab"
|
||||
aria-selected="false"
|
||||
aria-controls="judge-heuristic-section"
|
||||
@@ -850,7 +895,7 @@
|
||||
</button>
|
||||
<button
|
||||
id="judge-tab-output-guard"
|
||||
class="judge-section-btn"
|
||||
class="admin-subtab-btn"
|
||||
role="tab"
|
||||
aria-selected="false"
|
||||
aria-controls="judge-output-guard-section"
|
||||
@@ -1733,37 +1778,106 @@
|
||||
style="display: none"
|
||||
>
|
||||
<div class="admin-toolbar">
|
||||
<span class="section-header">Models</span>
|
||||
<button
|
||||
id="model-sync-btn"
|
||||
class="admin-action-btn admin-action-btn-ghost"
|
||||
onclick="reloadModelNodes()"
|
||||
title="Push model config to all cluster nodes"
|
||||
>
|
||||
Sync to Nodes
|
||||
</button>
|
||||
<button
|
||||
class="admin-action-btn"
|
||||
onclick="showCreateModelModal()"
|
||||
>
|
||||
+ Add Model
|
||||
</button>
|
||||
</div>
|
||||
<div class="admin-colheaders models-grid" aria-hidden="true">
|
||||
<span class="admin-col">ALIAS</span>
|
||||
<span class="admin-col">MODEL</span>
|
||||
<span class="admin-col">PROVIDER</span>
|
||||
<span class="admin-col">CTX WINDOW</span>
|
||||
<span class="admin-col">STATUS</span>
|
||||
<span class="admin-col">ACTIONS</span>
|
||||
<span class="section-header" style="margin: 0">MODELS</span>
|
||||
</div>
|
||||
|
||||
<!-- Sub-panel switcher -->
|
||||
<div
|
||||
id="admin-models-table"
|
||||
role="list"
|
||||
aria-label="Model definitions"
|
||||
aria-live="polite"
|
||||
class="admin-subtab-switcher"
|
||||
role="tablist"
|
||||
aria-label="Models sections"
|
||||
>
|
||||
<div class="dashboard-empty">Loading...</div>
|
||||
<button
|
||||
id="models-tab-list"
|
||||
class="admin-subtab-btn active"
|
||||
role="tab"
|
||||
aria-selected="true"
|
||||
aria-controls="models-list-section"
|
||||
tabindex="0"
|
||||
data-section="models-list"
|
||||
onclick="switchModelsSection('models-list')"
|
||||
>
|
||||
Definitions
|
||||
</button>
|
||||
<button
|
||||
id="models-tab-roles"
|
||||
class="admin-subtab-btn"
|
||||
role="tab"
|
||||
aria-selected="false"
|
||||
aria-controls="models-roles-section"
|
||||
tabindex="-1"
|
||||
data-section="models-roles"
|
||||
onclick="switchModelsSection('models-roles')"
|
||||
>
|
||||
Roles
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Models list section -->
|
||||
<div
|
||||
id="models-list-section"
|
||||
class="models-section"
|
||||
role="tabpanel"
|
||||
aria-labelledby="models-tab-list"
|
||||
>
|
||||
<div class="admin-toolbar" style="margin-bottom: 12px">
|
||||
<span style="font-size: 13px; color: var(--fg-dim)"
|
||||
>Model definitions used by sessions across the cluster</span
|
||||
>
|
||||
<button
|
||||
id="model-sync-btn"
|
||||
class="admin-action-btn admin-action-btn-ghost"
|
||||
onclick="reloadModelNodes()"
|
||||
title="Push model config to all cluster nodes"
|
||||
>
|
||||
Sync to Nodes
|
||||
</button>
|
||||
<button
|
||||
class="admin-action-btn"
|
||||
onclick="showCreateModelModal()"
|
||||
>
|
||||
+ Add Model
|
||||
</button>
|
||||
</div>
|
||||
<div class="admin-colheaders models-grid" aria-hidden="true">
|
||||
<span class="admin-col">ALIAS</span>
|
||||
<span class="admin-col">MODEL</span>
|
||||
<span class="admin-col">PROVIDER</span>
|
||||
<span class="admin-col">CTX WINDOW</span>
|
||||
<span class="admin-col">STATUS</span>
|
||||
<span class="admin-col">ACTIONS</span>
|
||||
</div>
|
||||
<div
|
||||
id="admin-models-table"
|
||||
role="list"
|
||||
aria-label="Model definitions"
|
||||
aria-live="polite"
|
||||
>
|
||||
<div class="dashboard-empty">Loading...</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Roles section (Coordinator / Judge / future perception roles) -->
|
||||
<div
|
||||
id="models-roles-section"
|
||||
class="models-section"
|
||||
role="tabpanel"
|
||||
aria-labelledby="models-tab-roles"
|
||||
style="display: none"
|
||||
>
|
||||
<div style="margin-bottom: 12px">
|
||||
<span style="font-size: 13px; color: var(--fg-dim)"
|
||||
>Per-role model assignments. Empty = use the default
|
||||
model.</span
|
||||
>
|
||||
</div>
|
||||
<div
|
||||
id="admin-models-roles-container"
|
||||
aria-live="polite"
|
||||
style="max-width: 720px"
|
||||
>
|
||||
<div class="dashboard-empty">Loading…</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3806,6 +3920,17 @@
|
||||
<option value="llama.cpp">llama.cpp</option>
|
||||
<option value="openai-compatible">Other OpenAI-compatible</option>
|
||||
</select>
|
||||
<label for="model-api-surface"
|
||||
>API Surface
|
||||
<span style="font-weight: 400; text-transform: none"
|
||||
>(Chat Completions vs Responses)</span
|
||||
></label
|
||||
>
|
||||
<select id="model-api-surface">
|
||||
<option value="">Inherit (Chat Completions)</option>
|
||||
<option value="chat">Chat Completions (pinned)</option>
|
||||
<option value="responses">Responses API</option>
|
||||
</select>
|
||||
<label for="model-thinking-mode"
|
||||
>Thinking Mode
|
||||
<span style="font-weight: 400; text-transform: none"
|
||||
@@ -3907,6 +4032,40 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Delete coordinators confirmation modal (batch) -->
|
||||
<div
|
||||
id="coord-delete-overlay"
|
||||
class="ws-delete-modal-overlay"
|
||||
style="display: none"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="coord-delete-title"
|
||||
>
|
||||
<div id="coord-delete-box" class="ws-delete-modal-box">
|
||||
<h3 id="coord-delete-title">Delete Coordinators</h3>
|
||||
<div id="coord-delete-error" role="alert" aria-live="assertive"></div>
|
||||
<p id="coord-delete-count"></p>
|
||||
<div id="coord-delete-list" class="ws-delete-modal-list"></div>
|
||||
<div id="coord-delete-buttons" class="ws-delete-modal-buttons">
|
||||
<button
|
||||
id="coord-delete-cancel-btn"
|
||||
type="button"
|
||||
onclick="cancelCoordDelete()"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
id="coord-delete-confirm-btn"
|
||||
class="ws-delete-confirm"
|
||||
type="button"
|
||||
onclick="confirmCoordDelete()"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/static/admin.js"></script>
|
||||
<script src="/static/governance.js"></script>
|
||||
<script src="/static/app.js"></script>
|
||||
|
||||
@@ -88,27 +88,45 @@
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.home-composer-banner {
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--yellow);
|
||||
border-left-width: 3px;
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 8px 10px;
|
||||
color: var(--fg-bright);
|
||||
font-size: 12px;
|
||||
}
|
||||
.home-composer-banner a {
|
||||
color: var(--accent);
|
||||
text-decoration: underline;
|
||||
text-decoration-thickness: 2px;
|
||||
}
|
||||
|
||||
.home-composer-error {
|
||||
/* Always rendered (no display toggle in JS) so toggling validation
|
||||
messages doesn't reflow the active-coordinators list below. The
|
||||
min-height holds a single 12px line + padding so an empty state
|
||||
reserves the same space the rendered error will occupy. */
|
||||
min-height: 20px;
|
||||
color: var(--red);
|
||||
font-size: 12px;
|
||||
padding: 4px 2px 0;
|
||||
}
|
||||
|
||||
/* Drag-over feedback for the home coord composer — wired by Composer's
|
||||
dragDrop option (dropClass: home-coord-drop) on the composer mount.
|
||||
Mirrors the dashed-outline affordance on coord-main so users see the
|
||||
same drop visual the in-coord composer uses. */
|
||||
#home-coord-composer-mount {
|
||||
position: relative;
|
||||
}
|
||||
#home-coord-composer-mount.home-coord-drop {
|
||||
outline: 2px dashed var(--accent);
|
||||
outline-offset: -6px;
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
/* Cap chip filename width inside the home composer so a long-name
|
||||
attachment doesn't push the strip wider than the textarea or wrap
|
||||
unpredictably across multiple rows. shared_static/chat.css defines
|
||||
.composer-chip / .composer-chip-size / .composer-chip-remove but
|
||||
leaves .composer-chip-name unstyled — the span just inherits the
|
||||
.composer-chip font with no width cap, fine for chat-pane width but
|
||||
too loose for the narrower home column. Apply the same ellipsis
|
||||
cap the .msg-user-attach-pill rule uses on the user bubble. */
|
||||
#home-coord-composer-mount .composer-chip-name {
|
||||
max-width: 200px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.home-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -133,6 +151,17 @@
|
||||
font-size: 11px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
/* Right-align action buttons (e.g. Saved Coordinators "Delete") inside
|
||||
.home-section-title without breaking the count's natural left position. */
|
||||
.home-section-title .home-section-action {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
/* Saved Coordinators reuses the existing .pagination control (see the
|
||||
"Pagination" block below) — the visible page is capped at
|
||||
COORD_PAGE_SIZE so Select-All fan-out is bounded. Pagination is
|
||||
hidden in delete mode and when there's only one page (see
|
||||
_renderCoordPagination). */
|
||||
|
||||
.home-coord-list {
|
||||
border: 1px solid var(--border);
|
||||
@@ -2306,15 +2335,15 @@ textarea.skill-content-area {
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Judge sub-section tabs
|
||||
Admin sub-section tabs (used by Judge + Models tabs)
|
||||
========================================================================== */
|
||||
.judge-section-switcher {
|
||||
.admin-subtab-switcher {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin: 12px 0 16px;
|
||||
border-bottom: 1px solid var(--border-strong);
|
||||
}
|
||||
.judge-section-btn {
|
||||
.admin-subtab-btn {
|
||||
padding: 6px 14px;
|
||||
background: none;
|
||||
border: none;
|
||||
@@ -2327,14 +2356,14 @@ textarea.skill-content-area {
|
||||
color 0.15s,
|
||||
border-color 0.15s;
|
||||
}
|
||||
.judge-section-btn:hover {
|
||||
.admin-subtab-btn:hover {
|
||||
color: var(--fg);
|
||||
}
|
||||
.judge-section-btn.active {
|
||||
.admin-subtab-btn.active {
|
||||
border-bottom-color: var(--accent);
|
||||
color: var(--fg);
|
||||
}
|
||||
.judge-section-btn:focus-visible {
|
||||
.admin-subtab-btn:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
@@ -3797,6 +3826,64 @@ textarea.skill-content-area {
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
/* Models → Roles sub-tab rows */
|
||||
.model-role-row {
|
||||
padding: 12px 0;
|
||||
border-bottom: 1px solid var(--border-strong);
|
||||
}
|
||||
.model-role-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
.model-role-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.model-role-label {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--fg);
|
||||
}
|
||||
.model-role-desc {
|
||||
font-size: 11px;
|
||||
color: var(--fg-dim);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.model-role-controls {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(240px, 1fr) minmax(180px, auto);
|
||||
column-gap: 16px;
|
||||
row-gap: 8px;
|
||||
align-items: end;
|
||||
}
|
||||
.model-role-control {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
.model-role-control-label {
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--fg-dim);
|
||||
}
|
||||
.model-role-control select {
|
||||
width: 100%;
|
||||
padding: 4px 8px;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border-strong);
|
||||
color: var(--fg);
|
||||
border-radius: 3px;
|
||||
font-family: var(--font-ui);
|
||||
font-size: 12px;
|
||||
}
|
||||
.model-role-control select:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
/* Modal section divider for field groups */
|
||||
.modal-section-divider {
|
||||
font-family: var(--font-ui);
|
||||
@@ -3844,7 +3931,7 @@ textarea.skill-content-area {
|
||||
.admin-btn-danger,
|
||||
.admin-btn-caution,
|
||||
.admin-btn-action,
|
||||
.judge-section-btn {
|
||||
.admin-subtab-btn {
|
||||
transition: none;
|
||||
}
|
||||
.settings-toggle-slider,
|
||||
|
||||
@@ -0,0 +1,268 @@
|
||||
"""Strategy interface for delivering child workstream lifecycle events.
|
||||
|
||||
Two implementations bind to the unified :class:`ChildrenRegistry`:
|
||||
|
||||
- :class:`SameNodeChildSource` — subscribes to a :class:`SessionManager`'s
|
||||
state-change callbacks. In-process, no transport. For interactive
|
||||
workstreams that spawn children locally (no cluster routing).
|
||||
- :class:`ClusterChildSource` — subscribes to a :class:`ClusterCollector`'s
|
||||
listener channel and runs a daemon thread that drains the queue and
|
||||
pushes events to the sink. For coordinator workstreams whose
|
||||
children are routed across the cluster by hash bucket.
|
||||
|
||||
The strategy doesn't translate events into UI-shaped payloads; that's
|
||||
the sink's job. This split keeps the strategy generic across kinds and
|
||||
lets the consumer (e.g. ``CoordinatorAdapter._dispatch_child_event``)
|
||||
own the per-kind translation.
|
||||
|
||||
Sink signature: ``Callable[[dict[str, Any]], None]``. The sink is
|
||||
responsible for filtering by registry membership; strategies push raw
|
||||
events without registry-side filtering so the sink can decide whether
|
||||
to act based on its own state.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import queue
|
||||
import threading
|
||||
from typing import TYPE_CHECKING, Any, Protocol
|
||||
|
||||
from turnstone.core.log import get_logger
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable, Iterable
|
||||
|
||||
from turnstone.core.children_registry import ChildrenRegistry
|
||||
from turnstone.core.workstream import WorkstreamState
|
||||
|
||||
log = get_logger(__name__)
|
||||
|
||||
|
||||
class ChildSource(Protocol):
|
||||
"""Subscription strategy for child workstream lifecycle events."""
|
||||
|
||||
def start(self, sink: Callable[[dict[str, Any]], None]) -> None:
|
||||
"""Begin delivering events to ``sink``. Idempotent."""
|
||||
|
||||
def shutdown(self) -> None:
|
||||
"""Stop the strategy. Idempotent; safe to call multiple times."""
|
||||
|
||||
|
||||
class _CollectorProtocol(Protocol):
|
||||
"""Subset of :class:`ClusterCollector` that :class:`ClusterChildSource` consumes.
|
||||
|
||||
Defined here (not imported) to keep ``turnstone/core/`` free of
|
||||
``turnstone/console/`` imports AND to let test fakes satisfy the
|
||||
type signature without subclassing the real collector.
|
||||
"""
|
||||
|
||||
def get_snapshot_and_register(self, q: queue.Queue[dict[str, Any]]) -> dict[str, Any]:
|
||||
"""Register ``q`` and return the current snapshot."""
|
||||
|
||||
def unregister_listener(self, q: queue.Queue[dict[str, Any]]) -> None:
|
||||
"""Drop ``q`` from the listener set."""
|
||||
|
||||
|
||||
class _ManagerProtocol(Protocol):
|
||||
"""Subset of :class:`SessionManager` that :class:`SameNodeChildSource` consumes.
|
||||
|
||||
Lets test fakes participate in the strategy's typed surface
|
||||
without forcing a full SessionManager construction.
|
||||
"""
|
||||
|
||||
def subscribe_to_state(self, callback: Callable[[str, WorkstreamState], None]) -> None:
|
||||
"""Register ``callback`` for state-change events."""
|
||||
|
||||
def unsubscribe_from_state(self, callback: Callable[[str, WorkstreamState], None]) -> None:
|
||||
"""Remove a previously-registered ``callback``."""
|
||||
|
||||
|
||||
class SameNodeChildSource:
|
||||
"""In-process child events via :class:`SessionManager` state observer.
|
||||
|
||||
Subscribes to the manager's state-change callbacks (registered via
|
||||
:meth:`SessionManager.subscribe_to_state`). For each transition on
|
||||
a workstream that's a known child (per the
|
||||
:class:`ChildrenRegistry` reverse index), synthesises a
|
||||
cluster-state-shaped event and pushes it to the sink.
|
||||
|
||||
Used by interactive workstreams when they gain spawn capability —
|
||||
children live on the same node as the parent, so no cluster routing
|
||||
is needed and event fan-out is in-process.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
manager: _ManagerProtocol,
|
||||
registry: ChildrenRegistry,
|
||||
) -> None:
|
||||
self._manager = manager
|
||||
self._registry = registry
|
||||
self._sink: Callable[[dict[str, Any]], None] | None = None
|
||||
self._callback: Callable[[str, WorkstreamState], None] | None = None
|
||||
|
||||
def start(self, sink: Callable[[dict[str, Any]], None]) -> None:
|
||||
if self._callback is not None:
|
||||
return # idempotent — already started
|
||||
self._sink = sink
|
||||
|
||||
def _on_state(ws_id: str, state: WorkstreamState) -> None:
|
||||
sink_fn = self._sink
|
||||
if sink_fn is None:
|
||||
return
|
||||
# Cheap pre-filter: skip dispatch for transitions on
|
||||
# workstreams that aren't children of any in-memory parent.
|
||||
# ``has_children`` is a lock-free dict-truthiness read; the
|
||||
# ``parent_for`` call below would otherwise acquire the
|
||||
# registry lock on every state change even when no
|
||||
# children exist (the steady state for an interactive
|
||||
# manager). The cluster strategy can't pre-filter because
|
||||
# the collector queue carries all events; here we have the
|
||||
# information to skip the synthesis entirely.
|
||||
if not self._registry.has_children():
|
||||
return
|
||||
if self._registry.parent_for(ws_id) is None:
|
||||
return
|
||||
# ``pending_approval_detail`` deliberately omitted — the
|
||||
# field was removed from cluster_state end-to-end in the
|
||||
# Stage 3 cleanup pass. Approval items arrive via bulk
|
||||
# fetch; verdicts via the explicit intent_verdict event;
|
||||
# resolution via approval_resolved.
|
||||
event = {
|
||||
"type": "cluster_state",
|
||||
"ws_id": ws_id,
|
||||
"state": state.value,
|
||||
"node_id": "",
|
||||
"tokens": 0,
|
||||
"activity_state": "",
|
||||
}
|
||||
try:
|
||||
sink_fn(event)
|
||||
except Exception:
|
||||
log.debug("same_node_child_source.sink_failed", exc_info=True)
|
||||
|
||||
self._callback = _on_state
|
||||
self._manager.subscribe_to_state(_on_state)
|
||||
|
||||
def shutdown(self) -> None:
|
||||
cb = self._callback
|
||||
if cb is None:
|
||||
return
|
||||
try:
|
||||
self._manager.unsubscribe_from_state(cb)
|
||||
except Exception:
|
||||
log.debug("same_node_child_source.unsubscribe_failed", exc_info=True)
|
||||
self._callback = None
|
||||
self._sink = None
|
||||
|
||||
|
||||
class ClusterChildSource:
|
||||
"""Cross-node child events via :class:`ClusterCollector` subscription.
|
||||
|
||||
Refactor of the existing fan-out machinery from
|
||||
``CoordinatorAdapter`` (was ``_collector_queue`` +
|
||||
``_fanout_thread`` + ``_fanout_loop``). Subscribes as a listener on
|
||||
the collector's broadcast channel and runs a daemon thread that
|
||||
drains the queue, pushing each event to the sink.
|
||||
|
||||
On :meth:`start`, also primes the registry from the collector's
|
||||
snapshot so a parent that re-installs after a console restart sees
|
||||
its already-live children without waiting for the next state tick.
|
||||
The ``parents_provider`` callback returns the set of in-memory
|
||||
parent ws_ids for snapshot filtering — only children whose parent
|
||||
is currently installed get merged.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
collector: _CollectorProtocol,
|
||||
registry: ChildrenRegistry,
|
||||
*,
|
||||
parents_provider: Callable[[], Iterable[str]],
|
||||
) -> None:
|
||||
self._collector = collector
|
||||
self._registry = registry
|
||||
self._parents_provider = parents_provider
|
||||
self._sink: Callable[[dict[str, Any]], None] | None = None
|
||||
self._queue: queue.Queue[dict[str, Any]] | None = None
|
||||
self._thread: threading.Thread | None = None
|
||||
self._stop = threading.Event()
|
||||
|
||||
def start(self, sink: Callable[[dict[str, Any]], None]) -> None:
|
||||
if self._thread is not None and self._thread.is_alive():
|
||||
return # idempotent — already started
|
||||
self._sink = sink
|
||||
self._queue = queue.Queue(maxsize=1000)
|
||||
snapshot = self._collector.get_snapshot_and_register(self._queue)
|
||||
self._prime_from_snapshot(snapshot)
|
||||
self._stop.clear()
|
||||
t = threading.Thread(
|
||||
target=self._loop,
|
||||
name="cluster-child-source",
|
||||
daemon=True,
|
||||
)
|
||||
self._thread = t
|
||||
t.start()
|
||||
|
||||
def shutdown(self) -> None:
|
||||
self._stop.set()
|
||||
t = self._thread
|
||||
q = self._queue
|
||||
coll = self._collector
|
||||
self._thread = None
|
||||
self._queue = None
|
||||
if coll is not None and q is not None:
|
||||
try:
|
||||
coll.unregister_listener(q)
|
||||
except Exception:
|
||||
log.debug(
|
||||
"cluster_child_source.unregister_listener_failed",
|
||||
exc_info=True,
|
||||
)
|
||||
if t is not None:
|
||||
t.join(timeout=2.0)
|
||||
self._sink = None
|
||||
|
||||
def _loop(self) -> None:
|
||||
q = self._queue
|
||||
if q is None:
|
||||
return
|
||||
while not self._stop.is_set():
|
||||
try:
|
||||
event = q.get(timeout=1.0)
|
||||
except queue.Empty:
|
||||
continue
|
||||
sink = self._sink
|
||||
if sink is None:
|
||||
continue
|
||||
try:
|
||||
sink(event)
|
||||
except Exception:
|
||||
log.debug("cluster_child_source.dispatch_failed", exc_info=True)
|
||||
|
||||
def _prime_from_snapshot(self, snapshot: dict[str, Any]) -> None:
|
||||
"""Populate the registry from a collector snapshot.
|
||||
|
||||
For every workstream in the snapshot whose ``parent_ws_id``
|
||||
names a currently-installed parent (per ``parents_provider``),
|
||||
merge it into the registry. Caller-installed parents that
|
||||
appear in the snapshot are seeded; unknown parents are skipped
|
||||
— they'll be picked up by the live fan-out path once their
|
||||
``ws_created`` event arrives.
|
||||
"""
|
||||
nodes = snapshot.get("nodes", []) if isinstance(snapshot, dict) else []
|
||||
if not nodes:
|
||||
return
|
||||
known_parents = set(self._parents_provider())
|
||||
if not known_parents:
|
||||
return
|
||||
by_parent: dict[str, list[str]] = {}
|
||||
for node in nodes:
|
||||
for entry in node.get("workstreams", []) or []:
|
||||
parent = entry.get("parent_ws_id") or ""
|
||||
child_id = entry.get("id") or ""
|
||||
if not parent or not child_id or parent not in known_parents:
|
||||
continue
|
||||
by_parent.setdefault(parent, []).append(child_id)
|
||||
for parent, kids in by_parent.items():
|
||||
self._registry.merge_children(parent, kids)
|
||||
@@ -0,0 +1,177 @@
|
||||
"""Universal parent → children registry for SessionManager.
|
||||
|
||||
Pure data + lookups; no IO, no transport. Lifted from
|
||||
:class:`turnstone.console.coordinator_adapter.CoordinatorAdapter` where
|
||||
it lived bound to the coordinator kind. The lift is what lets the
|
||||
``ChildSource`` strategies (Step 2) plug into a single shared primitive
|
||||
regardless of whether children are local (interactive) or cluster-routed
|
||||
(coordinator).
|
||||
|
||||
Storage rebuild and snapshot priming happen in the caller — typically
|
||||
the ``ChildSource`` implementation that owns the relevant transport.
|
||||
The registry exposes :meth:`merge_children` for bulk seeding so callers
|
||||
that compute child id lists from any source can feed them in without
|
||||
the registry needing to know about storage shapes or collector
|
||||
snapshots.
|
||||
|
||||
Threading: every public method is internally locked. Helpers suffixed
|
||||
``_locked`` require the caller to already hold :attr:`_lock`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Iterable
|
||||
|
||||
|
||||
class ChildrenRegistry:
|
||||
"""Tracks parent → children + reverse lookup for in-memory parents.
|
||||
|
||||
The forward index (``_children``) is parent_ws_id → set of child ws_ids.
|
||||
The reverse index (``_child_to_parent``) is child_ws_id → parent_ws_id.
|
||||
The presence map (``_active``) is parent_ws_id → UI ref, used by the
|
||||
dispatch path to atomically check-and-route in one lock acquisition.
|
||||
|
||||
Closed / deleted children stay in the registry until their owning
|
||||
parent is uninstalled — the tree UI keeps rendering them grayed out;
|
||||
state authority lives in storage, not here.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._children: dict[str, set[str]] = {}
|
||||
self._child_to_parent: dict[str, str] = {}
|
||||
self._lock = threading.Lock()
|
||||
self._active: dict[str, Any] = {}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Lifecycle — install / uninstall a parent
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def install(self, parent_ws_id: str, ui: Any) -> None:
|
||||
"""Seed the forward set + presence map for a new parent.
|
||||
|
||||
Idempotent — re-installing re-points the UI but leaves the
|
||||
existing child set intact. Mirrors the original
|
||||
``_install_coord_registry`` semantics so a coordinator that
|
||||
rehydrates after a crash doesn't lose its known-children.
|
||||
"""
|
||||
with self._lock:
|
||||
self._children.setdefault(parent_ws_id, set())
|
||||
self._active[parent_ws_id] = ui
|
||||
|
||||
def uninstall(self, parent_ws_id: str) -> None:
|
||||
"""Drop a parent: forward set, reverse-index entries, presence.
|
||||
|
||||
No-op if the parent is unknown. Used by close / eviction paths.
|
||||
"""
|
||||
with self._lock:
|
||||
self._uninstall_locked(parent_ws_id)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Mutation — register children under a parent
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def add_child(self, parent_ws_id: str, child_ws_id: str) -> Any | None:
|
||||
"""Register a child under a parent. Returns parent's UI or None.
|
||||
|
||||
Returns the parent's UI on success (so the dispatch path can
|
||||
atomically check-and-route in one lock acquisition). Returns
|
||||
``None`` if the parent isn't installed (concurrent close /
|
||||
eviction) or if the child is already registered (duplicate
|
||||
ws_created from the cluster fan-out).
|
||||
"""
|
||||
with self._lock:
|
||||
ui = self._active.get(parent_ws_id)
|
||||
if ui is None:
|
||||
return None
|
||||
existing = self._children.setdefault(parent_ws_id, set())
|
||||
if child_ws_id in existing:
|
||||
return None
|
||||
existing.add(child_ws_id)
|
||||
self._child_to_parent[child_ws_id] = parent_ws_id
|
||||
return ui
|
||||
|
||||
def merge_children(self, parent_ws_id: str, child_ws_ids: Iterable[str]) -> None:
|
||||
"""Bulk-merge child_ids under a parent. Idempotent.
|
||||
|
||||
Sole bulk write-path — used by both storage-seeded rebuilds and
|
||||
snapshot-seeded priming so reverse-index ordering invariants
|
||||
hold regardless of which seed source races first.
|
||||
"""
|
||||
with self._lock:
|
||||
self._merge_locked(parent_ws_id, child_ws_ids)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Lookups
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def parent_for(self, child_ws_id: str) -> str | None:
|
||||
"""Reverse lookup: which parent owns this child? O(1)."""
|
||||
with self._lock:
|
||||
return self._child_to_parent.get(child_ws_id)
|
||||
|
||||
def has_children(self) -> bool:
|
||||
"""Lock-free fast path: any child registered under any parent?
|
||||
|
||||
Reads ``bool(self._child_to_parent)`` without taking the lock.
|
||||
Dict-truthiness is a single GIL-atomic read, so callers on
|
||||
the hot state-broadcast path can short-circuit without paying
|
||||
the lock acquisition when the registry is empty (the steady
|
||||
state for an interactive manager today). The answer is best-
|
||||
effort — if a child is added concurrently with the read the
|
||||
caller may falsely return ``False``, but the next state event
|
||||
will pick up the change correctly.
|
||||
"""
|
||||
return bool(self._child_to_parent)
|
||||
|
||||
def children_of(self, parent_ws_id: str) -> list[str]:
|
||||
"""Snapshot copy of the parent's child ws_ids.
|
||||
|
||||
Returned list is a copy so callers can iterate without holding
|
||||
the registry lock during per-child work. A mutation racing with
|
||||
the snapshot either lands before (included) or after (excluded)
|
||||
— both outcomes are safe for cascade-style dispatch.
|
||||
"""
|
||||
with self._lock:
|
||||
child_set = self._children.get(parent_ws_id)
|
||||
return list(child_set) if child_set else []
|
||||
|
||||
def ui_for(self, parent_ws_id: str) -> Any | None:
|
||||
"""Look up the UI registered for a parent."""
|
||||
with self._lock:
|
||||
return self._active.get(parent_ws_id)
|
||||
|
||||
def parents(self) -> list[str]:
|
||||
"""Snapshot copy of installed parent ws_ids."""
|
||||
with self._lock:
|
||||
return list(self._active)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Locked helpers — caller must hold ``self._lock``
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _merge_locked(self, parent_ws_id: str, child_ws_ids: Iterable[str]) -> None:
|
||||
"""Idempotent merge under caller's lock. Empty/falsy ids skipped."""
|
||||
existing = self._children.setdefault(parent_ws_id, set())
|
||||
for cid in child_ws_ids:
|
||||
if cid and cid not in existing:
|
||||
existing.add(cid)
|
||||
self._child_to_parent[cid] = parent_ws_id
|
||||
|
||||
def _uninstall_locked(self, parent_ws_id: str) -> None:
|
||||
"""Pop forward set + presence + own reverse-index entries.
|
||||
|
||||
Defensive: only clears reverse entries that still point at
|
||||
``parent_ws_id``. Schema-shaped reassignments (rare but
|
||||
possible) shouldn't orphan the new owner's entry.
|
||||
"""
|
||||
child_set = self._children.pop(parent_ws_id, None)
|
||||
self._active.pop(parent_ws_id, None)
|
||||
if child_set is None:
|
||||
return
|
||||
for cid in child_set:
|
||||
if self._child_to_parent.get(cid) == parent_ws_id:
|
||||
self._child_to_parent.pop(cid, None)
|
||||
@@ -75,6 +75,15 @@ _EXPLICIT_SCRUB: frozenset[str] = frozenset(
|
||||
"GOOGLE_APPLICATION_CREDENTIALS",
|
||||
"DATABASE_URL", # conventional name (Heroku, Railway, etc.) — kept for defence-in-depth
|
||||
"TURNSTONE_DB_URL",
|
||||
# Tool-config env vars whose target files can directly load
|
||||
# executable directives (preprocessor commands, pagers, etc.).
|
||||
# Defence-in-depth alongside the on-CLI ``--no-config`` we pass
|
||||
# to ripgrep — if a future caller forgets that flag, an attacker
|
||||
# who can set one of these can plant a config that runs commands.
|
||||
"RIPGREP_CONFIG_PATH",
|
||||
"GIT_CONFIG",
|
||||
"GIT_CONFIG_GLOBAL",
|
||||
"GIT_CONFIG_SYSTEM",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
"""Shared history-replay decoration helpers.
|
||||
|
||||
Both surfaces that build a history wire payload — interactive's SSE
|
||||
``_build_history`` and the lifted ``make_history_handler`` REST
|
||||
endpoint — need the same audit-trail data attached to each
|
||||
``tool_calls`` entry: the persisted intent verdict (``intent_verdicts``
|
||||
table) and the output-guard assessment (``output_assessments`` table).
|
||||
|
||||
Centralising the lookup + decoration here keeps the two surfaces from
|
||||
drifting on which fields ship to the client and how they're shaped.
|
||||
The shared helpers also let us project only the fields the UI actually
|
||||
renders, dropping redundant ones (``call_id``/``func_name`` already
|
||||
carried on ``tc.id``/``tc.name``) so the wire payload stays tight.
|
||||
|
||||
All functions are pure I/O or pure transforms — safe to call from
|
||||
either an async caller (via ``asyncio.to_thread``) or a sync hook.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from turnstone.core.log import get_logger
|
||||
|
||||
log = get_logger(__name__)
|
||||
|
||||
# Tool results are clamped at this length per row at storage time
|
||||
# (see ``session.py``'s ``store_text = raw_output[:TOOL_RESULT_STORAGE_CAP]``).
|
||||
# Keeping the constant here lets the truncation flag detection in
|
||||
# ``decorate_history_messages`` stay in sync without a magic number
|
||||
# duplicated across server.py / session.py.
|
||||
#
|
||||
# Raised from 2000 → 10000 because a 2000-char clip routinely cut
|
||||
# the body of a single grep / file read mid-line, leaving the
|
||||
# historical record useless for retrospective debugging. FTS5
|
||||
# index + row size grow proportionally; the per-tool upper bound is
|
||||
# still bounded upstream by ``_truncate_output``'s context-budget
|
||||
# clamp (so a single huge result can't blow past the live context
|
||||
# window).
|
||||
TOOL_RESULT_STORAGE_CAP = 10000
|
||||
|
||||
|
||||
def load_verdict_indexes(
|
||||
ws_id: str,
|
||||
) -> tuple[dict[str, dict[str, Any]], dict[str, dict[str, Any]]]:
|
||||
"""Bulk-load intent verdicts and output assessments for a workstream.
|
||||
|
||||
Returns ``(verdicts_by_call_id, assessments_by_call_id)``. Both
|
||||
tables are indexed by ws_id so the queries are O(rows-for-ws); the
|
||||
DESC ordering plus first-seen-wins dedupe leaves the newest
|
||||
verdict per call_id (LLM upgrade beats heuristic when both exist).
|
||||
|
||||
Pure storage I/O — safe to run in ``asyncio.to_thread`` from an
|
||||
async caller. Returns empty dicts when storage is unavailable or
|
||||
the lookup raises (best-effort: replay must never block on
|
||||
audit-trail decoration).
|
||||
"""
|
||||
verdicts_by_call_id: dict[str, dict[str, Any]] = {}
|
||||
assessments_by_call_id: dict[str, dict[str, Any]] = {}
|
||||
if not ws_id:
|
||||
return verdicts_by_call_id, assessments_by_call_id
|
||||
try:
|
||||
from turnstone.core.storage._registry import get_storage
|
||||
|
||||
storage = get_storage()
|
||||
if storage is None:
|
||||
return verdicts_by_call_id, assessments_by_call_id
|
||||
for v in storage.list_intent_verdicts(ws_id=ws_id, limit=10000):
|
||||
cid = v.get("call_id") or ""
|
||||
if cid and cid not in verdicts_by_call_id:
|
||||
verdicts_by_call_id[cid] = v
|
||||
for a in storage.list_output_assessments(ws_id=ws_id, limit=10000):
|
||||
cid = a.get("call_id") or ""
|
||||
if cid and cid not in assessments_by_call_id:
|
||||
assessments_by_call_id[cid] = a
|
||||
except Exception:
|
||||
# Missing storage / migration drift / driver error must not
|
||||
# block replay — degrade to an unannotated history.
|
||||
log.debug(
|
||||
"verdict/assessment lookup failed; replay continues unannotated",
|
||||
exc_info=True,
|
||||
)
|
||||
return verdicts_by_call_id, assessments_by_call_id
|
||||
|
||||
|
||||
def build_verdict_payload(vrow: dict[str, Any]) -> dict[str, Any] | None:
|
||||
"""Project a stored ``intent_verdicts`` row into the wire shape.
|
||||
|
||||
Returns ``None`` when the verdict is the unflagged baseline
|
||||
(``risk_level == "none"``) — the client's ``renderVerdictBadge``
|
||||
helper would suppress those anyway, so skipping at the wire layer
|
||||
keeps the payload tight on long workstreams.
|
||||
|
||||
Drops ``call_id`` and ``func_name`` from the wire payload — they're
|
||||
already carried on the parent ``tc.id`` / ``tc.name`` fields.
|
||||
Ships ``reasoning`` for either tier when the row has non-empty
|
||||
prose (heuristic rules in this project DO write meaningful
|
||||
rationales — e.g. ``policy.py`` emits structured reasoning per
|
||||
matched pattern). ``judge_model`` rides through so the batch tier
|
||||
badge can render ``⚖ llm:claude-haiku-4`` on history-only batches
|
||||
rather than the bare ``⚖ llm`` label.
|
||||
"""
|
||||
if (vrow.get("risk_level") or "none") == "none":
|
||||
return None
|
||||
payload: dict[str, Any] = {
|
||||
"risk_level": vrow.get("risk_level", "medium"),
|
||||
"recommendation": vrow.get("recommendation", "review"),
|
||||
"confidence": vrow.get("confidence", 0.0),
|
||||
"intent_summary": vrow.get("intent_summary", ""),
|
||||
"tier": vrow.get("tier", "heuristic"),
|
||||
}
|
||||
if vrow.get("reasoning"):
|
||||
payload["reasoning"] = vrow.get("reasoning", "")
|
||||
judge_model = vrow.get("judge_model") or ""
|
||||
if judge_model:
|
||||
payload["judge_model"] = judge_model
|
||||
return payload
|
||||
|
||||
|
||||
def build_output_assessment_payload(arow: dict[str, Any]) -> dict[str, Any] | None:
|
||||
"""Project a stored ``output_assessments`` row into the wire shape.
|
||||
|
||||
Returns ``None`` when the assessment is the unflagged baseline
|
||||
(``risk_level == "none"``) — same skip-on-clean pattern as
|
||||
:func:`build_verdict_payload`.
|
||||
|
||||
Decodes ``flags`` from its JSON string form here so the client
|
||||
never has to parse twice. Falls back to an empty list on bad JSON
|
||||
rather than raising — the rest of the assessment is still useful.
|
||||
"""
|
||||
if (arow.get("risk_level") or "none") == "none":
|
||||
return None
|
||||
flags_raw = arow.get("flags") or "[]"
|
||||
try:
|
||||
flags = json.loads(flags_raw) if isinstance(flags_raw, str) else flags_raw
|
||||
except (ValueError, TypeError):
|
||||
flags = []
|
||||
return {
|
||||
"risk_level": arow.get("risk_level", "none"),
|
||||
"flags": flags if isinstance(flags, list) else [],
|
||||
"redacted": bool(arow.get("redacted", 0)),
|
||||
}
|
||||
|
||||
|
||||
def decorate_tool_call(
|
||||
tc: dict[str, Any],
|
||||
verdicts_by_call_id: dict[str, dict[str, Any]],
|
||||
assessments_by_call_id: dict[str, dict[str, Any]],
|
||||
) -> None:
|
||||
"""Mutate ``tc`` in place, attaching ``verdict`` / ``output_assessment``.
|
||||
|
||||
Works on either tool_call shape:
|
||||
- OpenAI format (``{id, function: {name, arguments}}``) — used by
|
||||
``/history`` REST.
|
||||
- Flattened format (``{id, name, arguments}``) — used by SSE replay.
|
||||
|
||||
Both carry ``id`` at the top level, which is the only field this
|
||||
helper reads. No-ops cleanly when the call_id has no matching
|
||||
row (unflagged tools stay clean).
|
||||
"""
|
||||
call_id = tc.get("id", "") or ""
|
||||
if not call_id:
|
||||
return
|
||||
vrow = verdicts_by_call_id.get(call_id)
|
||||
if vrow is not None:
|
||||
verdict = build_verdict_payload(vrow)
|
||||
if verdict is not None:
|
||||
tc["verdict"] = verdict
|
||||
arow = assessments_by_call_id.get(call_id)
|
||||
if arow is not None:
|
||||
assessment = build_output_assessment_payload(arow)
|
||||
if assessment is not None:
|
||||
tc["output_assessment"] = assessment
|
||||
|
||||
|
||||
def decorate_history_messages(
|
||||
messages: list[dict[str, Any]],
|
||||
verdicts_by_call_id: dict[str, dict[str, Any]],
|
||||
assessments_by_call_id: dict[str, dict[str, Any]],
|
||||
) -> None:
|
||||
"""Mutate a list of OpenAI-format messages, decorating tool_calls.
|
||||
|
||||
Used by the ``/history`` REST endpoint after ``load_messages``
|
||||
returns. For each assistant message with ``tool_calls``, runs
|
||||
:func:`decorate_tool_call` on every entry. For each tool message
|
||||
whose content hits the storage cap, sets ``truncated: True`` so
|
||||
the client can render the "… truncated in storage" pill.
|
||||
|
||||
Pure transform — no I/O. Async callers should pre-load the
|
||||
indexes via :func:`load_verdict_indexes` (in ``to_thread``) and
|
||||
pass them in.
|
||||
"""
|
||||
for msg in messages:
|
||||
role = msg.get("role")
|
||||
if role == "assistant":
|
||||
tcs = msg.get("tool_calls")
|
||||
if isinstance(tcs, list):
|
||||
for tc in tcs:
|
||||
if isinstance(tc, dict):
|
||||
decorate_tool_call(tc, verdicts_by_call_id, assessments_by_call_id)
|
||||
elif role == "tool":
|
||||
content = msg.get("content")
|
||||
if isinstance(content, str) and len(content) >= TOOL_RESULT_STORAGE_CAP:
|
||||
msg["truncated"] = True
|
||||
@@ -432,7 +432,7 @@ LAST_ERROR_CONFIG_KEY = "last_error"
|
||||
# such error per workstream would bloat workstream_config and the model
|
||||
# prompt the coord LLM ingests on inspect. 1024 chars matches the
|
||||
# practical "useful for triage" length while staying well under the
|
||||
# WAIT_MESSAGE_MAX_BYTES (6 KiB) cap so the truncate happens here at
|
||||
# WAIT_MESSAGE_MAX_BYTES (10 KiB) cap so the truncate happens here at
|
||||
# write time, not later at the wait surface.
|
||||
LAST_ERROR_MAX_LEN = 1024
|
||||
|
||||
@@ -757,6 +757,37 @@ def search_structured_memories(
|
||||
return []
|
||||
|
||||
|
||||
def list_visible_structured_memories(
|
||||
scopes: list[tuple[str, str]],
|
||||
mem_type: str = "",
|
||||
limit: int = 100,
|
||||
) -> list[dict[str, str]]:
|
||||
"""Single-query union across visible (scope, scope_id) pairs."""
|
||||
try:
|
||||
return get_storage().list_visible_structured_memories(
|
||||
scopes, mem_type=mem_type, limit=limit
|
||||
)
|
||||
except Exception:
|
||||
log.warning("Failed to list visible structured memories", exc_info=True)
|
||||
return []
|
||||
|
||||
|
||||
def search_visible_structured_memories(
|
||||
query: str,
|
||||
scopes: list[tuple[str, str]],
|
||||
mem_type: str = "",
|
||||
limit: int = 20,
|
||||
) -> list[dict[str, str]]:
|
||||
"""OR-of-terms search joined with a single visibility OR-group."""
|
||||
try:
|
||||
return get_storage().search_visible_structured_memories(
|
||||
query, scopes, mem_type=mem_type, limit=limit
|
||||
)
|
||||
except Exception:
|
||||
log.warning("Failed to search visible structured memories", exc_info=True)
|
||||
return []
|
||||
|
||||
|
||||
def touch_structured_memories(keys: list[tuple[str, str, str]]) -> int:
|
||||
"""Batch-touch memories (bump last_accessed, increment access_count).
|
||||
|
||||
|
||||
@@ -5,7 +5,50 @@ from __future__ import annotations
|
||||
import re
|
||||
import time
|
||||
|
||||
_COOLDOWN_SECS = 300 # 5 minutes between nudges of the same type
|
||||
# Default cooldown (s) between nudges of the same type. Production
|
||||
# paths pass ``cooldown_secs`` explicitly from
|
||||
# ``MemoryConfig.nudge_cooldown`` (config-store ``memory.nudge_cooldown``,
|
||||
# default 300); this constant is the fallback for tests and unit-style
|
||||
# callers without a ``MemoryConfig`` and is kept aligned with that
|
||||
# canonical default so both paths behave the same.
|
||||
_COOLDOWN_SECS = 300
|
||||
|
||||
# Repeat-detection threshold — number of *consecutive* identical tool
|
||||
# calls (same name + same arguments) before a repeat warning fires.
|
||||
# Two-in-a-row is too noisy because legitimate retries on transient
|
||||
# failures look identical; three-in-a-row is the cheapest signal that
|
||||
# the model is stuck on the same call.
|
||||
_REPEAT_THRESHOLD = 3
|
||||
|
||||
|
||||
class RepeatDetector:
|
||||
"""Detect a streak of identical tool-call signatures.
|
||||
|
||||
``record(sig)`` returns ``True`` once *sig* has been recorded
|
||||
``threshold`` times in a row (default 3). Recording a different
|
||||
signature resets the streak — interleaved tool calls aren't a
|
||||
stuck loop, only repeated identical ones are. After a fire, the
|
||||
caller is expected to call ``clear()`` to start a fresh streak.
|
||||
"""
|
||||
|
||||
def __init__(self, threshold: int = _REPEAT_THRESHOLD) -> None:
|
||||
self._threshold = threshold
|
||||
self._sig: str | None = None
|
||||
self._count = 0
|
||||
|
||||
def record(self, sig: str) -> bool:
|
||||
"""Record *sig*; return ``True`` when the streak hits the threshold."""
|
||||
if sig == self._sig:
|
||||
self._count += 1
|
||||
else:
|
||||
self._sig = sig
|
||||
self._count = 1
|
||||
return self._count >= self._threshold
|
||||
|
||||
def clear(self) -> None:
|
||||
self._sig = None
|
||||
self._count = 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Nudge messages (brief, model-facing hints)
|
||||
|
||||
@@ -32,6 +32,9 @@ class MetricsCollector:
|
||||
# counters (continued)
|
||||
self._ratelimit_rejects: int = 0 # counter: total 429 responses
|
||||
self._evictions: int = 0 # counter: workstreams evicted
|
||||
# node_models publish (heartbeat-loop refresh of node_metadata.models)
|
||||
self._node_models_publish_written: int = 0
|
||||
self._node_models_publish_skipped: int = 0
|
||||
# judge metrics
|
||||
self._judge_verdicts: dict[tuple[str, str], int] = defaultdict(int)
|
||||
self._judge_latency: dict[str, Any] = {
|
||||
@@ -104,6 +107,22 @@ class MetricsCollector:
|
||||
with self._lock:
|
||||
self._evictions += 1
|
||||
|
||||
def record_node_models_publish(self, *, written: bool) -> None:
|
||||
"""Record one heartbeat-loop attempt to refresh ``node_metadata.models``.
|
||||
|
||||
``written=True`` means the projected payload differed from the
|
||||
cached one and we ran an UPSERT. ``written=False`` means the
|
||||
cache short-circuited the call. In a stable cluster the
|
||||
skipped:written ratio runs ~100:1 — a sustained drop in that
|
||||
ratio is the signal an operator wants (backend health flapping
|
||||
or a runaway model-reload loop).
|
||||
"""
|
||||
with self._lock:
|
||||
if written:
|
||||
self._node_models_publish_written += 1
|
||||
else:
|
||||
self._node_models_publish_skipped += 1
|
||||
|
||||
def set_judge_enabled(self, enabled: bool) -> None:
|
||||
with self._lock:
|
||||
self._judge_enabled = enabled
|
||||
@@ -170,6 +189,8 @@ class MetricsCollector:
|
||||
judge_verdicts = dict(self._judge_verdicts)
|
||||
judge_latency = dict(self._judge_latency)
|
||||
judge_enabled = self._judge_enabled
|
||||
node_models_publish_written = self._node_models_publish_written
|
||||
node_models_publish_skipped = self._node_models_publish_skipped
|
||||
|
||||
# turnstone_build_info
|
||||
lines.append("# HELP turnstone_build_info Server version and model info")
|
||||
@@ -277,6 +298,25 @@ class MetricsCollector:
|
||||
evictions,
|
||||
)
|
||||
|
||||
# turnstone_node_models_publish_total — split by outcome so an
|
||||
# operator can compute hit-rate as
|
||||
# ``rate(skipped) / (rate(skipped) + rate(written))``. In a
|
||||
# stable cluster this ratio sits very close to 1.0; sustained
|
||||
# dips signal backend health flapping or reload churn.
|
||||
lines.append(
|
||||
"# HELP turnstone_node_models_publish_total "
|
||||
"node_metadata.models refresh attempts by outcome"
|
||||
)
|
||||
lines.append("# TYPE turnstone_node_models_publish_total counter")
|
||||
lines.append(
|
||||
f'turnstone_node_models_publish_total{{outcome="written"}} '
|
||||
f"{node_models_publish_written}"
|
||||
)
|
||||
lines.append(
|
||||
f'turnstone_node_models_publish_total{{outcome="skipped"}} '
|
||||
f"{node_models_publish_skipped}"
|
||||
)
|
||||
|
||||
# turnstone_judge_enabled
|
||||
gauge(
|
||||
"turnstone_judge_enabled",
|
||||
|
||||
@@ -44,6 +44,19 @@ class ModelConfig:
|
||||
server_compat: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
def _api_surface_of(cfg: ModelConfig) -> str | None:
|
||||
"""Extract the operator-pinned api_surface from *cfg*, or ``None``.
|
||||
|
||||
Used both at provider-cache lookup time and at reload-eviction time so the
|
||||
two sites stay in sync. Returns ``None`` when the field is absent, blank,
|
||||
or not a string — matching the "inherit provider default" semantics.
|
||||
"""
|
||||
raw = cfg.server_compat.get("api_surface") if isinstance(cfg.server_compat, dict) else None
|
||||
if isinstance(raw, str) and raw.strip():
|
||||
return raw
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Registry
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -127,7 +140,9 @@ class ModelRegistry:
|
||||
raise ValueError(f"Unknown model alias: {alias}")
|
||||
if alias not in self._providers:
|
||||
cfg = self._models[alias]
|
||||
self._providers[alias] = create_provider(cfg.provider)
|
||||
self._providers[alias] = create_provider(
|
||||
cfg.provider, api_surface=_api_surface_of(cfg)
|
||||
)
|
||||
return self._providers[alias]
|
||||
|
||||
def get_config(self, alias: str) -> ModelConfig:
|
||||
@@ -230,6 +245,7 @@ class ModelRegistry:
|
||||
if task_model and task_model not in models:
|
||||
raise ValueError(f"Task model '{task_model}' not found in registry")
|
||||
with self._client_lock:
|
||||
old_models = self._models
|
||||
self._models = dict(models)
|
||||
self.default = default
|
||||
self.fallback = list(fallback) if fallback else []
|
||||
@@ -238,11 +254,37 @@ class ModelRegistry:
|
||||
self.task_model = task_model
|
||||
self.plan_effort = plan_effort
|
||||
self.task_effort = task_effort
|
||||
for client in self._clients.values():
|
||||
if hasattr(client, "close"):
|
||||
client.close()
|
||||
self._clients.clear()
|
||||
self._providers.clear()
|
||||
# Selective teardown — close + drop only clients whose
|
||||
# connection target actually changed (alias removed, or
|
||||
# base_url / api_key / provider differs). Keeps connection
|
||||
# pools warm for the common admin-edit case where only
|
||||
# ``model`` / ``temperature`` / ``context_window`` changed.
|
||||
for alias, client in list(self._clients.items()):
|
||||
old_cfg = old_models.get(alias)
|
||||
new_cfg = self._models.get(alias)
|
||||
if (
|
||||
new_cfg is None
|
||||
or old_cfg is None
|
||||
or old_cfg.base_url != new_cfg.base_url
|
||||
or old_cfg.api_key != new_cfg.api_key
|
||||
or old_cfg.provider != new_cfg.provider
|
||||
):
|
||||
if hasattr(client, "close"):
|
||||
client.close()
|
||||
del self._clients[alias]
|
||||
# Providers are keyed on alias and depend on (cfg.provider,
|
||||
# cfg.server_compat["api_surface"]) — drop when either changes
|
||||
# or the alias was removed.
|
||||
for alias in list(self._providers.keys()):
|
||||
old_cfg = old_models.get(alias)
|
||||
new_cfg = self._models.get(alias)
|
||||
if (
|
||||
new_cfg is None
|
||||
or old_cfg is None
|
||||
or old_cfg.provider != new_cfg.provider
|
||||
or _api_surface_of(old_cfg) != _api_surface_of(new_cfg)
|
||||
):
|
||||
del self._providers[alias]
|
||||
|
||||
def shutdown(self) -> None:
|
||||
"""Close all cached client connections."""
|
||||
@@ -300,6 +342,7 @@ def load_model_registry(
|
||||
context_window: int = 32768,
|
||||
provider: str = "openai",
|
||||
storage: Any | None = None,
|
||||
strict: bool = False,
|
||||
) -> ModelRegistry:
|
||||
"""Build a ModelRegistry from CLI args, ``config.toml``, and database.
|
||||
|
||||
@@ -317,6 +360,15 @@ def load_model_registry(
|
||||
``[model].plan_effort``, ``[model].task_effort`` control routing.
|
||||
``plan_model``/``task_model`` override ``agent_model`` per sub-agent
|
||||
role; both fall back to it when unset.
|
||||
|
||||
``strict``: when True, a storage read failure during the DB-rows step
|
||||
re-raises instead of degrading to a config.toml-only registry.
|
||||
Callers that hot-reload an existing registry need this so a transient
|
||||
DB outage doesn't silently drop every DB-sourced alias when the
|
||||
truncated result is applied via ``ModelRegistry.reload``. Callers
|
||||
that build a fresh registry from scratch (CLI, lifespan startup) want
|
||||
the default behaviour — boot succeeds with a config-only fallback
|
||||
rather than crashing on a flaky DB.
|
||||
"""
|
||||
import json as _json
|
||||
|
||||
@@ -370,6 +422,8 @@ def load_model_registry(
|
||||
server_compat=row_server_compat,
|
||||
)
|
||||
except Exception:
|
||||
if strict:
|
||||
raise
|
||||
log.warning("Failed to load model definitions from storage", exc_info=True)
|
||||
|
||||
# 2. Build configs from [models.*] sections (overrides DB for same alias)
|
||||
|
||||
@@ -33,7 +33,9 @@ __all__ = [
|
||||
"lookup_model_capabilities",
|
||||
]
|
||||
|
||||
# Singleton instances (stateless, safe to share)
|
||||
# Singleton instances (stateless, safe to share). ``_openai_provider``
|
||||
# is reused for both cloud OpenAI and ``openai-compatible`` with
|
||||
# ``api_surface="responses"`` — see the ``create_provider`` docstring.
|
||||
_provider_lock = threading.Lock()
|
||||
_openai_provider = OpenAIResponsesProvider()
|
||||
_openai_compat_provider = OpenAIChatCompletionsProvider()
|
||||
@@ -41,12 +43,45 @@ _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."""
|
||||
_VALID_API_SURFACES = ("chat", "responses")
|
||||
|
||||
|
||||
def create_provider(
|
||||
provider_name: str,
|
||||
*,
|
||||
api_surface: str | None = None,
|
||||
) -> LLMProvider:
|
||||
"""Return a provider adapter for the given provider name. Thread-safe.
|
||||
|
||||
*api_surface* selects the OpenAI-compatible API surface for
|
||||
``provider_name="openai-compatible"``:
|
||||
|
||||
- ``"chat"`` (default) → Chat Completions (vLLM, llama.cpp, SGLang).
|
||||
- ``"responses"`` → Responses API (commercial OpenAI-compat
|
||||
endpoints like Mistral cloud, or local servers that expose the
|
||||
Responses surface).
|
||||
|
||||
Ignored for non-OpenAI providers. ``provider_name="openai"`` always
|
||||
uses the Responses API regardless of *api_surface*.
|
||||
|
||||
Note: the ``OpenAIResponsesProvider`` singleton is reused for both
|
||||
cloud OpenAI and ``openai-compatible`` + responses, so its
|
||||
``provider_name`` reports ``"openai"`` even when serving an
|
||||
openai-compatible config. Code that needs to distinguish the two
|
||||
must read ``ModelConfig.provider`` and ``server_compat["api_surface"]``
|
||||
rather than ``provider.provider_name``.
|
||||
"""
|
||||
global _anthropic_provider, _google_provider # noqa: PLW0603
|
||||
if provider_name == "openai":
|
||||
return _openai_provider
|
||||
if provider_name == "openai-compatible":
|
||||
normalised = (api_surface or "").strip().lower()
|
||||
if normalised and normalised not in _VALID_API_SURFACES:
|
||||
raise ValueError(
|
||||
f"Unknown api_surface: {api_surface!r}. Supported: {', '.join(_VALID_API_SURFACES)}"
|
||||
)
|
||||
if normalised == "responses":
|
||||
return _openai_provider
|
||||
return _openai_compat_provider
|
||||
if provider_name == "anthropic":
|
||||
with _provider_lock:
|
||||
|
||||
@@ -182,7 +182,7 @@ OPENAI_CAPABILITIES: dict[str, ModelCapabilities] = {
|
||||
}
|
||||
|
||||
# Default for unknown models (local servers: vLLM, llama.cpp, etc.)
|
||||
OPENAI_DEFAULT = ModelCapabilities(supports_tool_advisories=False)
|
||||
OPENAI_DEFAULT = ModelCapabilities()
|
||||
|
||||
|
||||
def lookup_openai_capabilities(model: str) -> ModelCapabilities:
|
||||
|
||||
@@ -86,7 +86,6 @@ class ModelCapabilities:
|
||||
supports_web_search: bool = False
|
||||
supports_tool_search: bool = False
|
||||
supports_vision: bool = False
|
||||
supports_tool_advisories: bool = True
|
||||
thinking_display: str = "" # "summarized" for models that omit thinking by default
|
||||
|
||||
|
||||
|
||||
@@ -1,14 +1,20 @@
|
||||
"""Server compatibility profiles for OpenAI-compatible backends.
|
||||
|
||||
Different local model servers (vLLM, llama.cpp, SGLang) need different
|
||||
request shaping. This module separates two concerns:
|
||||
request shaping. This module separates three concerns:
|
||||
|
||||
1. **Model capabilities** — ``thinking_mode`` and ``thinking_param`` are
|
||||
properties of the *model* (Gemma thinks, Llama doesn't). These go
|
||||
into the ``capabilities`` dict and flow through ``ModelCapabilities``
|
||||
so the provider can act on them (just like Anthropic's thinking mode).
|
||||
|
||||
2. **Server workarounds** — ``extra_body`` overrides like
|
||||
2. **API surface** — ``api_surface`` selects which OpenAI-compatible
|
||||
API surface the provider talks to: ``"chat"`` (Chat Completions,
|
||||
the default) or ``"responses"`` (Responses API, native reasoning).
|
||||
Stored under ``server_compat`` because it's an endpoint property,
|
||||
not a model property.
|
||||
|
||||
3. **Server workarounds** — ``extra_body`` overrides like
|
||||
``skip_special_tokens=false`` are properties of the *server* (vLLM
|
||||
bug workaround). These stay in ``server_compat`` and get merged
|
||||
into the request's ``extra_body`` at call time.
|
||||
@@ -82,6 +88,23 @@ _PROFILES: dict[str, dict[str, Any]] = {
|
||||
"server_type": "vllm",
|
||||
},
|
||||
},
|
||||
"vllm-mistral-medium": {
|
||||
# Mistral medium open-weights served by vLLM can deliver reasoning
|
||||
# via either surface, but the trade-off is asymmetric:
|
||||
# * Chat Completions — tool calling works (``--tool-call-parser
|
||||
# mistral``); reasoning is enabled via the vLLM CLI
|
||||
# (``--reasoning-parser``) rather than per-request.
|
||||
# * Responses API — reasoning effort is per-request and clean,
|
||||
# but as of vLLM 0.x the tool-call parser is not wired up on
|
||||
# this surface so tool calls leak as ``[TOOL_CALLS]`` text.
|
||||
# We do **not** auto-suggest this profile from Detect; an operator
|
||||
# who needs per-request effort and accepts the tool-calling
|
||||
# limitation can pick "Responses API" manually in the admin UI.
|
||||
"server_compat": {
|
||||
"server_type": "vllm",
|
||||
"api_surface": "responses",
|
||||
},
|
||||
},
|
||||
"vllm": {
|
||||
"server_compat": {
|
||||
"server_type": "vllm",
|
||||
@@ -125,6 +148,9 @@ _VLLM_MODEL_PROFILES: list[tuple[str, str]] = [
|
||||
("granite3", "vllm-granite-thinking"),
|
||||
("deepseek-r1", "vllm-deepseek-thinking"),
|
||||
("holo2", "vllm-holo-thinking"),
|
||||
# Mistral medium intentionally omitted — see ``vllm-mistral-medium``
|
||||
# profile docstring for the Chat-vs-Responses trade-off; operator
|
||||
# picks manually rather than letting Detect auto-suggest Responses.
|
||||
]
|
||||
|
||||
# llama.cpp model-family → profile key mapping.
|
||||
@@ -175,31 +201,39 @@ def suggest_profile(server_type: str, model_id: str) -> dict[str, Any]:
|
||||
|
||||
|
||||
def merge_server_compat(
|
||||
base_chat_template_kwargs: dict[str, Any],
|
||||
base_chat_template_kwargs: dict[str, Any] | None,
|
||||
server_compat: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
"""Build the ``extra_body`` dict by merging server compat into base kwargs.
|
||||
|
||||
*base_chat_template_kwargs* always contains at least ``reasoning_effort``.
|
||||
*server_compat* comes from ``ModelConfig.server_compat``.
|
||||
*base_chat_template_kwargs* is an explicit ``chat_template_kwargs`` dict
|
||||
to seed the request with, or ``None``/empty to skip seeding. Operator-
|
||||
supplied entries in ``server_compat["extra_body"]["chat_template_kwargs"]``
|
||||
are deep-merged on top. Top-level ``extra_body`` keys (``skip_special_tokens``,
|
||||
``reasoning_format``, etc.) are forwarded as-is.
|
||||
|
||||
Note: thinking-mode params (``enable_thinking``, ``thinking``) are **not**
|
||||
merged here — the provider handles those via ``ModelCapabilities``.
|
||||
This function only merges server workarounds from ``extra_body``.
|
||||
This function only merges what the operator stored in ``server_compat``.
|
||||
|
||||
Returns the complete dict to pass as ``extra_body`` to the OpenAI client.
|
||||
May be empty when there is nothing to send.
|
||||
"""
|
||||
extra: dict[str, Any] = {"chat_template_kwargs": dict(base_chat_template_kwargs)}
|
||||
extra: dict[str, Any] = {}
|
||||
if base_chat_template_kwargs:
|
||||
extra["chat_template_kwargs"] = dict(base_chat_template_kwargs)
|
||||
|
||||
# Merge top-level extra_body overrides (skip_special_tokens, etc.)
|
||||
compat_eb = server_compat.get("extra_body")
|
||||
if isinstance(compat_eb, dict):
|
||||
for key, value in compat_eb.items():
|
||||
if key == "chat_template_kwargs":
|
||||
# Deep-merge: operator values in extra_body win over the
|
||||
# base dict (which has reasoning_effort). This lets
|
||||
# operators intentionally extend chat_template_kwargs.
|
||||
# Deep-merge with operator values winning so an operator
|
||||
# can intentionally extend chat_template_kwargs (e.g. set
|
||||
# ``reasoning_effort`` for gpt-oss-style local templates).
|
||||
if isinstance(value, dict):
|
||||
if "chat_template_kwargs" not in extra:
|
||||
extra["chat_template_kwargs"] = {}
|
||||
extra["chat_template_kwargs"].update(value)
|
||||
continue
|
||||
extra[key] = value
|
||||
|
||||
+1038
-474
File diff suppressed because it is too large
Load Diff
@@ -13,6 +13,7 @@ import contextlib
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import TYPE_CHECKING, Any, Protocol
|
||||
|
||||
from turnstone.core.log import get_logger
|
||||
@@ -28,6 +29,22 @@ if TYPE_CHECKING:
|
||||
log = get_logger(__name__)
|
||||
|
||||
|
||||
# Maps each workstream kind to the ``services.service_type`` its hosting
|
||||
# process registers under. Used by ``SessionManager.close_idle`` pass 2
|
||||
# to enumerate live peer processes for orphan-reaper liveness scoping.
|
||||
# Server processes register as ``("server", node_id, ...)`` (see
|
||||
# ``turnstone/server.py``); the console process as ``("console",
|
||||
# "console", ...)`` (see ``turnstone/console/server.py``). Deriving from
|
||||
# kind here removes a duplicated-config footgun: any caller that builds
|
||||
# a ``SessionManager`` automatically gets the correct service_type for
|
||||
# its kind, with no risk of miswiring INTERACTIVE→"console" or vice
|
||||
# versa.
|
||||
_KIND_SERVICE_TYPE: dict[WorkstreamKind, str] = {
|
||||
WorkstreamKind.INTERACTIVE: "server",
|
||||
WorkstreamKind.COORDINATOR: "console",
|
||||
}
|
||||
|
||||
|
||||
class SessionKindAdapter(Protocol):
|
||||
"""Per-kind construction + cleanup policies the shared ``SessionManager`` delegates to.
|
||||
|
||||
@@ -163,6 +180,7 @@ class SessionManager:
|
||||
node_id: str | None = None,
|
||||
state_writer: StateWriter | None = None,
|
||||
event_emitter: SessionEventEmitter | None = None,
|
||||
model_validator: Callable[[str], bool] | None = None,
|
||||
) -> None:
|
||||
if max_active < 1:
|
||||
raise ValueError(f"max_active must be >= 1, got {max_active}")
|
||||
@@ -182,6 +200,15 @@ class SessionManager:
|
||||
# effects, and reserved for future kinds whose lifecycle
|
||||
# transitions don't fan out anywhere.
|
||||
self._event_emitter = event_emitter
|
||||
# Optional registry-membership check applied to the persisted
|
||||
# ``model_alias`` on the rehydrate path before threading it
|
||||
# into ``build_session``. Production wiring passes
|
||||
# ``registry.has_alias``; an alias that has been removed from
|
||||
# the registry since the workstream was created is filtered
|
||||
# out so the session_factory falls back to its default rather
|
||||
# than raising. Restricted to the rehydrate path — fresh
|
||||
# creates still want unknown aliases to surface as 503.
|
||||
self._model_validator = model_validator
|
||||
self._node_id = node_id
|
||||
self._workstreams: dict[str, Workstream] = {}
|
||||
self._order: list[str] = []
|
||||
@@ -197,13 +224,21 @@ class SessionManager:
|
||||
# manager never reads them.
|
||||
self._active_id: str | None = None
|
||||
self._eviction_count: int = 0
|
||||
# Optional state-change observer. The CLI sets this to a
|
||||
# callback that prints a background-attention notification
|
||||
# when a non-focused workstream transitions to ATTENTION.
|
||||
# Web/coord paths use the event_emitter's emit_state for their
|
||||
# own fan-out; this is a second, manager-level hook for callers
|
||||
# that don't consume SSE.
|
||||
self._on_state_change: Callable[[str, WorkstreamState], None] | None = None
|
||||
# State-change subscribers. Multi-subscriber to support the
|
||||
# CLI's background-attention notification AND the in-process
|
||||
# ``SameNodeChildSource`` strategy that delivers child
|
||||
# workstream state changes to a parent's UI without going
|
||||
# through the cluster bus. Each callback fires under
|
||||
# exception-suppression so one failing subscriber doesn't
|
||||
# block the others. Subscribers register via
|
||||
# :meth:`subscribe_to_state`. ``_state_subscribers_lock``
|
||||
# guards mutation + snapshot — set_state copies the list
|
||||
# under the lock then iterates the snapshot unlocked so a
|
||||
# slow subscriber doesn't block subscribe/unsubscribe (and
|
||||
# so concurrent subscribe/unsubscribe during a state event
|
||||
# can't shift the iterator's index — caught by /review bug-1).
|
||||
self._state_subscribers: list[Callable[[str, WorkstreamState], None]] = []
|
||||
self._state_subscribers_lock = threading.Lock()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Properties
|
||||
@@ -217,6 +252,16 @@ class SessionManager:
|
||||
def kind(self) -> WorkstreamKind:
|
||||
return self._adapter.kind
|
||||
|
||||
@property
|
||||
def _service_type(self) -> str | None:
|
||||
"""``services.service_type`` this manager's hosting process registers
|
||||
under, derived from its ``kind``. Used by ``close_idle`` pass 2 to
|
||||
enumerate live peer processes. Returns ``None`` for kinds that have
|
||||
no production service mapping (only the two existing kinds map
|
||||
today; ``None`` would be a marker for a future kind without a
|
||||
clustered hosting model)."""
|
||||
return _KIND_SERVICE_TYPE.get(self.kind)
|
||||
|
||||
@property
|
||||
def count(self) -> int:
|
||||
with self._lock:
|
||||
@@ -568,8 +613,34 @@ class SessionManager:
|
||||
evicted.id, reason="evicted", name=evicted.name
|
||||
)
|
||||
|
||||
# Thread the persisted ``model_alias`` into
|
||||
# ``build_session`` so reopened workstreams keep the
|
||||
# model they were created with. Pairs with the
|
||||
# ``ChatSession.__init__`` skip-save guard: without
|
||||
# both halves, ``_save_config`` clobbers persisted
|
||||
# config with constructor defaults before
|
||||
# ``ChatSession.resume`` reads them back. When
|
||||
# ``model_validator`` is wired and the saved alias is
|
||||
# no longer in the registry, drop it so the factory
|
||||
# falls back to its default — the session_factory
|
||||
# itself still raises on unknown aliases, since
|
||||
# fresh-create paths want that to surface as a 503.
|
||||
saved_cfg = self._storage.load_workstream_config(ws_id)
|
||||
saved_alias = (saved_cfg.get("model_alias") or None) if saved_cfg else None
|
||||
if (
|
||||
saved_alias
|
||||
and self._model_validator is not None
|
||||
and not self._model_validator(saved_alias)
|
||||
):
|
||||
log.warning(
|
||||
"session_mgr.stale_alias_dropped ws=%s alias=%s",
|
||||
ws_id[:8],
|
||||
saved_alias,
|
||||
)
|
||||
saved_alias = None
|
||||
|
||||
try:
|
||||
ws.session = self._adapter.build_session(ws)
|
||||
ws.session = self._adapter.build_session(ws, model=saved_alias)
|
||||
except Exception:
|
||||
# Clean up the UI the adapter built before re-raising
|
||||
# so any listener/lock resources are released.
|
||||
@@ -604,6 +675,22 @@ class SessionManager:
|
||||
# last close(). The next set_state() call syncs it
|
||||
# naturally; writing 'idle' here could race a concurrent
|
||||
# close() that writes 'closed' under self._lock.
|
||||
#
|
||||
# Bump only ``updated`` (no state write) so this row's
|
||||
# timestamp is fresh against the orphan-reaper cutoff —
|
||||
# otherwise a concurrent close_idle pass-2 in this same
|
||||
# process could clobber a freshly-rehydrated row whose
|
||||
# ``updated`` is older than the cutoff. The pure-
|
||||
# timestamp write is safe against concurrent close()
|
||||
# because close still wins on the state column.
|
||||
try:
|
||||
self._storage.touch_workstream(ws_id)
|
||||
except Exception:
|
||||
log.debug(
|
||||
"session_mgr.touch_workstream_failed ws=%s",
|
||||
ws_id[:8],
|
||||
exc_info=True,
|
||||
)
|
||||
if self._event_emitter is not None:
|
||||
self._event_emitter.emit_rehydrated(ws)
|
||||
return ws
|
||||
@@ -773,9 +860,36 @@ class SessionManager:
|
||||
log.debug("session_mgr.state_update_failed ws=%s", ws_id[:8], exc_info=True)
|
||||
if self._event_emitter is not None:
|
||||
self._event_emitter.emit_state(ws, state)
|
||||
if self._on_state_change is not None:
|
||||
# Snapshot under the subscribers lock so concurrent
|
||||
# subscribe / unsubscribe can't shift the iterator's index
|
||||
# mid-dispatch (skipping or repeating callbacks). Iterate
|
||||
# the snapshot WITHOUT the lock so a slow callback doesn't
|
||||
# block subscribe / unsubscribe.
|
||||
with self._state_subscribers_lock:
|
||||
subscribers = list(self._state_subscribers)
|
||||
for callback in subscribers:
|
||||
with contextlib.suppress(Exception):
|
||||
self._on_state_change(ws_id, state)
|
||||
callback(ws_id, state)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# State-change subscription
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def subscribe_to_state(self, callback: Callable[[str, WorkstreamState], None]) -> None:
|
||||
"""Register ``callback`` to fire on every workstream state change.
|
||||
|
||||
Multiple subscribers are supported and fire in registration order.
|
||||
Each callback is wrapped in exception-suppression so a failing
|
||||
subscriber doesn't block the others. Use
|
||||
:meth:`unsubscribe_from_state` to remove.
|
||||
"""
|
||||
with self._state_subscribers_lock:
|
||||
self._state_subscribers.append(callback)
|
||||
|
||||
def unsubscribe_from_state(self, callback: Callable[[str, WorkstreamState], None]) -> None:
|
||||
"""Remove a previously-registered state-change callback. No-op if absent."""
|
||||
with self._state_subscribers_lock, contextlib.suppress(ValueError):
|
||||
self._state_subscribers.remove(callback)
|
||||
|
||||
def cancel(self, ws_id: str) -> bool:
|
||||
"""Cancel in-flight generation and unblock any pending approval / plan.
|
||||
@@ -804,15 +918,52 @@ class SessionManager:
|
||||
def close_idle(self, max_age_seconds: float) -> list[str]:
|
||||
"""Close IDLE workstreams inactive for more than ``max_age_seconds``.
|
||||
|
||||
Returns the list of closed ws_ids. Unlike the old WSM version,
|
||||
this does NOT skip the last workstream — the default-startup
|
||||
relic is gone, callers can handle the 0-workstream case.
|
||||
Two-pass shape:
|
||||
|
||||
- Pass 1 (in-memory): close loaded ``IDLE`` rows whose
|
||||
``ws.last_active`` (monotonic) is past timeout. Closes only
|
||||
``IDLE`` so legitimately-attentive rows (waiting for user
|
||||
response) stay live.
|
||||
- Pass 2 (DB orphans): bulk-close DB rows of this manager's
|
||||
kind whose ``updated`` is past the wall-clock cutoff and
|
||||
which are not currently loaded. This catches workstreams
|
||||
left behind by prior process incarnations — a process crash
|
||||
/restart leaves rows in non-terminal states forever
|
||||
otherwise. Closes ``idle/thinking/attention/running``
|
||||
because any matching row is by definition not loaded by any
|
||||
live process and cannot be in a live interaction.
|
||||
|
||||
**Liveness scoping** (the rendezvous router's primitive
|
||||
since PR #384): when ``self._service_type`` resolves to a
|
||||
known service type — both production kinds do — pass 2
|
||||
calls ``storage.list_services`` to enumerate peer processes
|
||||
with recent heartbeats and protects rows whose ``node_id``
|
||||
matches a live ``service_id`` from reap, even when *this*
|
||||
manager is on a different node. This is essential for
|
||||
containerized deployments with dynamic hostnames: dead-pod
|
||||
rows fall out of the live set after the heartbeat window
|
||||
and become reapable; alive-pod rows stay protected as long
|
||||
as the owner heartbeats. A future kind with no service
|
||||
registration would resolve ``_service_type`` to ``None``
|
||||
and skip the live-services lookup (single-process / CLI).
|
||||
|
||||
**Conservative fallback**: if ``list_services`` raises,
|
||||
pass 2 is skipped entirely this tick — never reap when
|
||||
liveness state is unknown. Pass 1 still runs. Next tick
|
||||
retries the lookup.
|
||||
|
||||
Returns the combined list of closed ws_ids (in-memory first,
|
||||
then DB orphans). Pass 1 emits ``ws_closed``; pass 2 does
|
||||
not, because never-loaded rows have no SSE listeners
|
||||
expecting them.
|
||||
|
||||
Atomic pop per victim under ``self._lock`` (bug-5): a pending
|
||||
tool result can flip state IDLE→RUNNING between the snapshot
|
||||
and the close, so the state test + pop must run together.
|
||||
Batches every pop under one ``self._lock`` acquisition (perf-5)
|
||||
rather than locking once per victim.
|
||||
rather than locking once per victim. The DB pass runs OUTSIDE
|
||||
``self._lock`` — only a brief lock to snapshot loaded keys —
|
||||
so a slow UPDATE doesn't block create/get/set_state.
|
||||
"""
|
||||
now = time.monotonic()
|
||||
popped: list[Workstream] = []
|
||||
@@ -853,6 +1004,63 @@ class SessionManager:
|
||||
if self._event_emitter is not None:
|
||||
self._event_emitter.emit_closed(ws.id, name=ws.name)
|
||||
closed_ids.append(ws.id)
|
||||
|
||||
# Pass 2: reap DB orphans of this kind older than the cutoff.
|
||||
# Snapshot loaded keys under self._lock briefly so a concurrent
|
||||
# create/load doesn't get its row clobbered by the UPDATE; release
|
||||
# before the DB call.
|
||||
#
|
||||
# Liveness scoping uses ``services.last_heartbeat`` — the same
|
||||
# primitive the rendezvous router (PR #384) uses for routing. A
|
||||
# row's ``node_id`` is stamped at create time and never updated;
|
||||
# in containerized deployments with dynamic hostnames the dead
|
||||
# pod's ``node_id`` points at a service that's no longer
|
||||
# heartbeating, so the row falls through to reap. Conversely,
|
||||
# rows whose ``node_id`` matches a heartbeating service are
|
||||
# protected even when *this* manager is on a different node —
|
||||
# the alive peer may legitimately have them loaded.
|
||||
with self._lock:
|
||||
loaded = list(self._workstreams.keys())
|
||||
cutoff = (datetime.now(UTC) - timedelta(seconds=max_age_seconds)).strftime(
|
||||
"%Y-%m-%dT%H:%M:%S"
|
||||
)
|
||||
live_node_ids: list[str] | None = None
|
||||
skip_pass_2 = False
|
||||
if self._service_type is not None:
|
||||
try:
|
||||
live_services = self._storage.list_services(self._service_type)
|
||||
live_node_ids = [
|
||||
str(svc["service_id"]) for svc in live_services if svc.get("service_id")
|
||||
]
|
||||
except Exception:
|
||||
# Conservative fallback: skip pass 2 entirely this tick
|
||||
# so we can't accidentally reap rows whose owners we
|
||||
# failed to enumerate. Next tick retries.
|
||||
log.debug(
|
||||
"session_mgr.list_services_failed kind=%s",
|
||||
self.kind.value,
|
||||
exc_info=True,
|
||||
)
|
||||
skip_pass_2 = True
|
||||
orphans: list[str] = []
|
||||
if not skip_pass_2:
|
||||
try:
|
||||
orphans = self._storage.bulk_close_stale_orphans(
|
||||
self.kind, cutoff, loaded, live_node_ids=live_node_ids
|
||||
)
|
||||
except Exception:
|
||||
log.debug(
|
||||
"session_mgr.bulk_close_orphans_failed kind=%s",
|
||||
self.kind.value,
|
||||
exc_info=True,
|
||||
)
|
||||
if orphans:
|
||||
log.info(
|
||||
"session_mgr.bulk_close_orphans count=%d kind=%s",
|
||||
len(orphans),
|
||||
self.kind.value,
|
||||
)
|
||||
closed_ids.extend(orphans)
|
||||
return closed_ids
|
||||
|
||||
def _close_if_idle_locked(self, ws_id: str) -> Workstream | None:
|
||||
|
||||
@@ -394,6 +394,15 @@ class SessionEndpointConfig:
|
||||
# separate ``/history`` endpoint and doesn't render the per-tab
|
||||
# status bar). Kinds that don't need pre-replay wire ``None``.
|
||||
events_replay: EventsReplay | None = None
|
||||
# async (ws, ui, request) -> None. Kind-specific async pre-step
|
||||
# the lifted ``events`` body awaits BEFORE iterating
|
||||
# ``events_replay``. Lets a kind move blocking storage I/O off
|
||||
# the event loop (via ``asyncio.to_thread``) and stash results
|
||||
# on ``request.state`` for the sync replay generator to read.
|
||||
# Interactive uses it to pre-load intent_verdicts +
|
||||
# output_assessments so ``_build_history``'s decoration stays
|
||||
# off the hot path. Coord wires ``None``.
|
||||
events_replay_prepare: Callable[..., Any] | None = None
|
||||
# (request) -> Executor for the SSE live-loop's blocking
|
||||
# ``queue.get`` wait. Interactive returns the dedicated
|
||||
# ``request.app.state.sse_executor`` (200-thread pool) so SSE
|
||||
@@ -1203,6 +1212,8 @@ def make_open_handler(
|
||||
"""
|
||||
|
||||
async def open_ws(request: Request) -> Response:
|
||||
import asyncio
|
||||
|
||||
if cfg.permission_gate is not None:
|
||||
err = cfg.permission_gate(request)
|
||||
if err is not None:
|
||||
@@ -1304,7 +1315,14 @@ def make_open_handler(
|
||||
# emit_rehydrated path).
|
||||
if cfg.open_post_load is not None:
|
||||
try:
|
||||
cfg.open_post_load(request, ws)
|
||||
# Off-loop: interactive's post_load runs the sync
|
||||
# ``_build_history`` (storage I/O for verdict
|
||||
# indexes + message reconstruction) — without the
|
||||
# to_thread wrap this blocks the event loop on every
|
||||
# workstream open, mirroring the SSE replay path
|
||||
# that's already protected via
|
||||
# ``events_replay_prepare``.
|
||||
await asyncio.to_thread(cfg.open_post_load, request, ws)
|
||||
except Exception:
|
||||
# Post-load is observational — never let a hook bug
|
||||
# block the open. Log + continue.
|
||||
@@ -1454,6 +1472,20 @@ def make_events_handler(cfg: SessionEndpointConfig) -> Handler:
|
||||
# 500-slot cap on a chatty mid-generation workstream)
|
||||
# while replay was being built.
|
||||
if replay_cb is not None:
|
||||
# Kind-specific async prep — runs before the sync
|
||||
# replay generator iterates so blocking storage
|
||||
# I/O lands in the executor pool rather than the
|
||||
# event loop's hot path. Interactive uses this
|
||||
# to pre-load verdict indexes; coord skips.
|
||||
if cfg.events_replay_prepare is not None:
|
||||
try:
|
||||
await cfg.events_replay_prepare(ws, ui, request)
|
||||
except Exception:
|
||||
log.debug(
|
||||
"ws.events.replay_prepare_failed ws=%s",
|
||||
ws_id[:8],
|
||||
exc_info=True,
|
||||
)
|
||||
try:
|
||||
for ev in replay_cb(ws, ui, request):
|
||||
yield {"data": json.dumps(ev)}
|
||||
@@ -2240,6 +2272,34 @@ def make_history_handler(cfg: SessionEndpointConfig) -> Handler:
|
||||
messages = await asyncio.to_thread(storage.load_messages, ws_id, limit=limit)
|
||||
except Exception:
|
||||
log.debug("ws.history.load_failed ws=%s", ws_id[:8], exc_info=True)
|
||||
# Audit-trail decoration — attach persisted intent_verdict and
|
||||
# output_assessment data to each assistant.tool_calls entry so
|
||||
# the dashboard's history replay paints the same verdict pills
|
||||
# / output-warning bubbles the live SSE path shows. Both
|
||||
# storage queries are off-loop via ``to_thread``. Best-effort:
|
||||
# any failure leaves messages undecorated — replay degrades to
|
||||
# the pre-decoration shape rather than 500-ing.
|
||||
if messages:
|
||||
try:
|
||||
from turnstone.core.history_decoration import (
|
||||
decorate_history_messages,
|
||||
load_verdict_indexes,
|
||||
)
|
||||
|
||||
indexes = await asyncio.to_thread(load_verdict_indexes, ws_id)
|
||||
decorate_history_messages(messages, indexes[0], indexes[1])
|
||||
except Exception:
|
||||
# Operationally interesting: a persistent decoration
|
||||
# failure (missing migration, driver mismatch, schema
|
||||
# drift) silently strips verdict pills + output
|
||||
# warnings from every reload of every workstream.
|
||||
# Log at warning so it surfaces in normal log review
|
||||
# rather than only when DEBUG is on.
|
||||
log.warning(
|
||||
"ws.history.decoration_failed ws=%s",
|
||||
ws_id[:8],
|
||||
exc_info=True,
|
||||
)
|
||||
return JSONResponse({"ws_id": ws_id, "messages": messages})
|
||||
|
||||
return history
|
||||
@@ -2453,7 +2513,7 @@ def make_send_handler(cfg: SessionEndpointConfig) -> Handler:
|
||||
import uuid
|
||||
|
||||
from turnstone.core import session_worker
|
||||
from turnstone.core.session import GenerationCancelled
|
||||
from turnstone.core.session import AttachmentsNotQueueableError, GenerationCancelled
|
||||
from turnstone.core.web_helpers import read_json_or_400
|
||||
|
||||
async def send(request: Request) -> Response:
|
||||
@@ -2616,11 +2676,15 @@ def make_send_handler(cfg: SessionEndpointConfig) -> Handler:
|
||||
queue_outcome: dict[str, Any] = {}
|
||||
|
||||
def _enqueue() -> None:
|
||||
cleaned, priority, msg_id = session.queue_message(
|
||||
message,
|
||||
attachment_ids=list(ordered_reserved),
|
||||
queue_msg_id=send_id or None,
|
||||
)
|
||||
try:
|
||||
cleaned, priority, msg_id = session.queue_message(
|
||||
message,
|
||||
attachment_ids=list(ordered_reserved),
|
||||
queue_msg_id=send_id or None,
|
||||
)
|
||||
except AttachmentsNotQueueableError:
|
||||
queue_outcome["rejected"] = "attachments_busy"
|
||||
return
|
||||
queue_outcome["cleaned"] = cleaned
|
||||
queue_outcome["priority"] = priority
|
||||
queue_outcome["msg_id"] = msg_id
|
||||
@@ -2703,6 +2767,20 @@ def make_send_handler(cfg: SessionEndpointConfig) -> Handler:
|
||||
}
|
||||
)
|
||||
|
||||
if queue_outcome.get("rejected") == "attachments_busy":
|
||||
# Attachments can't ride a queued user turn (see
|
||||
# AttachmentsNotQueueableError for the role-ordering reason).
|
||||
# Release reservations and surface to the caller so the
|
||||
# client can hold the file and retry once the worker idles.
|
||||
_release_reservation_on_fail()
|
||||
return JSONResponse(
|
||||
{
|
||||
"status": "attachments_busy",
|
||||
"attached_ids": [],
|
||||
"dropped_attachment_ids": list(requested_ids),
|
||||
}
|
||||
)
|
||||
|
||||
dropped = [aid for aid in requested_ids if aid not in reserved_set]
|
||||
if queue_outcome:
|
||||
# Reused a live worker; ``queue_message`` succeeded.
|
||||
@@ -2963,8 +3041,9 @@ def make_dequeue_handler(cfg: SessionEndpointConfig) -> Handler:
|
||||
Removes a previously-queued message identified by ``msg_id`` from
|
||||
the workstream's pending queue. Returns ``status: removed`` when
|
||||
the queue had the entry and ``status: not_found`` otherwise.
|
||||
Reservations attached to the dequeued message are released by
|
||||
``ChatSession.dequeue_message`` so attachments can be reused.
|
||||
Queued messages don't carry attachments (see
|
||||
:class:`AttachmentsNotQueueableError`), so there's no reservation
|
||||
side-effect to undo here.
|
||||
"""
|
||||
from turnstone.core.web_helpers import read_json_or_400
|
||||
|
||||
|
||||
@@ -41,6 +41,7 @@ log = get_logger(__name__)
|
||||
# from bloating memory.
|
||||
_DEFAULT_LISTENER_QUEUE_MAX = 500
|
||||
|
||||
|
||||
# Cap on the per-turn assistant content accumulator. The accumulator
|
||||
# is piggybacked onto the ``ws_state:idle`` broadcast payload so the
|
||||
# cluster collector / dashboard can render the freshly-emitted assistant
|
||||
@@ -327,6 +328,11 @@ class SessionUIBase:
|
||||
"always": bool(always),
|
||||
}
|
||||
)
|
||||
# Kind-specific cross-stream broadcast — ConsoleCoordinatorUI
|
||||
# overrides to push onto the cluster bus so a coord parent's
|
||||
# tree UI clears the pending-approval pill in lockstep with
|
||||
# the actual decision. Stage 3 Step 4.
|
||||
self._broadcast_approval_resolved(approved, feedback, always=always)
|
||||
self._approval_event.set()
|
||||
|
||||
@staticmethod
|
||||
@@ -579,6 +585,20 @@ class SessionUIBase:
|
||||
"judge_pending": judge_pending,
|
||||
}
|
||||
self._enqueue(self._pending_approval)
|
||||
# Cross-stream broadcast — push the items via the cluster bus
|
||||
# so a coord parent's tree UI can render the inline approve/deny
|
||||
# block without waiting for a bulk fetch. Without this, the
|
||||
# bulk fetch races with this assignment: the state transition
|
||||
# to ATTENTION fires upstream BEFORE approve_tools runs (see
|
||||
# session.py:_emit_state("attention") preceding ui.approve_tools),
|
||||
# so a bulk fetch landing in the ~50-200ms window between
|
||||
# _emit_state and this point sees ``_pending_approval=None``
|
||||
# and returns ``pending_approval_detail: null``. The 5s TTL
|
||||
# then locks the coord row on a "loading" placeholder until
|
||||
# the next state event triggers a refresh — which never comes
|
||||
# while parked on _approval_event.wait. The push path
|
||||
# eliminates the race.
|
||||
self._broadcast_approve_request(self._pending_approval)
|
||||
if not self._approval_event.wait(timeout=self._APPROVAL_WAIT_TIMEOUT):
|
||||
# Approval timed out (e.g., user disconnected). Deny via
|
||||
# resolve_approval so verdicts and state are updated consistently.
|
||||
@@ -632,6 +652,12 @@ class SessionUIBase:
|
||||
del self._llm_verdicts[oldest_key]
|
||||
self._llm_verdicts[call_id] = verdict
|
||||
self._enqueue({"type": "intent_verdict", **verdict})
|
||||
# Kind-specific cross-stream broadcast — ConsoleCoordinatorUI
|
||||
# overrides to push onto the cluster bus so a coord parent's
|
||||
# tree UI sees the verdict without polling. Default is no-op
|
||||
# (the per-ws ``_enqueue`` above already covers WebUI's own
|
||||
# SSE listeners). Stage 3 Step 4.
|
||||
self._broadcast_intent_verdict(verdict)
|
||||
self._persist_intent_verdict(verdict)
|
||||
# Decision check + either queue or flag-for-persist happen
|
||||
# under ONE lock acquisition so resolve_approval can't swap-
|
||||
@@ -1293,6 +1319,42 @@ class SessionUIBase:
|
||||
def on_error(self, message: str) -> None:
|
||||
self._enqueue({"type": "error", "message": message})
|
||||
|
||||
def on_user_reminder(self, reminders: list[dict[str, str]]) -> None:
|
||||
"""Surface a metacognitive user-channel nudge as its own UI
|
||||
element.
|
||||
|
||||
Reminders live on the user message dict's ``_reminders``
|
||||
side-channel and are spliced into ``content`` only at the
|
||||
provider boundary; this event is what lets every connected
|
||||
SSE consumer (other browser tabs, CLI mirrors, future channel
|
||||
adapters) render the reminder bubble in lockstep with the
|
||||
originating tab. The history-replay path surfaces the same
|
||||
shape via ``_build_history`` so a tab reconnecting later
|
||||
renders the same bubble.
|
||||
"""
|
||||
self._enqueue({"type": "user_reminder", "reminders": reminders})
|
||||
|
||||
def on_tool_reminder(self, reminders: list[dict[str, str]], tool_call_id: str) -> None:
|
||||
"""Surface a metacognitive tool-channel nudge (``tool_error`` /
|
||||
``repeat``) as its own UI element below the tool result that
|
||||
triggered it.
|
||||
|
||||
Tool-channel reminders ride the same ``_reminders``
|
||||
side-channel pattern as the user channel — kept out of
|
||||
``content`` so compaction / title-gen / channel adapters never
|
||||
see the nudge text, spliced into the wire only via
|
||||
``_apply_reminders_for_provider``. ``tool_call_id`` is the
|
||||
anchor the frontend uses to render the bubble below the
|
||||
specific tool result that triggered the batch's reminder.
|
||||
"""
|
||||
self._enqueue(
|
||||
{
|
||||
"type": "tool_reminder",
|
||||
"reminders": reminders,
|
||||
"tool_call_id": tool_call_id,
|
||||
}
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Broadcast hooks — kind-specific transport.
|
||||
#
|
||||
@@ -1315,6 +1377,42 @@ class SessionUIBase:
|
||||
Default: no-op. Subclasses override.
|
||||
"""
|
||||
|
||||
def _broadcast_intent_verdict(self, verdict: dict[str, Any]) -> None: # noqa: ARG002 — hook stub
|
||||
"""Fan an LLM intent-judge verdict out to the kind's transport.
|
||||
|
||||
Default: no-op. ``ConsoleCoordinatorUI`` overrides to push a
|
||||
``intent_verdict`` event onto the cluster bus so the parent
|
||||
coordinator's tree UI can render the risk pill + verdict
|
||||
result without polling. Stage 3 Step 4: hook only — the
|
||||
cluster-bus event class lands in Step 5.
|
||||
"""
|
||||
|
||||
def _broadcast_approval_resolved(
|
||||
self,
|
||||
approved: bool, # noqa: ARG002 — hook stub
|
||||
feedback: str | None = None, # noqa: ARG002 — hook stub
|
||||
*,
|
||||
always: bool = False, # noqa: ARG002 — hook stub
|
||||
) -> None:
|
||||
"""Fan an ``approval_resolved`` decision out to the kind's transport.
|
||||
|
||||
Default: no-op. ``ConsoleCoordinatorUI`` overrides to push to
|
||||
the cluster bus so the parent coordinator's tree UI can clear
|
||||
the pending-approval pill in sync with the actual decision.
|
||||
"""
|
||||
|
||||
def _broadcast_approve_request(self, detail: dict[str, Any]) -> None: # noqa: ARG002 — hook stub
|
||||
"""Fan an ``approve_request`` payload out to the kind's transport.
|
||||
|
||||
Default: no-op. ``WebUI`` and ``ConsoleCoordinatorUI`` override
|
||||
to push the items list (the same dict that landed in
|
||||
``_pending_approval``) onto their respective transports. The
|
||||
push path eliminates the bulk-fetch race that otherwise
|
||||
leaves coord rows stuck on a loading placeholder when the
|
||||
bulk fetch lands in the gap between the state transition to
|
||||
ATTENTION and ``_pending_approval`` being set.
|
||||
"""
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# State-broadcast snapshot helper
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@@ -87,6 +87,9 @@ from turnstone.core.storage._utils import (
|
||||
from turnstone.core.storage._utils import (
|
||||
VERDICT_MUTABLE as _VERDICT_MUTABLE,
|
||||
)
|
||||
from turnstone.core.storage._utils import (
|
||||
normalize_search_terms as _normalize_search_terms,
|
||||
)
|
||||
from turnstone.core.storage._utils import (
|
||||
reconstruct_messages as _reconstruct_messages,
|
||||
)
|
||||
@@ -97,7 +100,7 @@ from turnstone.core.storage._utils import sanitize_text
|
||||
from turnstone.core.storage._utils import (
|
||||
scan_skill_content as _scan_skill_content,
|
||||
)
|
||||
from turnstone.core.workstream import WorkstreamKind
|
||||
from turnstone.core.workstream import BULK_CLOSE_STATE_VALUES, WorkstreamKind
|
||||
|
||||
log = get_logger(__name__)
|
||||
|
||||
@@ -595,6 +598,57 @@ class PostgreSQLBackend:
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
def bulk_close_stale_orphans(
|
||||
self,
|
||||
kind: WorkstreamKind | str,
|
||||
cutoff: str,
|
||||
exclude_ws_ids: list[str],
|
||||
live_node_ids: list[str] | None = None,
|
||||
) -> list[str]:
|
||||
norm_kind = WorkstreamKind(kind).value
|
||||
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
|
||||
stmt = (
|
||||
sa.update(workstreams)
|
||||
.where(
|
||||
workstreams.c.kind == norm_kind,
|
||||
workstreams.c.state.in_(BULK_CLOSE_STATE_VALUES),
|
||||
workstreams.c.updated < cutoff,
|
||||
)
|
||||
.values(state="closed", updated=now)
|
||||
.returning(workstreams.c.ws_id)
|
||||
)
|
||||
# Protect rows whose owning process is still heartbeating in the
|
||||
# services table (rendezvous router's liveness primitive). NULL
|
||||
# node_id rows have no owner identity — always eligible. The
|
||||
# ``and live_node_ids`` short-circuits both ``None`` (skip the
|
||||
# filter entirely — single-process / operator backfill) and ``[]``
|
||||
# (no nodes alive — every row unprotected, no extra predicate
|
||||
# needed since absence equals match-all).
|
||||
if live_node_ids is not None and live_node_ids:
|
||||
stmt = stmt.where(
|
||||
sa.or_(
|
||||
workstreams.c.node_id.is_(None),
|
||||
~workstreams.c.node_id.in_(live_node_ids),
|
||||
)
|
||||
)
|
||||
if exclude_ws_ids:
|
||||
# Skip ``NOT IN ()`` when nothing to exclude — keeps the SQL clean
|
||||
# and avoids SQLAlchemy's empty-collection warning.
|
||||
stmt = stmt.where(~workstreams.c.ws_id.in_(exclude_ws_ids))
|
||||
with self._conn() as conn:
|
||||
result = conn.execute(stmt)
|
||||
ids = [row[0] for row in result]
|
||||
conn.commit()
|
||||
return ids
|
||||
|
||||
def touch_workstream(self, ws_id: str) -> None:
|
||||
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
|
||||
with self._conn() as conn:
|
||||
conn.execute(
|
||||
sa.update(workstreams).where(workstreams.c.ws_id == ws_id).values(updated=now)
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
def update_workstream_name(self, ws_id: str, name: str) -> None:
|
||||
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
|
||||
with self._conn() as conn:
|
||||
@@ -3264,7 +3318,10 @@ class PostgreSQLBackend:
|
||||
limit: int = 100,
|
||||
) -> list[dict[str, str]]:
|
||||
with self._conn() as conn:
|
||||
q = sa.select(structured_memories).order_by(structured_memories.c.updated.desc())
|
||||
q = sa.select(structured_memories).order_by(
|
||||
structured_memories.c.updated.desc(),
|
||||
structured_memories.c.memory_id.asc(),
|
||||
)
|
||||
if mem_type:
|
||||
q = q.where(structured_memories.c.type == mem_type)
|
||||
if scope:
|
||||
@@ -3283,11 +3340,16 @@ class PostgreSQLBackend:
|
||||
scope_id: str = "",
|
||||
limit: int = 20,
|
||||
) -> list[dict[str, str]]:
|
||||
"""OR-of-terms ILIKE search; ranking is the caller's job (BM25 downstream)."""
|
||||
if not query or not query.strip():
|
||||
return self.list_structured_memories(
|
||||
mem_type=mem_type, scope=scope, scope_id=scope_id, limit=limit
|
||||
)
|
||||
terms = query.split()
|
||||
terms = _normalize_search_terms(query)
|
||||
if not terms:
|
||||
return self.list_structured_memories(
|
||||
mem_type=mem_type, scope=scope, scope_id=scope_id, limit=limit
|
||||
)
|
||||
with self._conn() as conn:
|
||||
clauses = []
|
||||
params: dict[str, str] = {}
|
||||
@@ -3301,25 +3363,116 @@ class PostgreSQLBackend:
|
||||
params[f"n{i}"] = f"%{escaped}%"
|
||||
params[f"d{i}"] = f"%{escaped}%"
|
||||
params[f"c{i}"] = f"%{escaped}%"
|
||||
where = " AND ".join(clauses)
|
||||
term_clause = " OR ".join(clauses)
|
||||
scope_filters = ""
|
||||
if mem_type:
|
||||
where += " AND type = :type_filter"
|
||||
scope_filters += " AND type = :type_filter"
|
||||
params["type_filter"] = mem_type
|
||||
if scope:
|
||||
where += " AND scope = :scope_filter"
|
||||
scope_filters += " AND scope = :scope_filter"
|
||||
params["scope_filter"] = scope
|
||||
if scope_id and scope:
|
||||
where += " AND scope_id = :scope_id_filter"
|
||||
scope_filters += " AND scope_id = :scope_id_filter"
|
||||
params["scope_id_filter"] = scope_id
|
||||
rows = conn.execute(
|
||||
sa.text(
|
||||
f"SELECT * FROM structured_memories WHERE {where} "
|
||||
f"ORDER BY updated DESC LIMIT :lim"
|
||||
f"SELECT * FROM structured_memories WHERE ({term_clause}){scope_filters} "
|
||||
f"ORDER BY updated DESC, memory_id ASC LIMIT :lim"
|
||||
),
|
||||
{**params, "lim": limit},
|
||||
).fetchall()
|
||||
return [dict(r._mapping) for r in rows]
|
||||
|
||||
def list_visible_structured_memories(
|
||||
self,
|
||||
scopes: list[tuple[str, str]],
|
||||
mem_type: str = "",
|
||||
limit: int = 100,
|
||||
) -> list[dict[str, str]]:
|
||||
"""Single-query union across visible (scope, scope_id) pairs.
|
||||
|
||||
Replaces the per-scope fan-out (one query per visible scope) so the
|
||||
composition path issues 1 round-trip instead of 3.
|
||||
"""
|
||||
if not scopes:
|
||||
return []
|
||||
with self._conn() as conn:
|
||||
scope_clauses, params = self._build_scope_or_clause(scopes)
|
||||
extra = ""
|
||||
if mem_type:
|
||||
extra = " AND type = :type_filter"
|
||||
params["type_filter"] = mem_type
|
||||
rows = conn.execute(
|
||||
sa.text(
|
||||
f"SELECT * FROM structured_memories WHERE ({scope_clauses}){extra} "
|
||||
f"ORDER BY updated DESC, memory_id ASC LIMIT :lim"
|
||||
),
|
||||
{**params, "lim": limit},
|
||||
).fetchall()
|
||||
return [dict(r._mapping) for r in rows]
|
||||
|
||||
def search_visible_structured_memories(
|
||||
self,
|
||||
query: str,
|
||||
scopes: list[tuple[str, str]],
|
||||
mem_type: str = "",
|
||||
limit: int = 20,
|
||||
) -> list[dict[str, str]]:
|
||||
"""OR-of-terms search joined with a single visibility OR-group.
|
||||
|
||||
Replaces the per-scope search fan-out; ranking is the caller's job.
|
||||
"""
|
||||
if not scopes:
|
||||
return []
|
||||
if not query or not query.strip():
|
||||
return self.list_visible_structured_memories(scopes, mem_type=mem_type, limit=limit)
|
||||
terms = _normalize_search_terms(query)
|
||||
if not terms:
|
||||
return self.list_visible_structured_memories(scopes, mem_type=mem_type, limit=limit)
|
||||
with self._conn() as conn:
|
||||
scope_clauses, params = self._build_scope_or_clause(scopes)
|
||||
term_clauses = []
|
||||
for i, t in enumerate(terms):
|
||||
escaped = _escape_ilike(t)
|
||||
term_clauses.append(
|
||||
f"(name ILIKE :n{i} ESCAPE '\\' "
|
||||
f"OR description ILIKE :d{i} ESCAPE '\\' "
|
||||
f"OR content ILIKE :c{i} ESCAPE '\\')"
|
||||
)
|
||||
params[f"n{i}"] = f"%{escaped}%"
|
||||
params[f"d{i}"] = f"%{escaped}%"
|
||||
params[f"c{i}"] = f"%{escaped}%"
|
||||
term_clause = " OR ".join(term_clauses)
|
||||
extra = ""
|
||||
if mem_type:
|
||||
extra = " AND type = :type_filter"
|
||||
params["type_filter"] = mem_type
|
||||
rows = conn.execute(
|
||||
sa.text(
|
||||
f"SELECT * FROM structured_memories "
|
||||
f"WHERE ({scope_clauses}) AND ({term_clause}){extra} "
|
||||
f"ORDER BY updated DESC, memory_id ASC LIMIT :lim"
|
||||
),
|
||||
{**params, "lim": limit},
|
||||
).fetchall()
|
||||
return [dict(r._mapping) for r in rows]
|
||||
|
||||
@staticmethod
|
||||
def _build_scope_or_clause(
|
||||
scopes: list[tuple[str, str]],
|
||||
) -> tuple[str, dict[str, str]]:
|
||||
"""Build a parameterized OR-group of (scope[, scope_id]) predicates."""
|
||||
params: dict[str, str] = {}
|
||||
clauses: list[str] = []
|
||||
for i, (s, sid) in enumerate(scopes):
|
||||
params[f"sc{i}"] = s
|
||||
if sid:
|
||||
params[f"sid{i}"] = sid
|
||||
clauses.append(f"(scope = :sc{i} AND scope_id = :sid{i})")
|
||||
else:
|
||||
clauses.append(f"scope = :sc{i}")
|
||||
return " OR ".join(clauses), params
|
||||
|
||||
def touch_structured_memories(self, keys: list[tuple[str, str, str]]) -> int:
|
||||
"""Batch-touch multiple memories by (name, scope, scope_id)."""
|
||||
if not keys:
|
||||
|
||||
@@ -386,6 +386,34 @@ class StorageBackend(Protocol):
|
||||
"""Search structured memories by query. Returns matching memory dicts."""
|
||||
...
|
||||
|
||||
def list_visible_structured_memories(
|
||||
self,
|
||||
scopes: list[tuple[str, str]],
|
||||
mem_type: str = "",
|
||||
limit: int = 100,
|
||||
) -> list[dict[str, str]]:
|
||||
"""List memories matching ANY of the (scope, scope_id) pairs in *scopes*.
|
||||
|
||||
A pair with an empty ``scope_id`` matches the scope alone (used for
|
||||
``("global", "")``). Single SQL query — replaces the per-scope fan-out
|
||||
pattern that issued one query per visible scope.
|
||||
"""
|
||||
...
|
||||
|
||||
def search_visible_structured_memories(
|
||||
self,
|
||||
query: str,
|
||||
scopes: list[tuple[str, str]],
|
||||
mem_type: str = "",
|
||||
limit: int = 20,
|
||||
) -> list[dict[str, str]]:
|
||||
"""OR-of-terms search across memories visible under *scopes*.
|
||||
|
||||
Single SQL query joining the scope OR-group with the term OR-group.
|
||||
Ranking is the caller's job (BM25 downstream).
|
||||
"""
|
||||
...
|
||||
|
||||
def touch_structured_memories(self, keys: list[tuple[str, str, str]]) -> int:
|
||||
"""Batch-touch multiple memories.
|
||||
|
||||
@@ -431,6 +459,68 @@ class StorageBackend(Protocol):
|
||||
"""Update a workstream's state and bump updated timestamp."""
|
||||
...
|
||||
|
||||
def bulk_close_stale_orphans(
|
||||
self,
|
||||
kind: WorkstreamKind | str,
|
||||
cutoff: str,
|
||||
exclude_ws_ids: list[str],
|
||||
live_node_ids: list[str] | None = None,
|
||||
) -> list[str]:
|
||||
"""Close DB-side workstream rows of *kind* whose state is in
|
||||
``BULK_CLOSE_STATE_VALUES`` and whose ``updated`` is lex-older than
|
||||
*cutoff*, excluding rows currently loaded in memory. Sets
|
||||
``state='closed'`` and bumps ``updated``. Returns the list of ws_ids
|
||||
actually transitioned.
|
||||
|
||||
``cutoff`` is a UTC ``YYYY-MM-DDTHH:MM:SS`` string matching the on-disk
|
||||
format ``update_workstream_state`` writes — lex compare is safe for
|
||||
same-offset timestamps. Empty ``exclude_ws_ids`` means no exclusion.
|
||||
|
||||
``live_node_ids`` is the set of ``services.service_id`` values whose
|
||||
``last_heartbeat`` is recent (i.e. owning processes still alive);
|
||||
rows whose ``node_id`` matches one of these are protected because
|
||||
their owning process may legitimately have them loaded on another
|
||||
worker. ``None`` skips the filter entirely (single-process / tests
|
||||
/ operator backfill). Empty list ``[]`` treats every node as dead —
|
||||
useful when operator scripts want to reap regardless of liveness.
|
||||
|
||||
Rows with ``NULL`` ``node_id`` are always eligible: they have no
|
||||
meaningful owner identity, so age alone gates the reap.
|
||||
|
||||
Liveness scoping replaces an earlier ``node_id == self`` heuristic.
|
||||
That heuristic broke in the post-rendezvous-routing world (PR #384):
|
||||
``workstreams.node_id`` is stamped at create time and never updated,
|
||||
so dead-pod orphans in containerized deployments with dynamic
|
||||
hostnames couldn't be reclaimed. ``services.last_heartbeat`` is the
|
||||
rendezvous router's authoritative liveness primitive — using it here
|
||||
keeps reap scoping aligned with routing.
|
||||
|
||||
Asymmetric with ``SessionManager.close_idle``'s in-memory pass on
|
||||
purpose: that pass closes only ``IDLE`` (legitimately-attentive rows
|
||||
stay), this method closes the broader ``BULK_CLOSE_STATE_VALUES`` set
|
||||
because any row matching here is by definition not loaded by any
|
||||
live process and cannot be in a live interaction.
|
||||
"""
|
||||
...
|
||||
|
||||
def touch_workstream(self, ws_id: str) -> None:
|
||||
"""Bump a workstream row's ``updated`` timestamp without touching its
|
||||
state.
|
||||
|
||||
Used by ``SessionManager.open()`` on cold rehydrate so a freshly-
|
||||
loaded row's ``updated`` can't be older than the orphan-reaper cutoff
|
||||
— protects against a same-process race where a parallel
|
||||
``close_idle`` pass-2 snapshots loaded keys after the storage read
|
||||
but before the in-memory install. Distinct from
|
||||
``update_workstream_state(ws_id, current_state)`` because the
|
||||
rehydrate path explicitly avoids a state write (see the
|
||||
``open()`` no-DB-state-flip-on-resurrect comment): a state write
|
||||
could race a concurrent ``close()`` and resurrect a closed row.
|
||||
Bumping only ``updated`` is safe — close still wins on the state
|
||||
column.
|
||||
"""
|
||||
...
|
||||
|
||||
def update_workstream_name(self, ws_id: str, name: str) -> None:
|
||||
"""Update a workstream's display name."""
|
||||
...
|
||||
|
||||
@@ -87,6 +87,9 @@ from turnstone.core.storage._utils import (
|
||||
from turnstone.core.storage._utils import (
|
||||
VERDICT_MUTABLE as _VERDICT_MUTABLE,
|
||||
)
|
||||
from turnstone.core.storage._utils import (
|
||||
normalize_search_terms as _normalize_search_terms,
|
||||
)
|
||||
from turnstone.core.storage._utils import (
|
||||
reconstruct_messages as _reconstruct_messages,
|
||||
)
|
||||
@@ -97,7 +100,7 @@ from turnstone.core.storage._utils import sanitize_text
|
||||
from turnstone.core.storage._utils import (
|
||||
scan_skill_content as _scan_skill_content,
|
||||
)
|
||||
from turnstone.core.workstream import WorkstreamKind
|
||||
from turnstone.core.workstream import BULK_CLOSE_STATE_VALUES, WorkstreamKind
|
||||
|
||||
log = get_logger(__name__)
|
||||
|
||||
@@ -707,6 +710,87 @@ class SQLiteBackend:
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
def bulk_close_stale_orphans(
|
||||
self,
|
||||
kind: WorkstreamKind | str,
|
||||
cutoff: str,
|
||||
exclude_ws_ids: list[str],
|
||||
live_node_ids: list[str] | None = None,
|
||||
) -> list[str]:
|
||||
norm_kind = WorkstreamKind(kind).value
|
||||
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
|
||||
# SQLite has no RETURNING precedent in this file — do SELECT-then-
|
||||
# UPDATE in one transaction, with the SAME WHERE predicates re-applied
|
||||
# to the UPDATE. Re-application defends against a same-process race:
|
||||
# ``SessionManager.open()`` calls ``touch_workstream`` between the
|
||||
# SELECT and the UPDATE could have bumped a row's ``updated`` past
|
||||
# ``cutoff`` (or ``set_state`` could have flipped its state out of
|
||||
# the bulk-close set). Without the re-applied WHERE the UPDATE
|
||||
# closes those rows anyway; with it, the UPDATE skips rows that
|
||||
# became ineligible after the SELECT and the row stays open.
|
||||
# Chunked through ``_in_chunks`` so the ``IN`` clause never exceeds
|
||||
# SQLite's bind-parameter limit (default 999) on a large reap.
|
||||
candidate_conditions = [
|
||||
workstreams.c.kind == norm_kind,
|
||||
workstreams.c.state.in_(BULK_CLOSE_STATE_VALUES),
|
||||
workstreams.c.updated < cutoff,
|
||||
]
|
||||
if live_node_ids is not None and live_node_ids:
|
||||
# Protect rows owned by heartbeating services. NULL node_id is
|
||||
# always eligible. Empty list means "no nodes alive" — every
|
||||
# row is unprotected; the absence of this predicate is
|
||||
# equivalent to "match all," so we just skip it.
|
||||
candidate_conditions.append(
|
||||
sa.or_(
|
||||
workstreams.c.node_id.is_(None),
|
||||
~workstreams.c.node_id.in_(live_node_ids),
|
||||
)
|
||||
)
|
||||
if exclude_ws_ids:
|
||||
candidate_conditions.append(~workstreams.c.ws_id.in_(exclude_ws_ids))
|
||||
select_stmt = sa.select(workstreams.c.ws_id).where(*candidate_conditions)
|
||||
closed: list[str] = []
|
||||
# Match the chunk size used by ``prune_workstreams`` (line 453) — keeps
|
||||
# ``IN`` clauses well below SQLite's default 999-bind-param limit even
|
||||
# on very large reaps.
|
||||
chunk_size = 500
|
||||
with self._conn() as conn:
|
||||
candidate_ids = [row[0] for row in conn.execute(select_stmt)]
|
||||
for i in range(0, len(candidate_ids), chunk_size):
|
||||
chunk = candidate_ids[i : i + chunk_size]
|
||||
# Re-apply the eligibility predicates on the UPDATE so a row
|
||||
# that became fresh between the SELECT and the UPDATE is not
|
||||
# clobbered. Then SELECT back by ``state='closed' AND updated=now``
|
||||
# to determine which rows actually transitioned this commit —
|
||||
# the returned list reflects reality even when re-application
|
||||
# filters out some candidates.
|
||||
conn.execute(
|
||||
sa.update(workstreams)
|
||||
.where(workstreams.c.ws_id.in_(chunk), *candidate_conditions)
|
||||
.values(state="closed", updated=now)
|
||||
)
|
||||
actually_closed = [
|
||||
row[0]
|
||||
for row in conn.execute(
|
||||
sa.select(workstreams.c.ws_id).where(
|
||||
workstreams.c.ws_id.in_(chunk),
|
||||
workstreams.c.state == "closed",
|
||||
workstreams.c.updated == now,
|
||||
)
|
||||
)
|
||||
]
|
||||
closed.extend(actually_closed)
|
||||
conn.commit()
|
||||
return closed
|
||||
|
||||
def touch_workstream(self, ws_id: str) -> None:
|
||||
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
|
||||
with self._conn() as conn:
|
||||
conn.execute(
|
||||
sa.update(workstreams).where(workstreams.c.ws_id == ws_id).values(updated=now)
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
def update_workstream_name(self, ws_id: str, name: str) -> None:
|
||||
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
|
||||
with self._conn() as conn:
|
||||
@@ -3373,7 +3457,10 @@ class SQLiteBackend:
|
||||
limit: int = 100,
|
||||
) -> list[dict[str, str]]:
|
||||
with self._conn() as conn:
|
||||
q = sa.select(structured_memories).order_by(structured_memories.c.updated.desc())
|
||||
q = sa.select(structured_memories).order_by(
|
||||
structured_memories.c.updated.desc(),
|
||||
structured_memories.c.memory_id.asc(),
|
||||
)
|
||||
if mem_type:
|
||||
q = q.where(structured_memories.c.type == mem_type)
|
||||
if scope:
|
||||
@@ -3392,11 +3479,16 @@ class SQLiteBackend:
|
||||
scope_id: str = "",
|
||||
limit: int = 20,
|
||||
) -> list[dict[str, str]]:
|
||||
"""OR-of-terms LIKE search; ranking is the caller's job (BM25 downstream)."""
|
||||
if not query or not query.strip():
|
||||
return self.list_structured_memories(
|
||||
mem_type=mem_type, scope=scope, scope_id=scope_id, limit=limit
|
||||
)
|
||||
terms = query.split()
|
||||
terms = _normalize_search_terms(query)
|
||||
if not terms:
|
||||
return self.list_structured_memories(
|
||||
mem_type=mem_type, scope=scope, scope_id=scope_id, limit=limit
|
||||
)
|
||||
with self._conn() as conn:
|
||||
clauses = []
|
||||
params: dict[str, str] = {}
|
||||
@@ -3410,25 +3502,109 @@ class SQLiteBackend:
|
||||
params[f"n{i}"] = f"%{escaped}%"
|
||||
params[f"d{i}"] = f"%{escaped}%"
|
||||
params[f"c{i}"] = f"%{escaped}%"
|
||||
where = " AND ".join(clauses)
|
||||
term_clause = " OR ".join(clauses)
|
||||
scope_filters = ""
|
||||
if mem_type:
|
||||
where += " AND type = :type_filter"
|
||||
scope_filters += " AND type = :type_filter"
|
||||
params["type_filter"] = mem_type
|
||||
if scope:
|
||||
where += " AND scope = :scope_filter"
|
||||
scope_filters += " AND scope = :scope_filter"
|
||||
params["scope_filter"] = scope
|
||||
if scope_id and scope:
|
||||
where += " AND scope_id = :scope_id_filter"
|
||||
scope_filters += " AND scope_id = :scope_id_filter"
|
||||
params["scope_id_filter"] = scope_id
|
||||
rows = conn.execute(
|
||||
sa.text(
|
||||
f"SELECT * FROM structured_memories WHERE {where} "
|
||||
f"ORDER BY updated DESC LIMIT :lim"
|
||||
f"SELECT * FROM structured_memories WHERE ({term_clause}){scope_filters} "
|
||||
f"ORDER BY updated DESC, memory_id ASC LIMIT :lim"
|
||||
),
|
||||
{**params, "lim": limit},
|
||||
).fetchall()
|
||||
return [dict(r._mapping) for r in rows]
|
||||
|
||||
def list_visible_structured_memories(
|
||||
self,
|
||||
scopes: list[tuple[str, str]],
|
||||
mem_type: str = "",
|
||||
limit: int = 100,
|
||||
) -> list[dict[str, str]]:
|
||||
"""Single-query union across visible (scope, scope_id) pairs."""
|
||||
if not scopes:
|
||||
return []
|
||||
with self._conn() as conn:
|
||||
scope_clauses, params = self._build_scope_or_clause(scopes)
|
||||
extra = ""
|
||||
if mem_type:
|
||||
extra = " AND type = :type_filter"
|
||||
params["type_filter"] = mem_type
|
||||
rows = conn.execute(
|
||||
sa.text(
|
||||
f"SELECT * FROM structured_memories WHERE ({scope_clauses}){extra} "
|
||||
f"ORDER BY updated DESC, memory_id ASC LIMIT :lim"
|
||||
),
|
||||
{**params, "lim": limit},
|
||||
).fetchall()
|
||||
return [dict(r._mapping) for r in rows]
|
||||
|
||||
def search_visible_structured_memories(
|
||||
self,
|
||||
query: str,
|
||||
scopes: list[tuple[str, str]],
|
||||
mem_type: str = "",
|
||||
limit: int = 20,
|
||||
) -> list[dict[str, str]]:
|
||||
"""OR-of-terms search joined with a single visibility OR-group."""
|
||||
if not scopes:
|
||||
return []
|
||||
if not query or not query.strip():
|
||||
return self.list_visible_structured_memories(scopes, mem_type=mem_type, limit=limit)
|
||||
terms = _normalize_search_terms(query)
|
||||
if not terms:
|
||||
return self.list_visible_structured_memories(scopes, mem_type=mem_type, limit=limit)
|
||||
with self._conn() as conn:
|
||||
scope_clauses, params = self._build_scope_or_clause(scopes)
|
||||
term_clauses = []
|
||||
for i, t in enumerate(terms):
|
||||
escaped = _escape_like(t)
|
||||
term_clauses.append(
|
||||
f"(name LIKE :n{i} ESCAPE '\\' "
|
||||
f"OR description LIKE :d{i} ESCAPE '\\' "
|
||||
f"OR content LIKE :c{i} ESCAPE '\\')"
|
||||
)
|
||||
params[f"n{i}"] = f"%{escaped}%"
|
||||
params[f"d{i}"] = f"%{escaped}%"
|
||||
params[f"c{i}"] = f"%{escaped}%"
|
||||
term_clause = " OR ".join(term_clauses)
|
||||
extra = ""
|
||||
if mem_type:
|
||||
extra = " AND type = :type_filter"
|
||||
params["type_filter"] = mem_type
|
||||
rows = conn.execute(
|
||||
sa.text(
|
||||
f"SELECT * FROM structured_memories "
|
||||
f"WHERE ({scope_clauses}) AND ({term_clause}){extra} "
|
||||
f"ORDER BY updated DESC, memory_id ASC LIMIT :lim"
|
||||
),
|
||||
{**params, "lim": limit},
|
||||
).fetchall()
|
||||
return [dict(r._mapping) for r in rows]
|
||||
|
||||
@staticmethod
|
||||
def _build_scope_or_clause(
|
||||
scopes: list[tuple[str, str]],
|
||||
) -> tuple[str, dict[str, str]]:
|
||||
"""Build a parameterized OR-group of (scope[, scope_id]) predicates."""
|
||||
params: dict[str, str] = {}
|
||||
clauses: list[str] = []
|
||||
for i, (s, sid) in enumerate(scopes):
|
||||
params[f"sc{i}"] = s
|
||||
if sid:
|
||||
params[f"sid{i}"] = sid
|
||||
clauses.append(f"(scope = :sc{i} AND scope_id = :sid{i})")
|
||||
else:
|
||||
clauses.append(f"scope = :sc{i}")
|
||||
return " OR ".join(clauses), params
|
||||
|
||||
def touch_structured_memories(self, keys: list[tuple[str, str, str]]) -> int:
|
||||
"""Batch-touch multiple memories by (name, scope, scope_id)."""
|
||||
if not keys:
|
||||
|
||||
@@ -5,6 +5,7 @@ from __future__ import annotations
|
||||
import base64
|
||||
import contextlib
|
||||
import json
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from turnstone.core.attachments import unreadable_placeholder
|
||||
@@ -48,6 +49,39 @@ def _attachment_to_content_part(att: dict[str, Any]) -> dict[str, Any] | None:
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Search-term normalization
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Composition can hand a multi-KB pasted user message to ILIKE-based search;
|
||||
# without a cap, every distinct token would emit one unindexable predicate
|
||||
# per scope-fanned query, producing hundreds of seq-scan clauses on a single
|
||||
# rebuild. Cap + dedupe + length filter keeps the SQL bounded.
|
||||
_MAX_SEARCH_TERMS = 16
|
||||
_MIN_TERM_LEN = 2
|
||||
|
||||
# Streaming tokenizer — finditer doesn't allocate a full list up front,
|
||||
# so a multi-KB pasted query stops being scanned the moment the cap is
|
||||
# hit instead of after splitting every token.
|
||||
_TOKEN_RE = re.compile(r"\S+")
|
||||
|
||||
|
||||
def normalize_search_terms(query: str) -> list[str]:
|
||||
"""De-dupe (case-insensitive), drop short tokens, and cap at MAX terms."""
|
||||
seen: set[str] = set()
|
||||
terms: list[str] = []
|
||||
for match in _TOKEN_RE.finditer(query):
|
||||
raw = match.group()
|
||||
lowered = raw.lower()
|
||||
if len(lowered) < _MIN_TERM_LEN or lowered in seen:
|
||||
continue
|
||||
seen.add(lowered)
|
||||
terms.append(raw)
|
||||
if len(terms) >= _MAX_SEARCH_TERMS:
|
||||
break
|
||||
return terms
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Text sanitization
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
"""Add a partial composite index for the orphan-reaper query.
|
||||
|
||||
``StorageBackend.bulk_close_stale_orphans`` (introduced alongside the
|
||||
workstream-lifecycle leak fix) runs every ``min(300s, idle_timeout/4)``
|
||||
on every server and console process. Its WHERE shape is:
|
||||
|
||||
WHERE kind = ?
|
||||
AND state IN ('idle', 'thinking', 'attention', 'running')
|
||||
AND updated < ?
|
||||
AND (node_id IS NULL OR node_id NOT IN (alive_service_ids))
|
||||
|
||||
At current scale (low-thousands of workstream rows) the existing single-
|
||||
column indexes are sufficient — ``idx_workstreams_state`` prunes to the
|
||||
non-closed subset, and the planner filters the rest sequentially. At
|
||||
100k+ rows that filter becomes a tablescan-shaped cost on the reaper's
|
||||
periodic run.
|
||||
|
||||
A **partial** index covering only ``BULK_CLOSE_STATE_VALUES`` rows
|
||||
matches the reaper's query exactly while staying tiny — closed rows
|
||||
(typically 95%+ of the table per empirical diagnosis) and ``error``
|
||||
rows are excluded, so the index is roughly 5% the size a full multi-
|
||||
column index would be. Write amplification only kicks in for
|
||||
transitions that touch one of the four covered states.
|
||||
|
||||
Column order ``(kind, updated)``:
|
||||
|
||||
- ``kind`` first because the reaper always supplies it as an equality
|
||||
predicate; partitions the partial index into interactive vs
|
||||
coordinator subtrees.
|
||||
- ``updated`` last so the range comparison rides the trailing column —
|
||||
classic composite-index pattern for ``WHERE eq AND range``.
|
||||
|
||||
``node_id`` is intentionally NOT in the index. The reaper's predicate
|
||||
on it is ``NOT IN (small list)`` against an unbounded-cardinality
|
||||
column, which planners don't index well; including it would just add
|
||||
write cost for negligible read benefit.
|
||||
|
||||
PostgreSQL uses ``CREATE INDEX CONCURRENTLY`` so the build is
|
||||
non-blocking on a live system; SQLite has no concurrent concept and
|
||||
the table-level write lock already serializes, so a plain
|
||||
``CREATE INDEX`` is fine.
|
||||
|
||||
Revision ID: 048
|
||||
Revises: 047
|
||||
Create Date: 2026-04-30
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = "048"
|
||||
down_revision = "047"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
_REAPER_PARTIAL_WHERE = "state IN ('idle', 'thinking', 'attention', 'running')"
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
dialect = bind.dialect.name
|
||||
|
||||
if dialect == "postgresql":
|
||||
with op.get_context().autocommit_block():
|
||||
op.execute(
|
||||
"CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_workstreams_reaper "
|
||||
"ON workstreams (kind, updated) "
|
||||
f"WHERE {_REAPER_PARTIAL_WHERE}"
|
||||
)
|
||||
else:
|
||||
op.create_index(
|
||||
"idx_workstreams_reaper",
|
||||
"workstreams",
|
||||
["kind", "updated"],
|
||||
sqlite_where=sa.text(_REAPER_PARTIAL_WHERE),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
dialect = bind.dialect.name
|
||||
|
||||
if dialect == "postgresql":
|
||||
with op.get_context().autocommit_block():
|
||||
op.execute("DROP INDEX CONCURRENTLY IF EXISTS idx_workstreams_reaper")
|
||||
else:
|
||||
op.drop_index("idx_workstreams_reaper", table_name="workstreams")
|
||||
@@ -77,6 +77,25 @@ class WorkstreamState(enum.Enum):
|
||||
ERROR = "error" # last operation failed
|
||||
|
||||
|
||||
# States the orphan reaper (``SessionManager.close_idle`` pass 2 +
|
||||
# ``StorageBackend.bulk_close_stale_orphans``) is allowed to flip to
|
||||
# ``closed`` for rows past the staleness cutoff. Excludes ``ERROR``
|
||||
# deliberately — error rows are user-investigatable and shouldn't be
|
||||
# auto-reaped — and excludes ``CLOSED`` (terminal). Centralized here
|
||||
# so the storage backends and FakeStorage all agree; if a new transient
|
||||
# state is added to ``WorkstreamState``, deciding whether it joins
|
||||
# this set is part of the change rather than an after-the-fact
|
||||
# audit across three files.
|
||||
BULK_CLOSE_STATE_VALUES: frozenset[str] = frozenset(
|
||||
{
|
||||
WorkstreamState.IDLE.value,
|
||||
WorkstreamState.THINKING.value,
|
||||
WorkstreamState.RUNNING.value,
|
||||
WorkstreamState.ATTENTION.value,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Workstream dataclass
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -139,6 +139,12 @@ class NullUI:
|
||||
def on_error(self, message: str) -> None:
|
||||
pass
|
||||
|
||||
def on_user_reminder(self, reminders: list[dict[str, str]]) -> None:
|
||||
pass
|
||||
|
||||
def on_tool_reminder(self, reminders: list[dict[str, str]], tool_call_id: str) -> None:
|
||||
pass
|
||||
|
||||
def on_state_change(self, state: str) -> None:
|
||||
pass
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
You are a coordinator on a small, focused infrastructure team. Your role is to orchestrate work across the cluster: you decompose a user's request into tasks, spawn child workstreams on appropriate nodes with the right skills, monitor their progress, synthesise their results, and surface the outcome back to the user.
|
||||
|
||||
You do not edit files, run shell commands, browse the web, or manipulate the codebase directly. Children do that. Your job is to pick the right child, give it a well-formed brief, and keep the plan coherent while multiple children run in parallel.
|
||||
You do not edit files, run shells, or browse the web — children do. You pick the right child, give a well-formed brief, and keep the plan coherent while multiple children run.
|
||||
|
||||
You think in plans: a tasks entry, a child to own it, a way to know when it's done. When a child reports back, you read what it said, decide whether the goal is met, and either close it out, push a follow-up message, or spawn another child to cover the gap.
|
||||
You think in plans: enumerate the independent units of work, spawn one child per unit, run them in parallel by default. Sequential only when one child's output feeds the next. When a child reports back, you decide whether the goal is met, then close it out, push a follow-up, or spawn another child to cover the gap.
|
||||
|
||||
You are precise about what you delegate. A child gets the minimum context it needs — skill, initial_message, maybe a node_id. You don't paste whole files into its prompt; children have their own tools for that.
|
||||
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
TOOL PATTERNS:
|
||||
|
||||
You are a coordinator. You do not edit files, run shell commands, or browse the web directly. You delegate work by spawning child workstreams on cluster nodes, monitoring their progress, and synthesising their results. Every tool below is in your schema; nothing else is.
|
||||
|
||||
Discover available capacity → list_nodes / list_skills:
|
||||
list_nodes(filters={'capability': 'gpu'})
|
||||
list_skills(category='engineering')
|
||||
@@ -10,11 +8,11 @@ Delegate a task → spawn_workstream:
|
||||
spawn_workstream(initial_message='audit auth.py for CSRF handling', name='csrf-audit')
|
||||
spawn_workstream(initial_message='compare FastAPI vs Starlette for async websockets', target_node='flat-blck-io_43a3')
|
||||
|
||||
Fan out to multiple children in one approval → spawn_batch (up to 10):
|
||||
Fan out across independent inputs → spawn_batch:
|
||||
spawn_batch(children=[
|
||||
{'initial_message': 'benchmark A'},
|
||||
{'initial_message': 'benchmark B'},
|
||||
{'initial_message': 'prototype the winner'},
|
||||
{'initial_message': 'top stories on Hacker News'},
|
||||
{'initial_message': 'top stories on Lobsters'},
|
||||
{'initial_message': 'top stories on r/programming'},
|
||||
])
|
||||
|
||||
Check on a child → inspect_workstream:
|
||||
@@ -24,8 +22,9 @@ Wait for spawned children to finish → wait_for_workstream (PREFER over busy-po
|
||||
wait_for_workstream(ws_ids=['a1b2c3d4'], timeout=120)
|
||||
wait_for_workstream(ws_ids=['a1b2c3d4', 'e5f6g7h8', 'i9j0k1l2'], mode='all', timeout=300)
|
||||
|
||||
Push a follow-up message to a running child → send_to_workstream:
|
||||
Push a follow-up message to a child → send_to_workstream (mid-run nudge, or course-correct a child that drifted off-brief):
|
||||
send_to_workstream(ws_id='a1b2c3d4', message='also capture the test-coverage delta')
|
||||
send_to_workstream(ws_id='a1b2c3d4', message='stop — you are editing auth_legacy.py, the active path is auth.py')
|
||||
|
||||
List what you've spawned → list_workstreams:
|
||||
list_workstreams()
|
||||
@@ -38,19 +37,10 @@ Wind a child down → close_workstream (soft; session stops, storage kept) or de
|
||||
close_workstream(ws_id='a1b2c3d4', reason='task complete')
|
||||
delete_workstream(ws_id='a1b2c3d4')
|
||||
|
||||
Wind all direct children down at once → close_all_children (soft-close cascade, single approval):
|
||||
Wind all direct children down at once → close_all_children (soft-close cascade):
|
||||
close_all_children(reason='batch complete, synthesising results')
|
||||
|
||||
Plan and track work → tasks (your scratchpad; children don't see it):
|
||||
tasks(action='add', title='audit auth.py for CSRF')
|
||||
tasks(action='update', task_id='t_03', status='in_progress')
|
||||
tasks(action='list')
|
||||
tasks(action='remove', task_id='t_03')
|
||||
|
||||
## Workflow shape
|
||||
|
||||
Prefer: tasks to plan → spawn_workstream to delegate → wait_for_workstream to block on completion → inspect_workstream to read the final message → synthesise → close_workstream.
|
||||
|
||||
Each repeated `inspect_workstream` poll costs a full assistant turn (+ judge + tokens); a single `wait_for_workstream` absorbs the wait at one call + one result. The cost gap widens fast on fan-outs of 3+ children.
|
||||
|
||||
If a user asks you to "edit X" or "run Y", spawn a child and delegate — the coordinator's tool schema doesn't include file or shell access by design.
|
||||
|
||||
+409
-18
@@ -53,6 +53,15 @@ from turnstone.core.auth import (
|
||||
_DenyFilter,
|
||||
jwt_version_slot,
|
||||
)
|
||||
from turnstone.core.history_decoration import (
|
||||
TOOL_RESULT_STORAGE_CAP,
|
||||
)
|
||||
from turnstone.core.history_decoration import (
|
||||
decorate_tool_call as _decorate_tool_call,
|
||||
)
|
||||
from turnstone.core.history_decoration import (
|
||||
load_verdict_indexes as _load_verdict_indexes,
|
||||
)
|
||||
from turnstone.core.log import get_logger
|
||||
from turnstone.core.metrics import metrics as _metrics
|
||||
from turnstone.core.ratelimit import resolve_client_ip
|
||||
@@ -194,6 +203,14 @@ class WebUI(SessionUIBase):
|
||||
}
|
||||
if state == "idle":
|
||||
event["content"] = payload["content"]
|
||||
# ``pending_approval_detail`` is NO LONGER piggybacked on
|
||||
# state-change events (Stage 3 cleanup). Symmetric event
|
||||
# flow now: initial approval items arrive via bulk fetch
|
||||
# triggered by the ``activity_state="approval"`` transition,
|
||||
# individual verdicts via the explicit
|
||||
# ``intent_verdict`` event class, and resolution via
|
||||
# ``approval_resolved``. Reducer no longer has to dedupe
|
||||
# the piggyback path against the explicit one.
|
||||
try:
|
||||
WebUI._global_queue.put_nowait(event)
|
||||
except queue.Full:
|
||||
@@ -218,6 +235,73 @@ class WebUI(SessionUIBase):
|
||||
}
|
||||
)
|
||||
|
||||
def _broadcast_intent_verdict(self, verdict: dict[str, Any]) -> None:
|
||||
"""Send an LLM intent-judge verdict to the global SSE channel.
|
||||
|
||||
Stage 3 Step 5 — the cluster collector's ``_apply_delta``
|
||||
forwards this verbatim to the cluster bus, where coord
|
||||
adapters dispatch it as ``child_ws_intent_verdict`` for the
|
||||
owning parent's tree UI. Unlike the existing
|
||||
``pending_approval_detail`` piggyback on ``ws_state``, this
|
||||
fires WHENEVER a verdict lands — including the common case
|
||||
where the judge daemon writes during ``attention`` with no
|
||||
state transition to ride along on.
|
||||
"""
|
||||
if WebUI._global_queue is not None:
|
||||
with contextlib.suppress(queue.Full):
|
||||
WebUI._global_queue.put_nowait(
|
||||
{
|
||||
"type": "intent_verdict",
|
||||
"ws_id": self.ws_id,
|
||||
"verdict": verdict,
|
||||
}
|
||||
)
|
||||
|
||||
def _broadcast_approval_resolved(
|
||||
self,
|
||||
approved: bool,
|
||||
feedback: str | None = None,
|
||||
*,
|
||||
always: bool = False,
|
||||
) -> None:
|
||||
"""Send an ``approval_resolved`` decision to the global SSE channel.
|
||||
|
||||
Clears the parent's pending-approval pill in lockstep with
|
||||
the actual decision rather than waiting for the next
|
||||
state-change piggyback.
|
||||
"""
|
||||
if WebUI._global_queue is not None:
|
||||
with contextlib.suppress(queue.Full):
|
||||
WebUI._global_queue.put_nowait(
|
||||
{
|
||||
"type": "approval_resolved",
|
||||
"ws_id": self.ws_id,
|
||||
"approved": approved,
|
||||
"feedback": feedback or "",
|
||||
"always": bool(always),
|
||||
}
|
||||
)
|
||||
|
||||
def _broadcast_approve_request(self, detail: dict[str, Any]) -> None:
|
||||
"""Send an ``approve_request`` payload to the global SSE channel.
|
||||
|
||||
Push path for the initial approval items so a coord parent's
|
||||
tree UI can render the inline approve/deny block immediately
|
||||
without waiting for a bulk-fetch round-trip. The bulk fetch
|
||||
races with ``_pending_approval`` being set inside
|
||||
``approve_tools`` (the state transition to ATTENTION fires
|
||||
upstream first); the push path eliminates that race entirely.
|
||||
"""
|
||||
if WebUI._global_queue is not None:
|
||||
with contextlib.suppress(queue.Full):
|
||||
WebUI._global_queue.put_nowait(
|
||||
{
|
||||
"type": "approve_request",
|
||||
"ws_id": self.ws_id,
|
||||
"detail": detail,
|
||||
}
|
||||
)
|
||||
|
||||
# --- SessionUI protocol ---
|
||||
#
|
||||
# ``on_thinking_start`` / ``on_thinking_stop`` / ``on_reasoning_token``
|
||||
@@ -333,8 +417,20 @@ class WebUI(SessionUIBase):
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
# Verdict + output-assessment decoration helpers (``_decorate_tool_call``,
|
||||
# ``_load_verdict_indexes``) are imported at module top alongside the
|
||||
# rest of ``turnstone.core.*``. Both this builder and
|
||||
# :func:`make_history_handler` (the /history REST endpoint coord uses
|
||||
# as its primary history loader) share them so the two surfaces don't
|
||||
# drift on the wire shape they emit.
|
||||
|
||||
|
||||
def _build_history(
|
||||
session: ChatSession, has_pending_approval: bool = False
|
||||
session: ChatSession,
|
||||
has_pending_approval: bool = False,
|
||||
*,
|
||||
verdicts: dict[str, dict[str, Any]] | None = None,
|
||||
assessments: dict[str, dict[str, Any]] | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Build a history replay list from ChatSession messages.
|
||||
|
||||
@@ -346,7 +442,35 @@ def _build_history(
|
||||
``"denied": True``, and the corresponding assistant entry that
|
||||
issued the tool calls is also marked ``"denied": True`` so the
|
||||
client can render the correct badge.
|
||||
|
||||
``verdicts`` and ``assessments`` are optional pre-loaded
|
||||
``{call_id → row}`` dicts (see :func:`_load_verdict_indexes`).
|
||||
Async callers should pre-load via ``asyncio.to_thread`` and pass
|
||||
them in to avoid blocking the event loop on storage I/O. When
|
||||
omitted, the storage call runs inline (sync call sites).
|
||||
"""
|
||||
# Metacognitive nudges live on the message dict's ``_reminders``
|
||||
# side-channel — user messages carry user-channel nudges
|
||||
# (correction / denial / resume / start / completion), tool
|
||||
# messages carry tool-channel nudges (tool_error / repeat). Both
|
||||
# are surfaced separately on each entry so the UI can render them
|
||||
# as their own bubble (live via ``user_reminder`` /
|
||||
# ``tool_reminder`` SSE events; replay via this propagation).
|
||||
# ``content`` never carries the ``<system-reminder>`` envelope —
|
||||
# that splice is transient, applied to a wire-bound copy in
|
||||
# ``ChatSession._apply_reminders_for_provider``.
|
||||
#
|
||||
# Verdict + output-assessment lookup tables — populated either
|
||||
# inline (sync call sites) or pre-loaded by an async caller via
|
||||
# asyncio.to_thread (see _load_verdict_indexes). Pre-loading is
|
||||
# what keeps _build_history off the event loop's hot path on the
|
||||
# SSE replay generator path.
|
||||
if verdicts is not None and assessments is not None:
|
||||
verdicts_by_call_id = verdicts
|
||||
assessments_by_call_id = assessments
|
||||
else:
|
||||
ws_id = getattr(session, "_ws_id", "") or ""
|
||||
verdicts_by_call_id, assessments_by_call_id = _load_verdict_indexes(ws_id)
|
||||
history = []
|
||||
for msg in session.messages:
|
||||
content = msg.get("content")
|
||||
@@ -392,18 +516,64 @@ def _build_history(
|
||||
entry = {"role": msg["role"], "content": content}
|
||||
if attachments_meta:
|
||||
entry["attachments"] = attachments_meta
|
||||
# Surface the ``_reminders`` side-channel so a tab reconnecting
|
||||
# via /history renders the same metacognitive nudge bubble the
|
||||
# originating tab saw live (user-channel reminders via
|
||||
# ``user_reminder`` SSE; tool-channel via ``tool_reminder``).
|
||||
# Reminders are in-memory only (not persisted to DB), so this
|
||||
# only fires for the originating session.
|
||||
reminders = msg.get("_reminders")
|
||||
if isinstance(reminders, list):
|
||||
# Filter first so an all-malformed _reminders doesn't set the
|
||||
# field to []; absent vs. empty-list should mean the same
|
||||
# thing on the wire.
|
||||
clean_reminders = [
|
||||
{"type": str(r.get("type") or ""), "text": str(r.get("text") or "")}
|
||||
for r in reminders
|
||||
if isinstance(r, dict)
|
||||
]
|
||||
if clean_reminders:
|
||||
entry["reminders"] = clean_reminders
|
||||
if msg.get("tool_calls"):
|
||||
entry["tool_calls"] = [
|
||||
{
|
||||
"id": tc.get("id", ""),
|
||||
tc_entries: list[dict[str, Any]] = []
|
||||
for tc in msg["tool_calls"]:
|
||||
tc_entry: dict[str, Any] = {
|
||||
"id": tc.get("id", "") or "",
|
||||
"name": tc["function"]["name"],
|
||||
"arguments": tc["function"].get("arguments", ""),
|
||||
}
|
||||
for tc in msg["tool_calls"]
|
||||
]
|
||||
# Decorate with persisted verdict + output_assessment
|
||||
# via the shared helper (also used by
|
||||
# ``make_history_handler``). Skips unflagged
|
||||
# ("risk_level == 'none'") rows so the wire stays
|
||||
# tight; ships only the fields the UI renders.
|
||||
_decorate_tool_call(
|
||||
tc_entry,
|
||||
verdicts_by_call_id,
|
||||
assessments_by_call_id,
|
||||
)
|
||||
tc_entries.append(tc_entry)
|
||||
entry["tool_calls"] = tc_entries
|
||||
# Detect denied/blocked/errored tool results by their content prefix.
|
||||
if msg.get("role") == "tool":
|
||||
content = msg.get("content", "")
|
||||
# Propagate tool_call_id so replayHistory can anchor the
|
||||
# rendered output to the specific .ts-approval-tool element
|
||||
# by data-call-id (mirrors the live appendToolOutput path).
|
||||
# Without this, multi-tool batches render every result at
|
||||
# the bottom of the block rather than under each header.
|
||||
result_call_id = msg.get("tool_call_id")
|
||||
if result_call_id:
|
||||
entry["tool_call_id"] = str(result_call_id)
|
||||
# Tool results are clamped to TOOL_RESULT_STORAGE_CAP
|
||||
# chars per row at storage time (session.py). Surface
|
||||
# that on replay so the user knows the visible output is
|
||||
# a clipped view of what the live session saw, rather
|
||||
# than the full result. Reference the shared constant
|
||||
# rather than a literal so the UI pill logic can't
|
||||
# silently desync if the cap ever changes.
|
||||
if isinstance(content, str) and len(content) >= TOOL_RESULT_STORAGE_CAP:
|
||||
entry["truncated"] = True
|
||||
if isinstance(content, str):
|
||||
if content.startswith("Denied by user") or content.startswith("Blocked"):
|
||||
entry["denied"] = True
|
||||
@@ -644,6 +814,31 @@ def _audit_close_workstream(
|
||||
)
|
||||
|
||||
|
||||
async def _interactive_events_replay_prepare(ws: Workstream, ui: Any, request: Request) -> None:
|
||||
"""Async pre-step run before ``_interactive_events_replay`` iterates.
|
||||
|
||||
Loads ``intent_verdicts`` + ``output_assessments`` for the
|
||||
workstream off the event loop (via ``asyncio.to_thread``) and
|
||||
stashes the result on ``request.state.verdict_indexes``. The sync
|
||||
replay generator reads from there and passes the dicts into
|
||||
``_build_history`` so the storage I/O never blocks the event loop
|
||||
on the SSE replay path.
|
||||
|
||||
Best-effort: if the workstream has no session or no ws_id, leaves
|
||||
``request.state.verdict_indexes`` unset and ``_build_history``
|
||||
falls back to the inline storage call (sync path).
|
||||
"""
|
||||
del ui # not needed; lookup is keyed on ws.session._ws_id
|
||||
session = ws.session
|
||||
if session is None:
|
||||
return
|
||||
ws_id = getattr(session, "_ws_id", "") or ""
|
||||
if not ws_id:
|
||||
return
|
||||
indexes = await asyncio.to_thread(_load_verdict_indexes, ws_id)
|
||||
request.state.verdict_indexes = indexes
|
||||
|
||||
|
||||
def _interactive_events_replay(
|
||||
ws: Workstream, ui: Any, request: Request
|
||||
) -> Iterable[dict[str, Any]]:
|
||||
@@ -661,7 +856,6 @@ def _interactive_events_replay(
|
||||
|
||||
Pure read — never mutates ``ws`` / ``ui`` / ``session``.
|
||||
"""
|
||||
del request # not needed; replay reads ws/ui/session state
|
||||
session = ws.session
|
||||
if session is None:
|
||||
# Defensive — the lifted body's UI presence check guarantees
|
||||
@@ -676,9 +870,22 @@ def _interactive_events_replay(
|
||||
|
||||
# History replay — pending-approval flag rides on the last
|
||||
# assistant entry's tool_calls so the client renders them as
|
||||
# awaiting approval rather than already approved.
|
||||
# awaiting approval rather than already approved. Verdict /
|
||||
# assessment indexes were pre-loaded off the event loop by
|
||||
# _interactive_events_replay_prepare; passing them in here keeps
|
||||
# _build_history's storage I/O out of the sync generator path.
|
||||
pending_approval = getattr(ui, "_pending_approval", None)
|
||||
history = _build_history(session, has_pending_approval=pending_approval is not None)
|
||||
cached_indexes = getattr(request.state, "verdict_indexes", None)
|
||||
if isinstance(cached_indexes, tuple) and len(cached_indexes) == 2:
|
||||
verdicts, assessments = cached_indexes
|
||||
else:
|
||||
verdicts, assessments = None, None
|
||||
history = _build_history(
|
||||
session,
|
||||
has_pending_approval=pending_approval is not None,
|
||||
verdicts=verdicts,
|
||||
assessments=assessments,
|
||||
)
|
||||
if history:
|
||||
yield {"type": "history", "messages": history}
|
||||
|
||||
@@ -823,6 +1030,16 @@ def _build_node_snapshot(app_state: Any) -> dict[str, Any]:
|
||||
title = ""
|
||||
if ws.session:
|
||||
title = get_workstream_display_name(ws.session.ws_id) or ""
|
||||
# ``pending_approval_detail`` mirrors the dashboard handler's
|
||||
# projection so the console collector's reconnect-via-snapshot
|
||||
# path (``_reconcile_node``) can carry the rich approval payload
|
||||
# across reconnects — without it, a child sitting in approval-
|
||||
# pending across a console restart or network blip would render
|
||||
# with no buttons until the next state change. Same data, same
|
||||
# ``read`` scope as ``/v1/api/dashboard``.
|
||||
approval_detail: dict[str, Any] | None = None
|
||||
if ui is not None and hasattr(ui, "serialize_pending_approval_detail"):
|
||||
approval_detail = ui.serialize_pending_approval_detail()
|
||||
ws_list.append(
|
||||
{
|
||||
"id": ws.id,
|
||||
@@ -839,6 +1056,7 @@ def _build_node_snapshot(app_state: Any) -> dict[str, Any]:
|
||||
"kind": ws.kind,
|
||||
"parent_ws_id": ws.parent_ws_id,
|
||||
"user_id": ws.user_id,
|
||||
"pending_approval_detail": approval_detail,
|
||||
}
|
||||
)
|
||||
return {
|
||||
@@ -1342,13 +1560,13 @@ async def command(request: Request) -> JSONResponse:
|
||||
ui._enqueue({"type": "clear_ui"})
|
||||
elif cmd_word == "/resume":
|
||||
ui._enqueue({"type": "clear_ui"})
|
||||
history = _build_history(ws.session)
|
||||
history = await asyncio.to_thread(_build_history, ws.session)
|
||||
if history:
|
||||
ui._enqueue({"type": "history", "messages": history})
|
||||
elif cmd_word in ("/rewind", "/retry"):
|
||||
# Refresh frontend with truncated history
|
||||
ui._enqueue({"type": "clear_ui"})
|
||||
history = _build_history(ws.session)
|
||||
history = await asyncio.to_thread(_build_history, ws.session)
|
||||
if history:
|
||||
ui._enqueue({"type": "history", "messages": history})
|
||||
# Audit trail
|
||||
@@ -1823,7 +2041,7 @@ async def _interactive_create_post_install(
|
||||
ui = ws.ui
|
||||
if isinstance(ui, WebUI):
|
||||
ui._enqueue({"type": "clear_ui"})
|
||||
history = _build_history(ws.session)
|
||||
history = await asyncio.to_thread(_build_history, ws.session)
|
||||
if history:
|
||||
ui._enqueue({"type": "history", "messages": history})
|
||||
with contextlib.suppress(queue.Full):
|
||||
@@ -2749,13 +2967,14 @@ def internal_model_reload(request: Request) -> JSONResponse:
|
||||
if registry is None or cli_args is None:
|
||||
return JSONResponse({"status": "error", "reason": "no registry"}, status_code=503)
|
||||
|
||||
storage = get_storage()
|
||||
new_registry = load_model_registry(
|
||||
base_url=cli_args["base_url"],
|
||||
api_key=cli_args["api_key"],
|
||||
model=cli_args["model"],
|
||||
context_window=cli_args["context_window"],
|
||||
provider=cli_args["provider"],
|
||||
storage=get_storage(),
|
||||
storage=storage,
|
||||
)
|
||||
cs = getattr(request.app.state, "config_store", None)
|
||||
if cs is not None:
|
||||
@@ -2821,6 +3040,13 @@ def internal_model_reload(request: Request) -> JSONResponse:
|
||||
# `model` parameter descriptions reflect the current registry.
|
||||
_broadcast_agent_tool_schema_refresh(request.app.state)
|
||||
|
||||
# Refresh the per-node ``models`` metadata entry the coord reads on
|
||||
# ``list_nodes``. Without this, the heartbeat loop's 30s tick would
|
||||
# be the coord's first chance to see new aliases an admin just added.
|
||||
node_id = getattr(request.app.state, "node_id", "")
|
||||
if node_id:
|
||||
_publish_models_metadata(request.app.state, storage, node_id)
|
||||
|
||||
return JSONResponse({"status": "ok", "aliases": registry.list_aliases()})
|
||||
|
||||
|
||||
@@ -2846,6 +3072,109 @@ def internal_model_status(request: Request) -> JSONResponse:
|
||||
return JSONResponse({"models": models})
|
||||
|
||||
|
||||
def _collect_node_models_metadata(app_state: Any) -> tuple[str, str, str] | None:
|
||||
"""Build the ``("models", json_value, "auto")`` node_metadata entry.
|
||||
|
||||
Each model alias on the live registry is projected to
|
||||
``{alias, provider, healthy}`` — the alias is what the coordinator
|
||||
passes back as ``spawn_workstream(model=...)``, ``provider`` lets
|
||||
coordinators classify or filter (e.g. "any anthropic node"), and
|
||||
``healthy`` reflects the backend's :class:`BackendHealthTracker`
|
||||
state at call time. The underlying model identifier (``cfg.model``)
|
||||
is intentionally omitted — coordinators kept reaching for the
|
||||
provider-side string when they should have been passing the local
|
||||
alias, and dropping it removes the footgun. Operators who need
|
||||
the model string can hit ``/v1/api/_internal/model-status`` on the
|
||||
node directly.
|
||||
|
||||
Trackers are eagerly seeded for every alias at server startup and
|
||||
on every model-reload, so ``health_reg.get_tracker(...)`` returns
|
||||
the existing tracker rather than minting a fresh one in steady
|
||||
state. In the unlikely race where a tracker hasn't been seeded
|
||||
yet, the freshly created tracker reports ``is_healthy=True``
|
||||
(default state) — which matches the prior "default to True when
|
||||
no tracker" behavior, just routed through the tracker object.
|
||||
|
||||
Returns ``None`` when the registry has not yet been built (caller
|
||||
should skip the write rather than zero out a previous snapshot).
|
||||
"""
|
||||
registry = getattr(app_state, "registry", None)
|
||||
if registry is None:
|
||||
return None
|
||||
health_reg = getattr(app_state, "health_registry", None)
|
||||
aliases_info: list[dict[str, Any]] = []
|
||||
# Iterate aliases in a stable order — ``list_aliases`` returns dict
|
||||
# insertion order, so two structurally identical registries built
|
||||
# from different sources (config.toml vs. DB rows in different
|
||||
# commit order) would otherwise serialize to different JSON and
|
||||
# defeat the publish-cache hit-rate that the
|
||||
# ``turnstone_node_models_publish_total`` metric tracks.
|
||||
for alias in sorted(registry.list_aliases()):
|
||||
try:
|
||||
cfg = registry.get_config(alias)
|
||||
except (ValueError, KeyError):
|
||||
continue
|
||||
healthy = True
|
||||
if health_reg is not None:
|
||||
# Direct keyed lookup — ``get_tracker_for_alias`` would
|
||||
# do a second ``registry.get_config(alias)`` internally,
|
||||
# but ``cfg`` is already in hand here.
|
||||
tracker = health_reg.get_tracker(provider=cfg.provider, base_url=cfg.base_url)
|
||||
healthy = tracker.is_healthy
|
||||
aliases_info.append(
|
||||
{
|
||||
"alias": alias,
|
||||
"provider": cfg.provider,
|
||||
"healthy": healthy,
|
||||
}
|
||||
)
|
||||
return ("models", json.dumps(aliases_info), "auto")
|
||||
|
||||
|
||||
def _publish_models_metadata(app_state: Any, storage: Any, node_id: str) -> None:
|
||||
"""Refresh the per-node ``models`` row when the projection changed.
|
||||
|
||||
Caches the last-written JSON on ``app_state._last_models_payload``
|
||||
so back-to-back heartbeat ticks with no health flip don't churn
|
||||
the row — without this, the ``updated`` timestamp on every node's
|
||||
``models`` row advances every 30s across the whole cluster.
|
||||
|
||||
Records the cache outcome on the metrics collector so
|
||||
``turnstone_node_models_publish_total{outcome=...}`` exposes the
|
||||
hit/miss ratio to Prometheus. Storage-error attempts don't
|
||||
record either outcome — the next call will retry and the
|
||||
counters reflect actual cache decisions, not transient DB
|
||||
failures.
|
||||
|
||||
Sync — callers on the asyncio loop wrap with ``asyncio.to_thread``.
|
||||
Concurrent callers (heartbeat tick vs. ``internal_model_reload``)
|
||||
can race on the cache attribute; the worst case is a redundant
|
||||
write, never a stale row, so we skip the lock.
|
||||
"""
|
||||
from turnstone.core.storage._registry import StorageUnavailableError
|
||||
|
||||
try:
|
||||
entry = _collect_node_models_metadata(app_state)
|
||||
except Exception:
|
||||
log.warning("server.node_models_projection_failed", exc_info=True)
|
||||
return
|
||||
if entry is None:
|
||||
return
|
||||
payload = entry[1]
|
||||
if payload == getattr(app_state, "_last_models_payload", None):
|
||||
_metrics.record_node_models_publish(written=False)
|
||||
return
|
||||
try:
|
||||
storage.set_node_metadata_bulk(node_id, [entry])
|
||||
except StorageUnavailableError:
|
||||
return # storage layer already logged
|
||||
except Exception:
|
||||
log.exception("server.node_models_publish_failed")
|
||||
return
|
||||
app_state._last_models_payload = payload
|
||||
_metrics.record_node_models_publish(written=True)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Global SSE fan-out
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -3122,6 +3451,22 @@ async def _lifespan(app: Starlette) -> AsyncGenerator[None, None]:
|
||||
]
|
||||
_cfg_meta = _load_meta_config("metadata")
|
||||
_meta_entries.extend((k, json.dumps(v), "config") for k, v in _cfg_meta.items())
|
||||
# Project the live model registry into a ``models`` entry so
|
||||
# coord-side ``list_nodes`` can surface healthy aliases per
|
||||
# node without a fan-out HTTP probe. Re-collected on each
|
||||
# heartbeat tick so health flips converge within ~30s.
|
||||
# Wrapped in its own try/except so a projection failure
|
||||
# doesn't take out the auto+config metadata write — losing
|
||||
# the discovery surface is recoverable on the next heartbeat
|
||||
# tick, but losing ``arch`` / ``os`` / ``cpu_count`` blinds
|
||||
# the cluster's capability filters until the next restart.
|
||||
try:
|
||||
_models_entry = _collect_node_models_metadata(app.state)
|
||||
except Exception:
|
||||
log.warning("server.node_models_projection_failed", exc_info=True)
|
||||
_models_entry = None
|
||||
if _models_entry is not None:
|
||||
_meta_entries.append(_models_entry)
|
||||
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")
|
||||
@@ -3132,11 +3477,26 @@ async def _lifespan(app: Starlette) -> AsyncGenerator[None, None]:
|
||||
node_id=_svc_node_id,
|
||||
count=len(_meta_entries),
|
||||
)
|
||||
# Seed the publish-cache so the first heartbeat tick
|
||||
# doesn't redundant-write the same payload we just put
|
||||
# in the bulk above.
|
||||
if _models_entry is not None:
|
||||
app.state._last_models_payload = _models_entry[1]
|
||||
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."""
|
||||
"""Periodically update service heartbeat and refresh models metadata.
|
||||
|
||||
The ``models`` entry on ``node_metadata`` doubles as the
|
||||
coord-side discovery surface for healthy model aliases per
|
||||
node — refreshed every 30s so health flips and registry
|
||||
reloads converge promptly without a fan-out HTTP probe on
|
||||
the coord's ``list_nodes`` path. The publish step short-
|
||||
circuits when the projection is byte-identical to the
|
||||
last write (cache lives on ``app.state``), so a stable
|
||||
cluster doesn't pay UPSERT churn here.
|
||||
"""
|
||||
from turnstone.core.storage._registry import StorageUnavailableError
|
||||
|
||||
while True:
|
||||
@@ -3147,6 +3507,12 @@ async def _lifespan(app: Starlette) -> AsyncGenerator[None, None]:
|
||||
pass # already logged by storage layer
|
||||
except Exception:
|
||||
log.exception("server.heartbeat_failed")
|
||||
# Both projection and write happen in the worker thread
|
||||
# — keeps the registry-lock acquisition off the loop and
|
||||
# bundles the round-trip into a single offload.
|
||||
await asyncio.to_thread(
|
||||
_publish_models_metadata, app.state, _svc_storage, _svc_node_id
|
||||
)
|
||||
|
||||
_heartbeat_task = asyncio.create_task(_heartbeat_loop())
|
||||
|
||||
@@ -3154,6 +3520,13 @@ async def _lifespan(app: Starlette) -> AsyncGenerator[None, None]:
|
||||
# Shutdown
|
||||
if _heartbeat_task is not None:
|
||||
_heartbeat_task.cancel()
|
||||
# Wait for the cancel to land before we run the metadata
|
||||
# delete below — a heartbeat tick mid-write would otherwise
|
||||
# complete its ``set_node_metadata_bulk`` AFTER our
|
||||
# ``delete_node_metadata_by_source(..., "auto")`` and
|
||||
# resurrect the row we just cleared.
|
||||
with contextlib.suppress(asyncio.CancelledError, Exception):
|
||||
await _heartbeat_task
|
||||
if _svc_node_id and _svc_url:
|
||||
from turnstone.core.storage import get_storage as _get_svc_dereg
|
||||
|
||||
@@ -3319,12 +3692,14 @@ def create_app(
|
||||
open_resolve_alias=_resolve_workstream_alias,
|
||||
open_post_load=_interactive_open_post_load,
|
||||
events_replay=_interactive_events_replay,
|
||||
events_replay_prepare=_interactive_events_replay_prepare,
|
||||
# Pre-lift ``events_sse`` used the dedicated 200-thread
|
||||
# ``sse_executor`` so SSE polling stayed isolated from
|
||||
# every other ``asyncio.to_thread`` caller in the process
|
||||
# (storage, router, audit). Restore that isolation under
|
||||
# the lifted contract — coord wires ``None`` and falls
|
||||
# back to the default executor.
|
||||
# the lifted contract. The console's coord endpoint wires
|
||||
# its own ``coord_sse_executor`` on the same lookup hook —
|
||||
# see ``turnstone/console/server.py``.
|
||||
sse_executor_lookup=lambda request: request.app.state.sse_executor,
|
||||
create_supports_attachments=True,
|
||||
create_supports_user_id_override=True,
|
||||
@@ -3544,6 +3919,11 @@ def main() -> None:
|
||||
default=8080,
|
||||
help="Port to listen on (default: 8080)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--skip-permissions",
|
||||
action="store_true",
|
||||
help="Auto-approve all tool calls (no confirmation prompts)",
|
||||
)
|
||||
# MCP config path is bootstrap-critical (needed before ConfigStore for tool loading)
|
||||
parser.add_argument(
|
||||
"--mcp-config",
|
||||
@@ -3794,6 +4174,13 @@ def main() -> None:
|
||||
assert ui is not None
|
||||
# Resolve the effective alias once and use it consistently
|
||||
# for both client resolution and ChatSession.model_alias.
|
||||
# Unknown aliases here raise ValueError — the create handler
|
||||
# maps that to a 503 with operator-friendly text so a typo or
|
||||
# removed alias in body.model surfaces instead of silently
|
||||
# starting on the default. SessionManager.open's rehydrate
|
||||
# path is the one place where unknown aliases must NOT fail
|
||||
# loud; the manager filters those out via its model_validator
|
||||
# before the alias reaches this factory.
|
||||
model_alias = model_alias or _effective_default_alias()
|
||||
r_client, r_model, r_cfg = registry.resolve(model_alias)
|
||||
# Read MCP client from shared ref — may have been replaced after startup
|
||||
@@ -3923,6 +4310,10 @@ def main() -> None:
|
||||
# emit_rehydrated are no-ops because those events fire from
|
||||
# out-of-band paths (create handler + WebUI._broadcast_state).
|
||||
event_emitter=interactive_adapter,
|
||||
# Filter out persisted aliases that no longer resolve so a
|
||||
# workstream pinned to a since-removed alias still rehydrates
|
||||
# (on the registry default) instead of 500-ing on every reopen.
|
||||
model_validator=registry.has_alias,
|
||||
)
|
||||
interactive_adapter.attach(manager)
|
||||
WebUI._workstream_mgr = manager
|
||||
@@ -3971,7 +4362,7 @@ def main() -> None:
|
||||
ws = manager.create(user_id="", name="resumed")
|
||||
if not isinstance(ws.ui, WebUI):
|
||||
raise TypeError(f"Expected WebUI, got {type(ws.ui).__name__}")
|
||||
if config_store.get("tools.skip_permissions"):
|
||||
if args.skip_permissions or config_store.get("tools.skip_permissions"):
|
||||
ws.ui.auto_approve = True
|
||||
assert ws.session is not None
|
||||
ws.session.set_watch_runner(
|
||||
@@ -4009,7 +4400,7 @@ def main() -> None:
|
||||
_advertise_host = args.host if args.host not in ("0.0.0.0", "::") else socket.gethostname()
|
||||
_advertise_url = f"http://{_advertise_host}:{args.port}"
|
||||
|
||||
_skip_perms = config_store.get("tools.skip_permissions")
|
||||
_skip_perms = args.skip_permissions or config_store.get("tools.skip_permissions")
|
||||
app = create_app(
|
||||
workstreams=manager,
|
||||
global_queue=global_queue,
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
console/static (Saved Coordinators). Single source of truth so the two
|
||||
surfaces don't drift on hover affordance, padding, or typography.
|
||||
==========================================================================
|
||||
Class names match the original ui/static rules they replaced; the
|
||||
delete-mode subset stays in ui/static/style.css until coordinator gets
|
||||
the same UX (then it can move here too).
|
||||
Class names match the original ui/static rules they replaced. Delete-mode
|
||||
selectors live here too so console (Saved Coordinators) and ui/static
|
||||
(Saved Workstreams) share one card + delete affordance.
|
||||
========================================================================== */
|
||||
|
||||
.dashboard-cards {
|
||||
@@ -76,3 +76,283 @@
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Delete UX — section-level "Delete" toggle, per-card checkboxes, bottom
|
||||
toolbar, and confirmation modal. Moved out of ui/static/style.css when
|
||||
the console grew the same multi-select delete on Saved Coordinators.
|
||||
========================================================================== */
|
||||
|
||||
.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);
|
||||
}
|
||||
|
||||
/* Delete mode */
|
||||
.dashboard-card.ws-delete-mode {
|
||||
cursor: pointer;
|
||||
}
|
||||
.dashboard-card.ws-delete-mode:hover {
|
||||
border-color: var(--red);
|
||||
background: rgba(248, 113, 113, 0.04);
|
||||
}
|
||||
.dashboard-card.ws-delete-mode.ws-selected {
|
||||
cursor: default;
|
||||
}
|
||||
.dashboard-card.ws-delete-mode.ws-selected:hover {
|
||||
border-color: var(--red);
|
||||
background: rgba(248, 113, 113, 0.08);
|
||||
}
|
||||
[data-theme="light"] .dashboard-card.ws-delete-mode:hover {
|
||||
background: rgba(220, 38, 38, 0.04);
|
||||
}
|
||||
[data-theme="light"] .dashboard-card.ws-delete-mode.ws-selected:hover {
|
||||
background: rgba(220, 38, 38, 0.08);
|
||||
}
|
||||
.ws-card-check {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
accent-color: var(--red);
|
||||
cursor: pointer;
|
||||
z-index: 1;
|
||||
opacity: 0;
|
||||
animation: ws-check-fadein 0.2s ease-out forwards;
|
||||
}
|
||||
.ws-card-check:focus-visible {
|
||||
/* Card click-handler proxies space/enter to the checkbox, so focus
|
||||
usually rests on the card; if a screen reader / power user tabs
|
||||
directly onto the checkbox the UA outline can be killed by
|
||||
adjacent rules — this guarantees a visible affordance. */
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
@keyframes ws-check-fadein {
|
||||
to {
|
||||
opacity: 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;
|
||||
animation: ws-bar-slide 0.2s ease-out;
|
||||
}
|
||||
@keyframes ws-bar-slide {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-8px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.ws-card-check {
|
||||
animation: none;
|
||||
opacity: 1;
|
||||
}
|
||||
.ws-delete-bar.visible {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
/* Below ~700px the four-pill bar's preferred width (~480-520px) starts
|
||||
eating into hit-targets and risking horizontal overflow. Wrap onto
|
||||
two rows: count + Cancel + Select All on the first, the destructive
|
||||
Delete Selected on its own full-width row underneath — also a better
|
||||
thumb-target separation than the desktop layout. */
|
||||
@media (max-width: 700px) {
|
||||
.ws-delete-bar {
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
.ws-delete-bar .ws-delete-bar-btn {
|
||||
margin-left: 0;
|
||||
flex: 1 1 100%;
|
||||
order: 99;
|
||||
}
|
||||
}
|
||||
.ws-delete-bar .ws-delete-count-label {
|
||||
font-size: 12px;
|
||||
color: var(--fg-dim);
|
||||
}
|
||||
.ws-delete-bar .ws-delete-bar-btn {
|
||||
margin-left: auto;
|
||||
/* Dark-theme --red (#f87171) on #fff is only 3.0:1 — below WCAG AA
|
||||
for normal text on a destructive button. Use the deeper red
|
||||
(#dc2626 → 4.85:1) on the filled state so the button label clears
|
||||
AA in the default theme. Light theme already uses --red (#b91c1c,
|
||||
5.9:1) and stays put — the override below pins it. */
|
||||
background: #dc2626;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: var(--radius);
|
||||
padding: 6px 16px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: filter 0.15s;
|
||||
}
|
||||
[data-theme="light"] .ws-delete-bar .ws-delete-bar-btn {
|
||||
background: var(--red);
|
||||
}
|
||||
.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);
|
||||
}
|
||||
.ws-delete-bar .ws-delete-selectall-btn {
|
||||
background: transparent;
|
||||
color: var(--fg-bright);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 6px 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
}
|
||||
.ws-delete-bar .ws-delete-selectall-btn:hover {
|
||||
border-color: var(--border-strong);
|
||||
background: var(--bg-highlight);
|
||||
}
|
||||
|
||||
/* Delete modal — id-scoped so the surface that owns it controls visibility.
|
||||
Both ui/static (#ws-delete-overlay) and console/static
|
||||
(#coord-delete-overlay) share the same shape via the .ws-delete-modal
|
||||
class hooks below. */
|
||||
.ws-delete-modal-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-modal-box {
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 24px;
|
||||
max-width: 480px;
|
||||
width: 90%;
|
||||
}
|
||||
.ws-delete-modal-box h3 {
|
||||
margin: 0 0 12px;
|
||||
font-size: 16px;
|
||||
color: var(--fg-bright);
|
||||
}
|
||||
.ws-delete-modal-list {
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
margin: 12px 0;
|
||||
}
|
||||
.ws-delete-modal-list .ws-delete-item {
|
||||
padding: 6px 0;
|
||||
font-size: 13px;
|
||||
color: var(--fg-bright);
|
||||
border-bottom: 1px solid var(--border);
|
||||
/* Long aliases / raw ws_ids in the confirm + results list shouldn't
|
||||
punch out of the modal at narrow viewports. */
|
||||
word-break: break-word;
|
||||
}
|
||||
/* Modal alert region — only painted when the controller writes a
|
||||
message. Both close paths clear it, so :not(:empty) keeps the box
|
||||
invisible at rest and avoids an empty-frame artefact. */
|
||||
.ws-delete-modal-box [role="alert"]:not(:empty) {
|
||||
background: rgba(248, 113, 113, 0.08);
|
||||
border: 1px solid var(--red);
|
||||
color: var(--red);
|
||||
border-radius: var(--radius);
|
||||
padding: 8px 10px;
|
||||
font-size: 12px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
[data-theme="light"] .ws-delete-modal-box [role="alert"]:not(:empty) {
|
||||
background: rgba(220, 38, 38, 0.06);
|
||||
}
|
||||
.ws-delete-modal-list .ws-delete-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
.ws-delete-modal-list .ws-delete-item.ws-delete-error {
|
||||
color: var(--red);
|
||||
}
|
||||
.ws-delete-modal-buttons {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
justify-content: flex-end;
|
||||
margin-top: 16px;
|
||||
}
|
||||
.ws-delete-modal-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-modal-buttons button.ws-delete-confirm {
|
||||
/* Mirror .ws-delete-bar-btn's contrast bump — same destructive
|
||||
filled-button treatment, same dark-theme AA fix. */
|
||||
background: #dc2626;
|
||||
color: #fff;
|
||||
border-color: #dc2626;
|
||||
}
|
||||
[data-theme="light"] .ws-delete-modal-buttons button.ws-delete-confirm {
|
||||
background: var(--red);
|
||||
border-color: var(--red);
|
||||
}
|
||||
/* `.ws-delete-close` is a state-marker, not a colour rule — the
|
||||
controller drops `.ws-delete-confirm` when the modal transitions to
|
||||
the post-delete "Close" state, and the default
|
||||
`.ws-delete-modal-buttons button` rule above already provides the
|
||||
transparent / fg-bright / border styling. The class itself is useful
|
||||
for DOM inspection and as a future hook. */
|
||||
|
||||
@@ -77,3 +77,433 @@ function renderSessionCard(sess, opts) {
|
||||
card.appendChild(metaEl);
|
||||
return card;
|
||||
}
|
||||
|
||||
/* createSavedCardsController — shared multi-select-delete behaviour for
|
||||
the dashboard / home "saved cards" surfaces. ui/static (Saved
|
||||
Workstreams) and console/static (Saved Coordinators) both instantiate
|
||||
one of these; the controller owns:
|
||||
|
||||
- delete-mode state (active flag + selected ws_id set)
|
||||
- card decoration (checkbox + key/click overrides)
|
||||
- the bottom toolbar wiring (count, Select All, Delete Selected)
|
||||
- the confirmation modal (focus trap, batch fan-out, results view)
|
||||
|
||||
It does NOT own how cards get fetched or rendered — the caller's
|
||||
render() is invoked when the controller needs the list redrawn (mode
|
||||
transitions, Select-All toggles).
|
||||
|
||||
Required opts:
|
||||
idPrefix — DOM-id prefix shared by the toolbar + modal
|
||||
(e.g. "ws-delete" / "coord-delete"). The DOM
|
||||
must already contain `${idPrefix}-bar`,
|
||||
`${idPrefix}-bar-count`, `${idPrefix}-bar-delete`,
|
||||
`${idPrefix}-bar-select-all`, `${idPrefix}-overlay`,
|
||||
`${idPrefix}-box`, `${idPrefix}-error`,
|
||||
`${idPrefix}-count`, `${idPrefix}-list`,
|
||||
`${idPrefix}-confirm-btn`, `${idPrefix}-cancel-btn`.
|
||||
buttonId — id of the section's start/cancel toggle button.
|
||||
noun — singular display word for the item kind, e.g.
|
||||
"workstream" / "coordinator". Used in toast +
|
||||
modal copy.
|
||||
activateLabel — sess => string; aria-label for the card when NOT
|
||||
in delete mode (e.g. "Resume: foo").
|
||||
buildDeleteRequest — wsId => { url, options }; what authFetch should
|
||||
send to delete one item.
|
||||
render — () => void; redraw the visible cards. Called by
|
||||
the controller on mode start/cancel and Select-
|
||||
All toggle. Caller is responsible for calling
|
||||
setItems(items) + decorateCard() inside it.
|
||||
onClose — optional () => void; called once after the user
|
||||
closes the post-delete results modal. Typical
|
||||
use: re-fetch the saved list.
|
||||
*/
|
||||
function createSavedCardsController(opts) {
|
||||
var state = { mode: false, selected: {}, items: [] };
|
||||
var batchTrap = null;
|
||||
/* Element that owned focus when the modal opened — restored in
|
||||
closeModal() so keyboard users land back on the toggle button (or
|
||||
wherever they came from) instead of <body>. WCAG 2.4.3. */
|
||||
var prevFocus = null;
|
||||
|
||||
function $(id) {
|
||||
return document.getElementById(opts.idPrefix + "-" + id);
|
||||
}
|
||||
|
||||
/* Replace the toggle button's content with a glyph + label, keeping
|
||||
the glyph in an aria-hidden span so screen readers only read the
|
||||
label. Built from DOM nodes (no innerHTML) — same shape as the
|
||||
section-header markup the JS replaces. */
|
||||
function setIconButton(btn, glyph, label) {
|
||||
btn.replaceChildren();
|
||||
var span = document.createElement("span");
|
||||
span.setAttribute("aria-hidden", "true");
|
||||
span.textContent = glyph;
|
||||
btn.appendChild(span);
|
||||
btn.appendChild(document.createTextNode(" " + label));
|
||||
}
|
||||
|
||||
function setItems(items) {
|
||||
state.items = items;
|
||||
/* Drop any selections whose ws_id is no longer on the visible page —
|
||||
SSE-driven re-renders or pagination jumps shouldn't leave ghost
|
||||
entries inflating the count and 404-ing on confirm. */
|
||||
if (state.mode) {
|
||||
var byId = {};
|
||||
items.forEach(function (s) {
|
||||
byId[s.ws_id] = true;
|
||||
});
|
||||
Object.keys(state.selected).forEach(function (id) {
|
||||
if (!byId[id]) delete state.selected[id];
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function inMode() {
|
||||
return state.mode;
|
||||
}
|
||||
|
||||
function blockActivate() {
|
||||
return state.mode;
|
||||
}
|
||||
|
||||
function isSelected(wsId) {
|
||||
return !!state.selected[wsId];
|
||||
}
|
||||
|
||||
function ariaLabel(sess) {
|
||||
var label = sess.alias || sess.title || sess.name || sess.ws_id;
|
||||
if (state.mode) return "Select " + opts.noun + ": " + label;
|
||||
return typeof opts.activateLabel === "function"
|
||||
? opts.activateLabel(sess)
|
||||
: "Activate: " + label;
|
||||
}
|
||||
|
||||
/* Decorate an already-rendered .dashboard-card with the checkbox +
|
||||
event overrides used in delete mode. Idempotent guard: only acts
|
||||
when the controller is active. */
|
||||
function decorateCard(card, sess) {
|
||||
if (!state.mode) return;
|
||||
card.classList.add("ws-delete-mode");
|
||||
card.removeAttribute("role");
|
||||
var chk = document.createElement("input");
|
||||
chk.type = "checkbox";
|
||||
chk.className = "ws-card-check";
|
||||
chk.checked = !!state.selected[sess.ws_id];
|
||||
var label = sess.alias || sess.title || sess.name || sess.ws_id;
|
||||
chk.setAttribute("aria-label", "Select " + label + " for deletion");
|
||||
chk.onclick = function (e) {
|
||||
e.stopPropagation();
|
||||
if (chk.checked) state.selected[sess.ws_id] = true;
|
||||
else delete state.selected[sess.ws_id];
|
||||
card.classList.toggle("ws-selected", chk.checked);
|
||||
refreshBar();
|
||||
};
|
||||
card.insertBefore(chk, card.firstChild);
|
||||
card.onclick = function (e) {
|
||||
if (e.target === chk) return;
|
||||
chk.checked = !chk.checked;
|
||||
chk.onclick(e);
|
||||
};
|
||||
card.onkeydown = function (e) {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
chk.checked = !chk.checked;
|
||||
chk.onclick(e);
|
||||
}
|
||||
};
|
||||
if (state.selected[sess.ws_id]) card.classList.add("ws-selected");
|
||||
}
|
||||
|
||||
function refreshBar() {
|
||||
var count = Object.keys(state.selected).length;
|
||||
var label = $("bar-count");
|
||||
if (label) label.textContent = count + " selected";
|
||||
var delBtn = $("bar-delete");
|
||||
if (delBtn) delBtn.disabled = count === 0;
|
||||
var selBtn = $("bar-select-all");
|
||||
if (selBtn) {
|
||||
var allSelected = count === state.items.length && state.items.length > 0;
|
||||
selBtn.textContent = allSelected ? "Deselect All" : "Select All";
|
||||
}
|
||||
}
|
||||
|
||||
function start() {
|
||||
if (!state.items.length) {
|
||||
if (typeof showToast === "function") {
|
||||
showToast("No saved " + opts.noun + "s to delete");
|
||||
}
|
||||
return;
|
||||
}
|
||||
state.mode = true;
|
||||
state.selected = {};
|
||||
opts.render();
|
||||
var btn = document.getElementById(opts.buttonId);
|
||||
if (btn) {
|
||||
setIconButton(btn, "✕", "Cancel");
|
||||
btn.onclick = cancel;
|
||||
}
|
||||
var bar = $("bar");
|
||||
if (bar) bar.classList.add("visible");
|
||||
refreshBar();
|
||||
}
|
||||
|
||||
function cancel() {
|
||||
state.mode = false;
|
||||
state.selected = {};
|
||||
opts.render();
|
||||
var btn = document.getElementById(opts.buttonId);
|
||||
if (btn) {
|
||||
setIconButton(btn, "\u{1f5d1}", "Delete");
|
||||
btn.onclick = start;
|
||||
}
|
||||
var bar = $("bar");
|
||||
if (bar) bar.classList.remove("visible");
|
||||
}
|
||||
|
||||
function toggleAll() {
|
||||
var allSelected =
|
||||
Object.keys(state.selected).length === state.items.length &&
|
||||
state.items.length > 0;
|
||||
if (allSelected) {
|
||||
state.selected = {};
|
||||
} else {
|
||||
state.items.forEach(function (s) {
|
||||
state.selected[s.ws_id] = true;
|
||||
});
|
||||
}
|
||||
opts.render();
|
||||
refreshBar();
|
||||
}
|
||||
|
||||
function _byId() {
|
||||
/* Single-pass index over the visible items so the modal + fan-out
|
||||
paths don't repeat O(N) `find` calls per selection. */
|
||||
var map = {};
|
||||
state.items.forEach(function (s) {
|
||||
map[s.ws_id] = s;
|
||||
});
|
||||
return map;
|
||||
}
|
||||
|
||||
function confirmSelection() {
|
||||
var selected = Object.keys(state.selected);
|
||||
if (!selected.length) {
|
||||
if (typeof showToast === "function") {
|
||||
showToast("No " + opts.noun + "s selected");
|
||||
}
|
||||
return;
|
||||
}
|
||||
var byId = _byId();
|
||||
var overlay = $("overlay");
|
||||
var countEl = $("count");
|
||||
var listEl = $("list");
|
||||
var errorEl = $("error");
|
||||
if (errorEl) errorEl.textContent = "";
|
||||
if (countEl) {
|
||||
countEl.textContent =
|
||||
selected.length + " " + opts.noun + "(s) will be permanently deleted:";
|
||||
}
|
||||
if (listEl) {
|
||||
listEl.replaceChildren();
|
||||
selected.forEach(function (wsId) {
|
||||
var item = byId[wsId];
|
||||
var name = item ? item.alias || item.title || item.name || wsId : wsId;
|
||||
var div = document.createElement("div");
|
||||
div.className = "ws-delete-item";
|
||||
div.textContent = name;
|
||||
listEl.appendChild(div);
|
||||
});
|
||||
}
|
||||
var delBtn = $("confirm-btn");
|
||||
if (delBtn) {
|
||||
delBtn.textContent = "Delete";
|
||||
delBtn.disabled = false;
|
||||
delBtn.classList.remove("ws-delete-close");
|
||||
delBtn.classList.add("ws-delete-confirm");
|
||||
delBtn.onclick = confirm;
|
||||
}
|
||||
var cancelBtn = $("cancel-btn");
|
||||
if (cancelBtn) cancelBtn.disabled = false;
|
||||
if (overlay) overlay.style.display = "flex";
|
||||
|
||||
if (batchTrap) document.removeEventListener("keydown", batchTrap);
|
||||
batchTrap = function (e) {
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
closeModal();
|
||||
return;
|
||||
}
|
||||
if (e.key === "Tab") {
|
||||
var box = $("box");
|
||||
if (!box) return;
|
||||
var focusable = box.querySelectorAll("button:not(:disabled)");
|
||||
if (!focusable.length) return;
|
||||
var first = focusable[0];
|
||||
var last = focusable[focusable.length - 1];
|
||||
if (e.shiftKey && document.activeElement === first) {
|
||||
e.preventDefault();
|
||||
last.focus();
|
||||
} else if (!e.shiftKey && document.activeElement === last) {
|
||||
e.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
}
|
||||
};
|
||||
document.addEventListener("keydown", batchTrap);
|
||||
/* Snapshot the pre-modal focus owner so closeModal() can return to
|
||||
it. Captured before we move focus into the dialog so the
|
||||
restore-target is the caller, not the dialog itself. */
|
||||
prevFocus = document.activeElement;
|
||||
if (cancelBtn) cancelBtn.focus();
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
var overlay = $("overlay");
|
||||
if (overlay) overlay.style.display = "none";
|
||||
if (batchTrap) {
|
||||
document.removeEventListener("keydown", batchTrap);
|
||||
batchTrap = null;
|
||||
}
|
||||
/* Pick the most useful focus target:
|
||||
1. prevFocus (where the user came from), if it's still in the
|
||||
DOM and visible. Esc / Cancel paths land here — the bar is
|
||||
still on screen, so focus returns to "Delete Selected".
|
||||
2. The section toggle button — always present, semantic exit
|
||||
point for the flow. Used when prevFocus has been hidden by
|
||||
cancel() (post-delete Close path: cancel() ran first and
|
||||
put `.ws-delete-bar` at display:none, so the bar's button
|
||||
is no longer focusable). */
|
||||
var target = prevFocus;
|
||||
if (!target || target.offsetParent === null) {
|
||||
target = document.getElementById(opts.buttonId);
|
||||
}
|
||||
if (target && typeof target.focus === "function") {
|
||||
try {
|
||||
target.focus();
|
||||
} catch (_) {
|
||||
/* node detached between open and close — give up silently */
|
||||
}
|
||||
}
|
||||
prevFocus = null;
|
||||
}
|
||||
|
||||
function confirm() {
|
||||
var selected = Object.keys(state.selected);
|
||||
if (!selected.length) return;
|
||||
var byId = _byId();
|
||||
var errorEl = $("error");
|
||||
var listEl = $("list");
|
||||
var countEl = $("count");
|
||||
var delBtn = $("confirm-btn");
|
||||
var cancelBtn = $("cancel-btn");
|
||||
if (errorEl) errorEl.textContent = "";
|
||||
if (delBtn) {
|
||||
delBtn.disabled = true;
|
||||
delBtn.textContent = "Deleting...";
|
||||
}
|
||||
if (cancelBtn) cancelBtn.disabled = true;
|
||||
|
||||
var results = [];
|
||||
var promises = selected.map(function (wsId) {
|
||||
var shortId = wsId.substring(0, 8);
|
||||
var item = byId[wsId];
|
||||
var name = item ? item.alias || item.title || item.name || wsId : wsId;
|
||||
var req = opts.buildDeleteRequest(wsId);
|
||||
return authFetch(req.url, req.options)
|
||||
.then(function (r) {
|
||||
var status = r.status;
|
||||
var contentType = r.headers.get("content-type") || "";
|
||||
if (r.ok) {
|
||||
results.push({ name: name, shortId: shortId, ok: true });
|
||||
return;
|
||||
}
|
||||
return r.text().then(function (body) {
|
||||
var errMsg = shortId + ": HTTP " + status;
|
||||
if (contentType.includes("json")) {
|
||||
try {
|
||||
var j = JSON.parse(body);
|
||||
if (j.error) errMsg = shortId + ": " + j.error;
|
||||
} catch (_) {
|
||||
/* fall through */
|
||||
}
|
||||
} else if (body) {
|
||||
errMsg = shortId + ": " + body.substring(0, 200);
|
||||
}
|
||||
results.push({
|
||||
name: name,
|
||||
shortId: shortId,
|
||||
ok: false,
|
||||
error: errMsg,
|
||||
});
|
||||
});
|
||||
})
|
||||
.catch(function (err) {
|
||||
results.push({
|
||||
name: name,
|
||||
shortId: shortId,
|
||||
ok: false,
|
||||
error: shortId + ": " + err.message,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Promise.all(promises).then(function () {
|
||||
if (listEl) {
|
||||
listEl.replaceChildren();
|
||||
results.forEach(function (r) {
|
||||
var div = document.createElement("div");
|
||||
div.className = "ws-delete-item" + (r.ok ? "" : " ws-delete-error");
|
||||
div.textContent =
|
||||
(r.ok ? "✓ " : "✗ ") + r.name + (r.error ? " — " + r.error : "");
|
||||
listEl.appendChild(div);
|
||||
});
|
||||
}
|
||||
var okCount = results.filter(function (r) {
|
||||
return r.ok;
|
||||
}).length;
|
||||
var failCount = results.filter(function (r) {
|
||||
return !r.ok;
|
||||
}).length;
|
||||
if (countEl) {
|
||||
countEl.textContent = okCount + " deleted, " + failCount + " failed";
|
||||
}
|
||||
if (delBtn) {
|
||||
delBtn.disabled = false;
|
||||
delBtn.textContent = "Close";
|
||||
/* Swap modifier classes so styling is intent-driven instead of
|
||||
cascade-positional: the Close button picks up the default
|
||||
".ws-delete-modal-buttons button" rule once .ws-delete-confirm
|
||||
is removed. */
|
||||
delBtn.classList.remove("ws-delete-confirm");
|
||||
delBtn.classList.add("ws-delete-close");
|
||||
delBtn.onclick = function () {
|
||||
/* Order matters: cancel() reshapes the toggle button via
|
||||
setIconButton(), which preserves the element identity but
|
||||
swaps its subtree. closeModal() then focuses prevFocus —
|
||||
which IS that toggle button — landing on a freshly rebuilt
|
||||
"Delete" affordance instead of <body>. */
|
||||
cancel();
|
||||
closeModal();
|
||||
if (typeof opts.onClose === "function") opts.onClose();
|
||||
};
|
||||
}
|
||||
if (cancelBtn) cancelBtn.disabled = false;
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
setItems: setItems,
|
||||
inMode: inMode,
|
||||
blockActivate: blockActivate,
|
||||
isSelected: isSelected,
|
||||
ariaLabel: ariaLabel,
|
||||
decorateCard: decorateCard,
|
||||
refreshBar: refreshBar,
|
||||
start: start,
|
||||
cancel: cancel,
|
||||
toggleAll: toggleAll,
|
||||
confirmSelection: confirmSelection,
|
||||
closeModal: closeModal,
|
||||
confirm: confirm,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -354,6 +354,15 @@
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
.composer-attach:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.composer-attach:disabled:hover {
|
||||
background: transparent;
|
||||
color: var(--fg-dim);
|
||||
border-color: var(--border-strong);
|
||||
}
|
||||
|
||||
.composer-input {
|
||||
flex: 1;
|
||||
@@ -885,6 +894,27 @@
|
||||
.msg.user {
|
||||
border-left-color: var(--accent);
|
||||
}
|
||||
/* Metacognitive reminder — slotted directly below the message it
|
||||
advises (user message for correction/denial/etc., tool result for
|
||||
tool_error/repeat). Yellow accent reads as "advisory metadata"
|
||||
against the amber-ish user colour and the cyan tool cards;
|
||||
deliberately quieter than the surrounding bubbles so it doesn't
|
||||
compete for attention. Lives in the shared stylesheet so both
|
||||
the interactive UI and the console coord viewer render the same
|
||||
themed bubble. */
|
||||
.msg.user-reminder {
|
||||
border-left-color: var(--yellow);
|
||||
color: var(--fg-dim);
|
||||
font-size: 12px;
|
||||
padding: 6px 10px;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.msg.user-reminder .msg-user-reminder-label {
|
||||
color: var(--yellow);
|
||||
font-weight: 600;
|
||||
margin-right: 6px;
|
||||
text-transform: lowercase;
|
||||
}
|
||||
.msg.assistant {
|
||||
border-left-color: var(--hair-2);
|
||||
}
|
||||
|
||||
@@ -576,6 +576,19 @@
|
||||
this.sendBtn.disabled = !!b && !opts.queueWhileBusy;
|
||||
}
|
||||
|
||||
// Paperclip is disabled whenever busy, even in queueWhileBusy mode:
|
||||
// attachments can't ride a queued user turn (would inject a `user`
|
||||
// turn between assistant(tool_calls) and tool — see backend
|
||||
// AttachmentsNotQueueableError).
|
||||
if (this.attachBtn) {
|
||||
this.attachBtn.disabled = !!b;
|
||||
var attachLabel = b
|
||||
? "Attach files (available once the current turn finishes)"
|
||||
: "Attach files";
|
||||
this.attachBtn.title = attachLabel;
|
||||
this.attachBtn.setAttribute("aria-label", attachLabel);
|
||||
}
|
||||
|
||||
// Stop button visibility + label reset — reset every transition so
|
||||
// cancelGeneration's transient "Cancelling…" label doesn't stick.
|
||||
if (this.stopBtn) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "list_nodes",
|
||||
"description": "List active cluster nodes with their metadata. By default only nodes with a fresh service-registry heartbeat (within 120s) are returned. Pass arbitrary `key=value` filters to narrow; all filters must match (AND). Pair with `target_node` on spawn_workstream to pin a child to a node that matches a capability. The 120s heartbeat is a sliding window, so a node returned here can drop out before a follow-up spawn lands — the race produces `\"No available node for routing\"`; omit `target_node` to let rendezvous pick from the still-healthy set, or retry after re-listing if a specific node is required. The `interfaces` key (container IPs, interface names) is stripped by default — routing should use capability/region tags, not IPs; pass `include_network_detail=true` only for debugging. Pass `include_inactive=true` to surface stale registrations (those nodes will reject `target_node` pinning).",
|
||||
"description": "List active cluster nodes with their metadata. By default only nodes with a fresh service-registry heartbeat (within 120s) are returned. Each row carries `node_id`, `metadata`, and `model_aliases` — the latter being a list of healthy model aliases the node will accept on `spawn_workstream(model=...)` / `spawn_batch` (refreshed every 30s by the node's heartbeat). Pass arbitrary `key=value` filters to narrow; all filters must match (AND). Pair with `target_node` on spawn_workstream to pin a child to a node that matches a capability. The 120s heartbeat is a sliding window, so a node returned here can drop out before a follow-up spawn lands — the race produces `\"No available node for routing\"`; omit `target_node` to let rendezvous pick from the still-healthy set, or retry after re-listing if a specific node is required. The `interfaces` key (container IPs, interface names) is stripped by default — routing should use capability/region tags, not IPs; pass `include_network_detail=true` only for debugging. Pass `include_inactive=true` to surface stale registrations (those nodes will reject `target_node` pinning).",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
},
|
||||
"model": {
|
||||
"type": "string",
|
||||
"description": "Optional model alias."
|
||||
"description": "Optional model alias. Discover available aliases per node via `list_nodes.model_aliases`."
|
||||
},
|
||||
"target_node": {
|
||||
"type": "string",
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
},
|
||||
"model": {
|
||||
"type": "string",
|
||||
"description": "Optional model alias from the registry. Omit to use the coordinator's default model (or the one the skill prescribes)."
|
||||
"description": "Optional model alias from the registry. Discover available aliases per node via `list_nodes` (the `model_aliases` field on each row lists the healthy aliases that node will accept). Omit to use the coordinator's default model (or the one the skill prescribes)."
|
||||
},
|
||||
"target_node": {
|
||||
"type": "string",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "wait_for_workstream",
|
||||
"description": "Block until one or all named child workstreams reach a terminal state. Prefer this over busy-polling inspect_workstream after a fan-out: the tool absorbs the wait, so you get one call + one result regardless of duration. Returns `{results: {ws_id: {state, tokens, updated, message, truncated}}, elapsed, complete, mode}` — `results` is keyed by ws_id (not top-level), `complete` is true when the wait condition fired before the timeout. `message` is the child's last assistant text for `idle`/`error`, a short sentinel for `closed`/`denied`, and `null` for non-terminal rows so a follow-up read knows which children still need work; capped at 6 KiB UTF-8, with `truncated=true` when the cap fires (call inspect_workstream for the rest). Real terminal states: `idle`, `error`, `closed`, `deleted` (the last is unobservable since hard-delete cascades the row out of storage). `mode='any'` returns when the first child hits a real terminal — a `denied` id alone never satisfies the condition, so a typo'd / foreign / nonexistent id can't false-positive a wait. `mode='all'` returns once every id has settled (real terminal OR denied). Cross-tenant guard: only the coordinator's own children (or itself) are visible; everything else is reported `state='denied'`. Capped at 32 ws_ids and 600s; both overflows error rather than silently truncating.",
|
||||
"description": "Block until one or all named child workstreams reach a terminal state. Prefer this over busy-polling inspect_workstream after a fan-out: the tool absorbs the wait, so you get one call + one result regardless of duration. Returns `{results: {ws_id: {state, tokens, updated, message, truncated}}, elapsed, complete, mode}` — `results` is keyed by ws_id (not top-level), `complete` is true when the wait condition fired before the timeout. `message` is the child's last assistant text for `idle`/`error`, a short sentinel for `closed`/`denied`, and `null` for non-terminal rows so a follow-up read knows which children still need work; capped at 10 KiB UTF-8, with `truncated=true` when the cap fires (call inspect_workstream for the rest). Real terminal states: `idle`, `error`, `closed`, `deleted` (the last is unobservable since hard-delete cascades the row out of storage). `mode='any'` returns when the first child hits a real terminal — a `denied` id alone never satisfies the condition, so a typo'd / foreign / nonexistent id can't false-positive a wait. `mode='all'` returns once every id has settled (real terminal OR denied). Cross-tenant guard: only the coordinator's own children (or itself) are visible; everything else is reported `state='denied'`. Capped at 32 ws_ids and 600s; both overflows error rather than silently truncating.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
+417
-304
@@ -557,6 +557,44 @@ Pane.prototype.handleEvent = function (evt) {
|
||||
this.addErrorMessage(evt.message);
|
||||
break;
|
||||
|
||||
case "user_reminder":
|
||||
// Metacognitive nudges — render as their own bubble below the
|
||||
// user message they advise (semantically: a hint to the model
|
||||
// right before its turn). The originating tab's optimistic
|
||||
// addUserMessage already ran when the user clicked send, so by
|
||||
// the time this SSE event arrives the just-sent user bubble is
|
||||
// at the bottom of messagesEl and addUserReminder's "anchor to
|
||||
// most recent .msg.user" lookup finds it correctly; the
|
||||
// insertAdjacentElement('afterend', el) call drops the bubble
|
||||
// immediately below.
|
||||
//
|
||||
// Multi-tab caveat: the server emits no user_message SSE event
|
||||
// today, so a non-originating tab open on the same workstream
|
||||
// sees the reminder without a paired user-message render — the
|
||||
// anchor falls on a stale prior user bubble, mis-positioning
|
||||
// the reminder. The next /history reload corrects it (the
|
||||
// entry["reminders"] propagation in _build_history is
|
||||
// anchor-stable because replayHistory runs addUserMessage first
|
||||
// for every turn). Acceptable cost for stage 1; closing the
|
||||
// gap is a follow-up that adds a user_message SSE event.
|
||||
if (Array.isArray(evt.reminders) && evt.reminders.length) {
|
||||
this.addUserReminder(evt.reminders);
|
||||
}
|
||||
break;
|
||||
|
||||
case "tool_reminder":
|
||||
// Metacognitive tool-channel nudge (tool_error / repeat) —
|
||||
// render as the same yellow themed bubble used for user-channel
|
||||
// reminders, anchored below the .ts-approval block whose tool
|
||||
// result triggered the batch's reminder. evt.tool_call_id
|
||||
// identifies the specific tool element; addToolReminder walks
|
||||
// up to its parent approval block and inserts the bubble
|
||||
// immediately after.
|
||||
if (Array.isArray(evt.reminders) && evt.reminders.length) {
|
||||
this.addToolReminder(evt.reminders, evt.tool_call_id || "");
|
||||
}
|
||||
break;
|
||||
|
||||
case "message_queued":
|
||||
// Confirmation from server that a queued message was accepted.
|
||||
// The UI already showed the message optimistically in addQueuedMessage.
|
||||
@@ -669,6 +707,97 @@ Pane.prototype.removeThinkingIndicator = function () {
|
||||
if (el) el.remove();
|
||||
};
|
||||
|
||||
Pane.prototype.addUserReminder = function (reminders) {
|
||||
// Render each metacognitive reminder as its own bubble immediately
|
||||
// BELOW the user message it advises — semantically the reminder is
|
||||
// a hint to the model right before the assistant turn. Always
|
||||
// called AFTER the corresponding addUserMessage (live: optimistic
|
||||
// local render ran before the SSE event arrived; replay:
|
||||
// replayHistory renders the user message first), so "most recent
|
||||
// .msg.user" is always THIS turn's bubble — insertAdjacentElement
|
||||
// afterend drops the reminder directly below it. When no .msg.user
|
||||
// exists at all (e.g. a non-originating tab receiving a reminder
|
||||
// before any user turn has rendered) we append; the next /history
|
||||
// reload corrects any anchor anomaly.
|
||||
this.removeEmptyState();
|
||||
var userBubbles = this.messagesEl.querySelectorAll(".msg.user");
|
||||
var anchor = userBubbles.length ? userBubbles[userBubbles.length - 1] : null;
|
||||
for (var i = 0; i < reminders.length; i++) {
|
||||
var r = reminders[i] || {};
|
||||
var el = document.createElement("div");
|
||||
el.className = "msg user-reminder";
|
||||
var labelEl = document.createElement("span");
|
||||
labelEl.className = "msg-user-reminder-label";
|
||||
labelEl.textContent =
|
||||
"metacognition" + (r.type ? " · " + String(r.type) : "");
|
||||
var textEl = document.createElement("span");
|
||||
textEl.className = "msg-user-reminder-text";
|
||||
textEl.textContent = r.text || "";
|
||||
el.appendChild(labelEl);
|
||||
el.appendChild(textEl);
|
||||
if (anchor) {
|
||||
anchor.insertAdjacentElement("afterend", el);
|
||||
// Anchor advances so multiple reminders stack below the user
|
||||
// message in queued order (rather than each landing
|
||||
// immediately-after the user msg, which would reverse them).
|
||||
anchor = el;
|
||||
} else {
|
||||
this.messagesEl.appendChild(el);
|
||||
}
|
||||
}
|
||||
this.scrollToBottom(true);
|
||||
};
|
||||
|
||||
Pane.prototype.addToolReminder = function (reminders, toolCallId) {
|
||||
// Render each metacognitive tool-channel reminder (tool_error /
|
||||
// repeat) as the same yellow themed bubble used for user-channel
|
||||
// reminders, anchored below the .ts-approval block that produced
|
||||
// the tool result. toolCallId is the live-path anchor (SSE event
|
||||
// carries it); during replay it's an empty string and we fall back
|
||||
// to "last .ts-approval block in messagesEl", which is correct
|
||||
// because messages render in order — the assistant block carrying
|
||||
// the tool batch is always the most recent approval block by the
|
||||
// time we hit the tool message that owns the reminder.
|
||||
this.removeEmptyState();
|
||||
var anchor = null;
|
||||
if (toolCallId) {
|
||||
var escapedId = CSS.escape(toolCallId);
|
||||
var toolEl = this.messagesEl.querySelector(
|
||||
'.ts-approval-tool[data-call-id="' + escapedId + '"]',
|
||||
);
|
||||
if (toolEl) {
|
||||
anchor = toolEl.closest(".ts-approval");
|
||||
}
|
||||
}
|
||||
if (!anchor) {
|
||||
var blocks = this.messagesEl.querySelectorAll(".ts-approval");
|
||||
if (blocks.length) anchor = blocks[blocks.length - 1];
|
||||
}
|
||||
for (var i = 0; i < reminders.length; i++) {
|
||||
var r = reminders[i] || {};
|
||||
var el = document.createElement("div");
|
||||
// Same .msg.user-reminder class — visual treatment is shared
|
||||
// across user and tool channels (both are metacog nudges).
|
||||
el.className = "msg user-reminder";
|
||||
var labelEl = document.createElement("span");
|
||||
labelEl.className = "msg-user-reminder-label";
|
||||
labelEl.textContent =
|
||||
"metacognition" + (r.type ? " · " + String(r.type) : "");
|
||||
var textEl = document.createElement("span");
|
||||
textEl.className = "msg-user-reminder-text";
|
||||
textEl.textContent = r.text || "";
|
||||
el.appendChild(labelEl);
|
||||
el.appendChild(textEl);
|
||||
if (anchor) {
|
||||
anchor.insertAdjacentElement("afterend", el);
|
||||
anchor = el;
|
||||
} else {
|
||||
this.messagesEl.appendChild(el);
|
||||
}
|
||||
}
|
||||
this.scrollToBottom(true);
|
||||
};
|
||||
|
||||
Pane.prototype.addUserMessage = function (text, attachments) {
|
||||
this.removeEmptyState();
|
||||
var el = document.createElement("div");
|
||||
@@ -907,13 +1036,54 @@ Pane.prototype.replayHistory = function (messages) {
|
||||
this.showEmptyState();
|
||||
return;
|
||||
}
|
||||
// Suppress the polite live region while we batch-build the replay
|
||||
// — messagesEl is aria-live="polite" so a fresh replay would otherwise
|
||||
// queue an announcement for every approved/denied/verdict pill we
|
||||
// insert. Restored after the loop so live SSE updates announce
|
||||
// normally. WCAG 4.1.3 — historical content should not behave like
|
||||
// real-time updates.
|
||||
this.messagesEl.setAttribute("aria-busy", "true");
|
||||
// pendingAssessments[call_id] = output_assessment dict. Populated
|
||||
// from the assistant branch, consumed by the role==="tool" branch
|
||||
// (or after the loop, for legacy rows missing tool_call_id).
|
||||
// Replaces a JSON.stringify→dataset→JSON.parse round-trip with an
|
||||
// in-memory map keyed by call_id.
|
||||
var pendingAssessments = {};
|
||||
var lastToolBlock = null;
|
||||
for (var i = 0; i < messages.length; i++) {
|
||||
var msg = messages[i];
|
||||
if (msg.role === "user") {
|
||||
// addUserMessage first so addUserReminder's "anchor to most
|
||||
// recent .msg.user" lookup finds THIS message's bubble (not the
|
||||
// previous user message's, which would associate the reminder
|
||||
// with the wrong turn). addUserReminder then drops the bubble
|
||||
// immediately below the just-rendered user message via
|
||||
// insertAdjacentElement('afterend', el).
|
||||
this.addUserMessage(msg.content || "", msg.attachments || null);
|
||||
if (Array.isArray(msg.reminders) && msg.reminders.length) {
|
||||
this.addUserReminder(msg.reminders);
|
||||
}
|
||||
lastToolBlock = null;
|
||||
} else if (msg.role === "assistant") {
|
||||
// Render content BEFORE the tool block so the visual order
|
||||
// matches the live SSE flow (stream_text streams content first,
|
||||
// then tool_info / approve_request paints the tool block, then
|
||||
// tool_result fills it in). Order also matters structurally:
|
||||
// the tool-result message in the NEXT iteration anchors via
|
||||
// lastToolBlock, which the tool-block branch sets last — so
|
||||
// content must run first to avoid clobbering that anchor.
|
||||
if (msg.content) {
|
||||
var el = document.createElement("div");
|
||||
el.className = "msg assistant";
|
||||
var bodyEl = document.createElement("div");
|
||||
bodyEl.className = "msg-body";
|
||||
var rendered = renderMarkdown(msg.content);
|
||||
bodyEl.innerHTML = rendered;
|
||||
el.appendChild(bodyEl);
|
||||
postRenderMarkdown(el);
|
||||
self.messagesEl.appendChild(el);
|
||||
lastToolBlock = null;
|
||||
}
|
||||
if (msg.tool_calls && msg.tool_calls.length) {
|
||||
if (msg.pending) {
|
||||
lastToolBlock = null;
|
||||
@@ -958,7 +1128,35 @@ Pane.prototype.replayHistory = function (messages) {
|
||||
cmd.textContent = tc.arguments.substring(0, 100);
|
||||
}
|
||||
div.appendChild(cmd);
|
||||
// Verdict badge — anchor to THIS tool's row (div) rather
|
||||
// than the whole block, so a multi-tool batch with one
|
||||
// flagged call doesn't drift the badge above unrelated
|
||||
// calls. Same renderVerdictBadge helper as live; pass
|
||||
// judgePending=false because any verdict on replay is
|
||||
// final — no spinner.
|
||||
if (tc.verdict) {
|
||||
div.insertAdjacentHTML(
|
||||
"beforeend",
|
||||
renderVerdictBadge(tc.verdict, false),
|
||||
);
|
||||
}
|
||||
block.appendChild(div);
|
||||
// Output-guard finding — defer insertion until the tool
|
||||
// result lands so the warning anchors under the output
|
||||
// (mirrors live showOutputWarning placement). Stash in
|
||||
// a function-local map keyed by call_id so the
|
||||
// role==="tool" branch below can pick it up; legacy rows
|
||||
// missing tool_call_id are flushed at end-of-replay.
|
||||
if (
|
||||
tc.output_assessment &&
|
||||
tc.output_assessment.risk_level &&
|
||||
tc.output_assessment.risk_level !== "none"
|
||||
) {
|
||||
pendingAssessments[tc.id || ""] = {
|
||||
assessment: tc.output_assessment,
|
||||
toolDiv: div,
|
||||
};
|
||||
}
|
||||
});
|
||||
var badge = document.createElement("div");
|
||||
badge.setAttribute("role", "status");
|
||||
@@ -974,18 +1172,6 @@ Pane.prototype.replayHistory = function (messages) {
|
||||
lastToolBlock = block;
|
||||
}
|
||||
}
|
||||
if (msg.content) {
|
||||
var el = document.createElement("div");
|
||||
el.className = "msg assistant";
|
||||
var bodyEl = document.createElement("div");
|
||||
bodyEl.className = "msg-body";
|
||||
var rendered = renderMarkdown(msg.content);
|
||||
bodyEl.innerHTML = rendered;
|
||||
el.appendChild(bodyEl);
|
||||
postRenderMarkdown(el);
|
||||
self.messagesEl.appendChild(el);
|
||||
lastToolBlock = null;
|
||||
}
|
||||
} else if (msg.role === "tool") {
|
||||
if (lastToolBlock) {
|
||||
var stripped = stripAnsi(msg.content || "").trim();
|
||||
@@ -994,34 +1180,156 @@ Pane.prototype.replayHistory = function (messages) {
|
||||
/^Denied by user/.test(stripped) ||
|
||||
/^Blocked/.test(stripped);
|
||||
var isToolError = !!msg.is_error;
|
||||
// Anchor the rendered output to the specific .ts-approval-tool
|
||||
// element matching this result's tool_call_id — mirrors the
|
||||
// live appendToolOutput path so multi-tool batches show
|
||||
// [hdr A][out A][hdr B][out B] rather than [A][B][out A][out B].
|
||||
// Falls back to "before badge" when tool_call_id is absent
|
||||
// (legacy rows pre-dating the wire-format addition).
|
||||
var resultTarget = null;
|
||||
if (msg.tool_call_id) {
|
||||
resultTarget = lastToolBlock.querySelector(
|
||||
'.ts-approval-tool[data-call-id="' +
|
||||
CSS.escape(msg.tool_call_id) +
|
||||
'"]',
|
||||
);
|
||||
}
|
||||
// Cursor-style append: cursor advances after each insert so
|
||||
// the next sibling lands AFTER the previous one. Fixes the
|
||||
// bug where calling resultTarget.after(node) twice put the
|
||||
// second node BETWEEN resultTarget and the first (the second
|
||||
// .after call was always relative to the same anchor).
|
||||
// Resulting order with all three present:
|
||||
// [tool div][output][truncation pill][output-warning]
|
||||
var insertCursor = resultTarget;
|
||||
var insertChained = function (node) {
|
||||
if (insertCursor) {
|
||||
insertCursor.after(node);
|
||||
insertCursor = node;
|
||||
} else {
|
||||
var bdg = lastToolBlock.querySelector(".ts-approval-badge");
|
||||
if (bdg) lastToolBlock.insertBefore(node, bdg);
|
||||
else lastToolBlock.appendChild(node);
|
||||
}
|
||||
};
|
||||
if (stripped && !isDenied) {
|
||||
var media = !isToolError ? tryParseMedia(stripped) : null;
|
||||
if (media) {
|
||||
var embed = buildMediaEmbed(media, stripped);
|
||||
var bdg = lastToolBlock.querySelector(".ts-approval-badge");
|
||||
if (bdg) lastToolBlock.insertBefore(embed, bdg);
|
||||
else lastToolBlock.appendChild(embed);
|
||||
insertChained(buildMediaEmbed(media, stripped));
|
||||
} else {
|
||||
var out = renderToolOutput(stripped, isToolError);
|
||||
if (out.textContent.split("\n").length > 10) {
|
||||
makeCollapsible(out);
|
||||
}
|
||||
var bdg = lastToolBlock.querySelector(".ts-approval-badge");
|
||||
if (bdg) lastToolBlock.insertBefore(out, bdg);
|
||||
else lastToolBlock.appendChild(out);
|
||||
insertChained(out);
|
||||
}
|
||||
// Truncation pill — server marks this when the stored row
|
||||
// hit the 2000-char cap. Live tool_result events carry full
|
||||
// output so they don't need the indicator.
|
||||
if (msg.truncated) {
|
||||
var pill = document.createElement("span");
|
||||
pill.className = "tool-output-truncated";
|
||||
pill.textContent = "… truncated in storage";
|
||||
pill.title =
|
||||
"The full tool output was sent to the model live; only the first 10000 characters are persisted to the conversation row.";
|
||||
insertChained(pill);
|
||||
}
|
||||
}
|
||||
if (isToolError && !lastToolBlock.classList.contains("denied")) {
|
||||
lastToolBlock.classList.add("error");
|
||||
appendToolErrorBadge(lastToolBlock);
|
||||
}
|
||||
// Output-guard warning — pull the assessment out of the
|
||||
// function-local pendingAssessments map (populated in the
|
||||
// assistant branch). Skip when the tool result was denied —
|
||||
// the ✗ denied badge already signals the deny path.
|
||||
if (!isDenied && msg.tool_call_id) {
|
||||
var pending = pendingAssessments[msg.tool_call_id];
|
||||
if (pending) {
|
||||
insertChained(_buildOutputWarningEl(pending.assessment));
|
||||
delete pendingAssessments[msg.tool_call_id];
|
||||
}
|
||||
}
|
||||
}
|
||||
// Tool-channel metacog reminders (tool_error / repeat) attach
|
||||
// to the LAST tool message in a batch; on replay we render the
|
||||
// bubble immediately below the .ts-approval block that owns
|
||||
// the tool result. addToolReminder's empty-toolCallId fallback
|
||||
// resolves to "last .ts-approval block" — which is exactly
|
||||
// lastToolBlock here.
|
||||
if (Array.isArray(msg.reminders) && msg.reminders.length) {
|
||||
this.addToolReminder(msg.reminders, "");
|
||||
}
|
||||
}
|
||||
}
|
||||
// Flush any output_assessments left in the map — these correspond
|
||||
// to assistant tool_calls whose tool result row didn't carry a
|
||||
// tool_call_id (legacy / migrated rows pre-dating the wire-format
|
||||
// addition). Render the warning under the tool div itself rather
|
||||
// than dropping the safety information silently.
|
||||
var leftoverIds = Object.keys(pendingAssessments);
|
||||
for (var p = 0; p < leftoverIds.length; p++) {
|
||||
var leftover = pendingAssessments[leftoverIds[p]];
|
||||
if (!leftover) continue;
|
||||
leftover.toolDiv.insertAdjacentElement(
|
||||
"afterend",
|
||||
_buildOutputWarningEl(leftover.assessment),
|
||||
);
|
||||
}
|
||||
this._attachRetryToLastAssistant();
|
||||
this.scrollToBottom();
|
||||
// Focus the input so keyboard users land on the next-action target
|
||||
// after replay finishes — but only when this is the focused pane,
|
||||
// there's no pending approval competing for focus, and an input
|
||||
// element actually exists. Skipping when not the focused pane
|
||||
// avoids stealing focus from another tab the user is interacting
|
||||
// with while a background replay completes.
|
||||
if (
|
||||
this.id === focusedPaneId &&
|
||||
!this.pendingApproval &&
|
||||
this.inputEl &&
|
||||
!this.busy
|
||||
) {
|
||||
try {
|
||||
this.inputEl.focus({ preventScroll: true });
|
||||
} catch (_) {
|
||||
this.inputEl.focus();
|
||||
}
|
||||
}
|
||||
// Restore live-region semantics now that the batch build is done.
|
||||
this.messagesEl.removeAttribute("aria-busy");
|
||||
};
|
||||
|
||||
// Shared output-warning DOM builder — used by both replayHistory
|
||||
// (saved-workstream rendering) and the live appendToolOutput path
|
||||
// via showOutputWarning. Single source of truth keeps the two
|
||||
// surfaces from drifting on role / class / escape semantics.
|
||||
function _buildOutputWarningEl(assessment) {
|
||||
var risk = (assessment && assessment.risk_level) || "medium";
|
||||
var flags = (assessment && assessment.flags) || [];
|
||||
var warning = document.createElement("div");
|
||||
warning.className = "output-warning output-warning-" + risk;
|
||||
// role="status" (polite) rather than "alert" (assertive) — these
|
||||
// are findings, not emergencies; the assertive announcement live
|
||||
// would interrupt the user mid-typing on a high-risk match, which
|
||||
// is more disruptive than informative.
|
||||
warning.setAttribute("role", "status");
|
||||
var labelEl = document.createElement("span");
|
||||
labelEl.className = "output-warning-label";
|
||||
labelEl.textContent = "⚠ " + String(risk).toUpperCase();
|
||||
warning.appendChild(labelEl);
|
||||
if (flags.length) {
|
||||
warning.appendChild(document.createTextNode(" " + flags.join(", ")));
|
||||
}
|
||||
if (assessment && assessment.redacted) {
|
||||
var redacted = document.createElement("span");
|
||||
redacted.className = "output-warning-redacted";
|
||||
redacted.textContent = " (credentials redacted)";
|
||||
warning.appendChild(redacted);
|
||||
}
|
||||
return warning;
|
||||
}
|
||||
|
||||
Pane.prototype._attachRetryToLastAssistant = function () {
|
||||
// Remove any previous retry buttons
|
||||
var old = this.messagesEl.querySelectorAll(".msg.assistant .msg-actions");
|
||||
@@ -1029,6 +1337,21 @@ Pane.prototype._attachRetryToLastAssistant = function () {
|
||||
// Find the last assistant message with content and add retry.
|
||||
// Reasoning blocks emit as .msg.reasoning (distinct modifier) so the
|
||||
// .msg.assistant selector already excludes them — no extra guard needed.
|
||||
//
|
||||
// Skip retry attachment when the most recent semantic turn is
|
||||
// tool-only — last DOM child is a .ts-approval block. Walk back
|
||||
// past .user-reminder bubbles (added via addToolReminder /
|
||||
// addUserReminder AFTER the .ts-approval block they advise) so the
|
||||
// guard fires correctly even when the tool turn carried a metacog
|
||||
// reminder. Without this skip, retry lands on a stale prior
|
||||
// assistant content bubble belonging to an earlier turn.
|
||||
var lastChild = this.messagesEl.lastElementChild;
|
||||
while (lastChild && lastChild.classList.contains("user-reminder")) {
|
||||
lastChild = lastChild.previousElementSibling;
|
||||
}
|
||||
if (lastChild && lastChild.classList.contains("ts-approval")) {
|
||||
return;
|
||||
}
|
||||
var assistants = this.messagesEl.querySelectorAll(".msg.assistant");
|
||||
if (assistants.length) {
|
||||
this._addRetryAction(assistants[assistants.length - 1]);
|
||||
@@ -1318,6 +1641,19 @@ Pane.prototype.appendToolOutput = function (callId, name, output, isError) {
|
||||
var stripped = stripAnsi(output || "").trim();
|
||||
if (!stripped) return;
|
||||
|
||||
// Skip rendering for denied/blocked tool results — the ✗ denied
|
||||
// badge from resolveApproval already shows the denial reason; the
|
||||
// SSE tool_result event would otherwise duplicate the text. Mirror
|
||||
// the guard in the history-replay path (the live path used to be
|
||||
// safe because no tool_result event was ever emitted for denied
|
||||
// items, but we now emit one so _tool_error_flags gets set).
|
||||
var parentBlock = target.closest(".ts-approval");
|
||||
var isDenied =
|
||||
(parentBlock && parentBlock.classList.contains("denied")) ||
|
||||
/^Denied by user/.test(stripped) ||
|
||||
/^Blocked/.test(stripped);
|
||||
if (isDenied) return;
|
||||
|
||||
// Detect structured media output and render interactive embed
|
||||
if (!isError) {
|
||||
var media = tryParseMedia(stripped);
|
||||
@@ -1332,12 +1668,9 @@ Pane.prototype.appendToolOutput = function (callId, name, output, isError) {
|
||||
var out = renderToolOutput(stripped, isError);
|
||||
|
||||
// Mark the parent approval block as errored
|
||||
if (isError) {
|
||||
var parentBlock = target.closest(".ts-approval");
|
||||
if (parentBlock && !parentBlock.classList.contains("denied")) {
|
||||
parentBlock.classList.add("error");
|
||||
appendToolErrorBadge(parentBlock);
|
||||
}
|
||||
if (isError && parentBlock && !parentBlock.classList.contains("denied")) {
|
||||
parentBlock.classList.add("error");
|
||||
appendToolErrorBadge(parentBlock);
|
||||
}
|
||||
|
||||
if (out.textContent.split("\n").length > 10) {
|
||||
@@ -1355,20 +1688,14 @@ Pane.prototype.showOutputWarning = function (evt) {
|
||||
'.ts-approval-tool[data-call-id="' + escapedId + '"]',
|
||||
);
|
||||
if (!toolDiv) return;
|
||||
var risk = evt.risk_level || "medium";
|
||||
var flags = evt.flags || [];
|
||||
var warning = document.createElement("div");
|
||||
warning.className = "output-warning output-warning-" + risk;
|
||||
warning.setAttribute("role", "alert");
|
||||
warning.innerHTML =
|
||||
'<span class="output-warning-label">\u26a0 ' +
|
||||
escapeHtml(risk.toUpperCase()) +
|
||||
"</span> " +
|
||||
flags.map(escapeHtml).join(", ");
|
||||
if (evt.redacted) {
|
||||
warning.innerHTML +=
|
||||
' <span class="output-warning-redacted">(credentials redacted)</span>';
|
||||
}
|
||||
// Shared DOM-builder with replayHistory \u2014 single source of truth for
|
||||
// role / class / escape semantics. Argument shape mirrors the
|
||||
// server-side output_assessment dict (risk_level / flags / redacted).
|
||||
var warning = _buildOutputWarningEl({
|
||||
risk_level: evt.risk_level,
|
||||
flags: evt.flags,
|
||||
redacted: evt.redacted,
|
||||
});
|
||||
var nextEl = toolDiv.nextElementSibling;
|
||||
if (nextEl && nextEl.classList.contains("tool-output")) {
|
||||
nextEl.insertAdjacentElement("afterend", warning);
|
||||
@@ -1594,6 +1921,16 @@ Pane.prototype.sendMessage = function () {
|
||||
} else if (data.status === "queue_full") {
|
||||
if (queuedEl) self.queue.remove(queuedEl);
|
||||
self.addErrorMessage("Message queue full. Please wait.");
|
||||
} else if (data.status === "attachments_busy") {
|
||||
// Attachments can't ride a queued user turn — server held the
|
||||
// chips' reservations long enough to bounce the request and
|
||||
// released them. Surface to the user; chips stay in the
|
||||
// composer so they can retry once the assistant finishes.
|
||||
if (queuedEl) self.queue.remove(queuedEl);
|
||||
self.addErrorMessage(
|
||||
"Attachments can't be sent while the assistant is working. " +
|
||||
"Send a text-only message now, or wait and resend with attachments.",
|
||||
);
|
||||
} else {
|
||||
self.attachments.consume(
|
||||
data.attached_ids,
|
||||
@@ -2351,6 +2688,9 @@ function showTabDropdown(chevronEl, wsId) {
|
||||
menu.style.top = my + "px";
|
||||
_tabDropdown = menu;
|
||||
|
||||
// Keyboard handler is mirrored by the console node-picker shim in
|
||||
// turnstone/console/server.py (search for closeHandler in _JS_PROXY_SHIM).
|
||||
// If you change the keys or filter selector here, change them there.
|
||||
_tabDropdownCloseHandler = function (e) {
|
||||
if (e.type === "keydown") {
|
||||
if (e.key === "Escape" || e.key === "Tab") {
|
||||
@@ -3511,12 +3851,35 @@ function updateDashFooter(agg) {
|
||||
}
|
||||
}
|
||||
|
||||
var _wsDeleteMode = false;
|
||||
var _wsDeleteSelected = {};
|
||||
// Saved Workstreams cache + multi-select delete controller. The
|
||||
// controller (from /shared/cards.js) owns mode state, checkbox
|
||||
// decoration, the toolbar wiring, and the confirmation modal — see
|
||||
// createSavedCardsController for the shared bits.
|
||||
var _wsSavedItems = [];
|
||||
var _wsDeleteController = createSavedCardsController({
|
||||
idPrefix: "ws-delete",
|
||||
buttonId: "ws-delete-btn",
|
||||
noun: "workstream",
|
||||
activateLabel: function (s) {
|
||||
return "Resume: " + (s.alias || s.title || s.ws_id);
|
||||
},
|
||||
buildDeleteRequest: function (wsId) {
|
||||
return {
|
||||
url: "/v1/api/workstreams/" + encodeURIComponent(wsId) + "/delete",
|
||||
options: { method: "POST" },
|
||||
};
|
||||
},
|
||||
render: function () {
|
||||
renderSavedWorkstreams(_wsSavedItems);
|
||||
},
|
||||
onClose: function () {
|
||||
loadDashboard();
|
||||
},
|
||||
});
|
||||
|
||||
function renderSavedWorkstreams(items) {
|
||||
_wsSavedItems = items;
|
||||
_wsDeleteController.setItems(items);
|
||||
var c = document.getElementById("dashboard-saved-cards");
|
||||
c.replaceChildren();
|
||||
if (!items.length) {
|
||||
@@ -3527,289 +3890,39 @@ function renderSavedWorkstreams(items) {
|
||||
return;
|
||||
}
|
||||
items.forEach(function (sess) {
|
||||
// Default card shape (title + meta + wsid + Resume click) comes from
|
||||
// the shared /shared/cards.js helper so console (Saved Coordinators)
|
||||
// and ui/static (Saved Workstreams) stay in lock-step. Delete mode
|
||||
// is interactive-only; we layer the checkbox + selection wiring on
|
||||
// top of the shared card after construction.
|
||||
var card = renderSessionCard(sess, {
|
||||
ariaLabel: function (s) {
|
||||
var label = s.alias || s.title || s.ws_id;
|
||||
return _wsDeleteMode ? "Select: " + label : "Resume: " + label;
|
||||
},
|
||||
ariaLabel: _wsDeleteController.ariaLabel,
|
||||
onActivate: function (s) {
|
||||
// Suppressed in delete mode \u2014 the layered checkbox handler below
|
||||
// owns clicks while delete-mode is active.
|
||||
if (_wsDeleteMode) return;
|
||||
if (_wsDeleteController.blockActivate()) return;
|
||||
dashboardResumeSession(s.ws_id);
|
||||
},
|
||||
});
|
||||
|
||||
if (_wsDeleteMode) {
|
||||
card.classList.add("ws-delete-mode");
|
||||
card.removeAttribute("role"); // becomes a checkbox host, not a button
|
||||
var chk = document.createElement("input");
|
||||
chk.type = "checkbox";
|
||||
chk.className = "ws-card-check";
|
||||
chk.checked = !!_wsDeleteSelected[sess.ws_id];
|
||||
var label = sess.alias || sess.title || sess.ws_id;
|
||||
chk.setAttribute("aria-label", "Select " + label + " for deletion");
|
||||
chk.onclick = function (e) {
|
||||
e.stopPropagation();
|
||||
if (chk.checked) _wsDeleteSelected[sess.ws_id] = true;
|
||||
else delete _wsDeleteSelected[sess.ws_id];
|
||||
card.classList.toggle("ws-selected", chk.checked);
|
||||
updateWsDeleteBar();
|
||||
};
|
||||
card.insertBefore(chk, card.firstChild);
|
||||
// Override the shared helper's onclick/onkeydown \u2014 in delete mode
|
||||
// a card click toggles the checkbox instead of activating Resume.
|
||||
card.onclick = function (e) {
|
||||
if (e.target === chk) return;
|
||||
chk.checked = !chk.checked;
|
||||
chk.onclick(e);
|
||||
};
|
||||
card.onkeydown = function (e) {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
chk.checked = !chk.checked;
|
||||
chk.onclick(e);
|
||||
}
|
||||
};
|
||||
if (_wsDeleteSelected[sess.ws_id]) card.classList.add("ws-selected");
|
||||
}
|
||||
|
||||
_wsDeleteController.decorateCard(card, sess);
|
||||
c.appendChild(card);
|
||||
});
|
||||
if (_wsDeleteController.inMode()) _wsDeleteController.refreshBar();
|
||||
}
|
||||
|
||||
// HTML inline-onclick wrappers — keep the global names the existing
|
||||
// markup binds to (`onclick="startWsDeleteMode()"` etc.) and forward
|
||||
// to the controller.
|
||||
function startWsDeleteMode() {
|
||||
_wsDeleteMode = true;
|
||||
_wsDeleteSelected = {};
|
||||
renderSavedWorkstreams(_wsSavedItems);
|
||||
var btn = document.getElementById("ws-delete-btn");
|
||||
if (btn) {
|
||||
btn.textContent = "\u2715 Cancel";
|
||||
btn.onclick = cancelWsDeleteMode;
|
||||
}
|
||||
var bar = document.getElementById("ws-delete-bar");
|
||||
if (bar) bar.classList.add("visible");
|
||||
_wsDeleteController.start();
|
||||
}
|
||||
|
||||
function cancelWsDeleteMode() {
|
||||
_wsDeleteMode = false;
|
||||
_wsDeleteSelected = {};
|
||||
renderSavedWorkstreams(_wsSavedItems);
|
||||
var btn = document.getElementById("ws-delete-btn");
|
||||
if (btn) {
|
||||
btn.innerHTML = "🗑 Delete";
|
||||
btn.onclick = startWsDeleteMode;
|
||||
}
|
||||
var bar = document.getElementById("ws-delete-bar");
|
||||
if (bar) bar.classList.remove("visible");
|
||||
_wsDeleteController.cancel();
|
||||
}
|
||||
|
||||
function updateWsDeleteBar() {
|
||||
var count = Object.keys(_wsDeleteSelected).length;
|
||||
var label = document.getElementById("ws-delete-bar-count");
|
||||
if (label) label.textContent = count + " selected";
|
||||
var delBtn = document.getElementById("ws-delete-bar-delete");
|
||||
if (delBtn) delBtn.disabled = count === 0;
|
||||
var selBtn = document.getElementById("ws-delete-bar-select-all");
|
||||
if (selBtn) {
|
||||
var allSelected =
|
||||
count === _wsSavedItems.length && _wsSavedItems.length > 0;
|
||||
selBtn.textContent = allSelected ? "Deselect All" : "Select All";
|
||||
}
|
||||
}
|
||||
|
||||
function toggleSelectAll() {
|
||||
var allSelected =
|
||||
Object.keys(_wsDeleteSelected).length === _wsSavedItems.length &&
|
||||
_wsSavedItems.length > 0;
|
||||
if (allSelected) {
|
||||
_wsDeleteSelected = {};
|
||||
} else {
|
||||
_wsSavedItems.forEach(function (s) {
|
||||
_wsDeleteSelected[s.ws_id] = true;
|
||||
});
|
||||
}
|
||||
renderSavedWorkstreams(_wsSavedItems);
|
||||
updateWsDeleteBar();
|
||||
_wsDeleteController.toggleAll();
|
||||
}
|
||||
|
||||
var _wsDeleteBatchTrap = null;
|
||||
|
||||
function confirmWsDeleteSelection() {
|
||||
var selected = Object.keys(_wsDeleteSelected);
|
||||
if (!selected.length) {
|
||||
showToast("No workstreams selected", "warning");
|
||||
return;
|
||||
}
|
||||
var overlay = document.getElementById("ws-delete-overlay");
|
||||
var countEl = document.getElementById("ws-delete-count");
|
||||
var listEl = document.getElementById("ws-delete-list");
|
||||
var errorEl = document.getElementById("ws-delete-error");
|
||||
errorEl.textContent = "";
|
||||
countEl.textContent =
|
||||
selected.length + " workstream(s) will be permanently deleted:";
|
||||
listEl.innerHTML = "";
|
||||
selected.forEach(function (wsId) {
|
||||
var item = _wsSavedItems.find(function (s) {
|
||||
return s.ws_id === wsId;
|
||||
});
|
||||
var name = item ? item.alias || item.title || wsId : wsId;
|
||||
var div = document.createElement("div");
|
||||
div.className = "ws-delete-item";
|
||||
div.textContent = name;
|
||||
listEl.appendChild(div);
|
||||
});
|
||||
// Reset confirm button handler (may have been overwritten to "Close" by previous run)
|
||||
var delBtn = document.getElementById("ws-delete-confirm-btn");
|
||||
if (delBtn) {
|
||||
delBtn.textContent = "Delete";
|
||||
delBtn.disabled = false;
|
||||
delBtn.classList.remove("ws-delete-close");
|
||||
delBtn.onclick = confirmWsDelete;
|
||||
}
|
||||
var cancelBtn = document.getElementById("ws-delete-cancel-btn");
|
||||
if (cancelBtn) cancelBtn.disabled = false;
|
||||
overlay.style.display = "flex";
|
||||
|
||||
// Focus trap + Escape
|
||||
if (_wsDeleteBatchTrap)
|
||||
document.removeEventListener("keydown", _wsDeleteBatchTrap);
|
||||
_wsDeleteBatchTrap = function (e) {
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
cancelWsDelete();
|
||||
return;
|
||||
}
|
||||
if (e.key === "Tab") {
|
||||
var box = document.getElementById("ws-delete-box");
|
||||
var focusable = box.querySelectorAll("button:not(:disabled)");
|
||||
var first = focusable[0];
|
||||
var last = focusable[focusable.length - 1];
|
||||
if (e.shiftKey && document.activeElement === first) {
|
||||
e.preventDefault();
|
||||
last.focus();
|
||||
} else if (!e.shiftKey && document.activeElement === last) {
|
||||
e.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
}
|
||||
};
|
||||
document.addEventListener("keydown", _wsDeleteBatchTrap);
|
||||
if (cancelBtn) cancelBtn.focus();
|
||||
_wsDeleteController.confirmSelection();
|
||||
}
|
||||
|
||||
function cancelWsDelete() {
|
||||
document.getElementById("ws-delete-overlay").style.display = "none";
|
||||
if (_wsDeleteBatchTrap) {
|
||||
document.removeEventListener("keydown", _wsDeleteBatchTrap);
|
||||
_wsDeleteBatchTrap = null;
|
||||
}
|
||||
_wsDeleteController.closeModal();
|
||||
}
|
||||
|
||||
function confirmWsDelete() {
|
||||
var selected = Object.keys(_wsDeleteSelected);
|
||||
if (!selected.length) return;
|
||||
var overlay = document.getElementById("ws-delete-overlay");
|
||||
var errorEl = document.getElementById("ws-delete-error");
|
||||
var listEl = document.getElementById("ws-delete-list");
|
||||
var countEl = document.getElementById("ws-delete-count");
|
||||
var delBtn = document.getElementById("ws-delete-confirm-btn");
|
||||
var cancelBtn = document.getElementById("ws-delete-cancel-btn");
|
||||
errorEl.textContent = "";
|
||||
|
||||
// Disable buttons during deletion
|
||||
if (delBtn) {
|
||||
delBtn.disabled = true;
|
||||
delBtn.textContent = "Deleting...";
|
||||
}
|
||||
if (cancelBtn) cancelBtn.disabled = true;
|
||||
|
||||
var results = [];
|
||||
var promises = selected.map(function (wsId) {
|
||||
var shortId = wsId.substring(0, 8);
|
||||
var item = _wsSavedItems.find(function (s) {
|
||||
return s.ws_id === wsId;
|
||||
});
|
||||
var name = item ? item.alias || item.title || wsId : wsId;
|
||||
var url = "/v1/api/workstreams/" + encodeURIComponent(wsId) + "/delete";
|
||||
|
||||
return authFetch(url, { method: "POST" })
|
||||
.then(function (r) {
|
||||
var status = r.status;
|
||||
var contentType = r.headers.get("content-type") || "";
|
||||
if (r.ok) {
|
||||
results.push({ name: name, shortId: shortId, ok: true });
|
||||
return;
|
||||
}
|
||||
// Read body as text first to avoid JSON parse errors
|
||||
return r.text().then(function (body) {
|
||||
var errMsg = shortId + ": HTTP " + status;
|
||||
if (contentType.includes("json")) {
|
||||
try {
|
||||
var j = JSON.parse(body);
|
||||
if (j.error) errMsg = shortId + ": " + j.error;
|
||||
} catch (_) {
|
||||
/* fall through */
|
||||
}
|
||||
} else if (body) {
|
||||
errMsg = shortId + ": " + body.substring(0, 200);
|
||||
}
|
||||
results.push({
|
||||
name: name,
|
||||
shortId: shortId,
|
||||
ok: false,
|
||||
error: errMsg,
|
||||
});
|
||||
});
|
||||
})
|
||||
.catch(function (err) {
|
||||
results.push({
|
||||
name: name,
|
||||
shortId: shortId,
|
||||
ok: false,
|
||||
error: shortId + ": " + err.message,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Promise.all(promises).then(function () {
|
||||
// Rebuild the list with results
|
||||
listEl.innerHTML = "";
|
||||
results.forEach(function (r) {
|
||||
var div = document.createElement("div");
|
||||
div.className = "ws-delete-item" + (r.ok ? "" : " ws-delete-error");
|
||||
div.textContent =
|
||||
(r.ok ? "\u2713 " : "\u2717 ") +
|
||||
r.name +
|
||||
(r.error ? " — " + r.error : "");
|
||||
listEl.appendChild(div);
|
||||
});
|
||||
|
||||
var okCount = results.filter(function (r) {
|
||||
return r.ok;
|
||||
}).length;
|
||||
var failCount = results.filter(function (r) {
|
||||
return !r.ok;
|
||||
}).length;
|
||||
countEl.textContent = okCount + " deleted, " + failCount + " failed";
|
||||
|
||||
if (delBtn) {
|
||||
delBtn.disabled = false;
|
||||
delBtn.textContent = "Close";
|
||||
delBtn.classList.add("ws-delete-close");
|
||||
delBtn.onclick = function () {
|
||||
cancelWsDelete();
|
||||
cancelWsDeleteMode();
|
||||
loadDashboard();
|
||||
};
|
||||
}
|
||||
if (cancelBtn) cancelBtn.disabled = false;
|
||||
});
|
||||
_wsDeleteController.confirm();
|
||||
}
|
||||
|
||||
// --- Workstream title management ---
|
||||
|
||||
@@ -363,17 +363,18 @@
|
||||
<!-- Delete workstreams confirmation modal (batch) -->
|
||||
<div
|
||||
id="ws-delete-overlay"
|
||||
class="ws-delete-modal-overlay"
|
||||
style="display: none"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="ws-delete-title"
|
||||
>
|
||||
<div id="ws-delete-box">
|
||||
<div id="ws-delete-box" class="ws-delete-modal-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">
|
||||
<div id="ws-delete-list" class="ws-delete-modal-list"></div>
|
||||
<div id="ws-delete-buttons" class="ws-delete-modal-buttons">
|
||||
<button
|
||||
id="ws-delete-cancel-btn"
|
||||
type="button"
|
||||
@@ -383,6 +384,7 @@
|
||||
</button>
|
||||
<button
|
||||
id="ws-delete-confirm-btn"
|
||||
class="ws-delete-confirm"
|
||||
type="button"
|
||||
onclick="confirmWsDelete()"
|
||||
>
|
||||
|
||||
+85
-250
@@ -642,6 +642,9 @@
|
||||
.msg.user {
|
||||
color: var(--fg-bright);
|
||||
}
|
||||
/* .msg.user-reminder lives in shared_static/chat.css so both the
|
||||
interactive UI and the console coord viewer pick up the same
|
||||
yellow themed bubble. */
|
||||
/* .msg.assistant / .msg.info / .msg.error alignment + baseline visuals
|
||||
come from shared_static/chat.css. Interactive UI adds a pre-wrap
|
||||
override for info messages and a tightened tool-message shape with
|
||||
@@ -1628,6 +1631,77 @@ body {
|
||||
.ts-approval-tool .tool-diff .diff-warn {
|
||||
color: var(--yellow);
|
||||
}
|
||||
/* memory/recall calls are background metadata — the audit trail is
|
||||
valuable but they crowd the narrative when a workstream contains
|
||||
dozens of them. Dim by default; full opacity on hover/focus so
|
||||
they remain inspectable without permanently competing for
|
||||
attention. General-sibling combinator (~) extends the fade past
|
||||
any verdict-badge or output-warning sitting between the tool row
|
||||
and its output, so the whole sub-tree fades together rather than
|
||||
leaving a full-opacity badge stranded next to a dim row. */
|
||||
.ts-approval-tool[data-func-name="memory"],
|
||||
.ts-approval-tool[data-func-name="recall"] {
|
||||
opacity: 0.55;
|
||||
transition: opacity 120ms ease-out;
|
||||
}
|
||||
.ts-approval-tool[data-func-name="memory"]:hover,
|
||||
.ts-approval-tool[data-func-name="memory"]:focus-within,
|
||||
.ts-approval-tool[data-func-name="recall"]:hover,
|
||||
.ts-approval-tool[data-func-name="recall"]:focus-within {
|
||||
opacity: 1;
|
||||
}
|
||||
.ts-approval-tool[data-func-name="memory"] ~ .tool-output,
|
||||
.ts-approval-tool[data-func-name="recall"] ~ .tool-output,
|
||||
.ts-approval-tool[data-func-name="memory"] ~ .media-embed,
|
||||
.ts-approval-tool[data-func-name="recall"] ~ .media-embed,
|
||||
.ts-approval-tool[data-func-name="memory"] ~ .output-warning,
|
||||
.ts-approval-tool[data-func-name="recall"] ~ .output-warning,
|
||||
.ts-approval-tool[data-func-name="memory"] ~ .tool-output-truncated,
|
||||
.ts-approval-tool[data-func-name="recall"] ~ .tool-output-truncated {
|
||||
opacity: 0.55;
|
||||
transition: opacity 120ms ease-out;
|
||||
}
|
||||
/* Reveal on hover OR focus-within across the entire dimmed
|
||||
subtree. Without :focus-within on the siblings, a keyboard user
|
||||
tabbing into a link or collapsible toggle inside .tool-output
|
||||
sees the content remain dimmed — a11y regression. Cover the
|
||||
warning + truncation pills too so they fully reveal alongside
|
||||
the result they decorate. */
|
||||
.ts-approval-tool[data-func-name="memory"] ~ .tool-output:hover,
|
||||
.ts-approval-tool[data-func-name="recall"] ~ .tool-output:hover,
|
||||
.ts-approval-tool[data-func-name="memory"] ~ .tool-output:focus-within,
|
||||
.ts-approval-tool[data-func-name="recall"] ~ .tool-output:focus-within,
|
||||
.ts-approval-tool[data-func-name="memory"] ~ .media-embed:hover,
|
||||
.ts-approval-tool[data-func-name="recall"] ~ .media-embed:hover,
|
||||
.ts-approval-tool[data-func-name="memory"] ~ .media-embed:focus-within,
|
||||
.ts-approval-tool[data-func-name="recall"] ~ .media-embed:focus-within,
|
||||
.ts-approval-tool[data-func-name="memory"] ~ .output-warning:hover,
|
||||
.ts-approval-tool[data-func-name="recall"] ~ .output-warning:hover,
|
||||
.ts-approval-tool[data-func-name="memory"] ~ .output-warning:focus-within,
|
||||
.ts-approval-tool[data-func-name="recall"] ~ .output-warning:focus-within,
|
||||
.ts-approval-tool[data-func-name="memory"] ~ .tool-output-truncated:hover,
|
||||
.ts-approval-tool[data-func-name="recall"] ~ .tool-output-truncated:hover,
|
||||
.ts-approval-tool[data-func-name="memory"] ~ .tool-output-truncated:focus-within,
|
||||
.ts-approval-tool[data-func-name="recall"] ~ .tool-output-truncated:focus-within {
|
||||
opacity: 1;
|
||||
}
|
||||
/* Truncation indicator — the persisted tool result is clamped at
|
||||
2000 chars per row in storage; surface that on replay so users
|
||||
know they're seeing a clipped view rather than the full output
|
||||
the live session saw. Aligns with .output-warning's left gutter
|
||||
(margin-left: 16px) and uses transparent background + dim border
|
||||
so it reads as quiet metadata rather than a foreign element. */
|
||||
.tool-output-truncated {
|
||||
display: inline-block;
|
||||
margin-top: 4px;
|
||||
margin-left: 16px;
|
||||
padding: 1px 6px;
|
||||
font-size: 10px;
|
||||
color: var(--fg-dim);
|
||||
background: transparent;
|
||||
border-radius: 3px;
|
||||
border: 1px solid var(--fg-dim);
|
||||
}
|
||||
/* .ts-approval (chat.css) stacks its children with flex gap, so a
|
||||
border-top on the body would float above a strip of container
|
||||
background instead of sitting flush against the previous tool row.
|
||||
@@ -2363,224 +2437,10 @@ audio.media-player {
|
||||
color: var(--accent);
|
||||
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 / .dashboard-card / .card-title / .card-meta moved to
|
||||
/shared/cards.css so console (Saved Coordinators) and ui/static (Saved
|
||||
Workstreams) share one source of truth for the basic card primitive.
|
||||
Delete-mode rules stay below — they're ui/static-only until coordinator
|
||||
gets the same UX. */
|
||||
|
||||
/* Delete mode */
|
||||
.dashboard-card.ws-delete-mode {
|
||||
cursor: pointer;
|
||||
}
|
||||
.dashboard-card.ws-delete-mode:hover {
|
||||
border-color: var(--red);
|
||||
background: rgba(248, 113, 113, 0.04);
|
||||
}
|
||||
.dashboard-card.ws-delete-mode.ws-selected {
|
||||
cursor: default;
|
||||
}
|
||||
.dashboard-card.ws-delete-mode.ws-selected:hover {
|
||||
border-color: var(--red);
|
||||
background: rgba(248, 113, 113, 0.08);
|
||||
}
|
||||
[data-theme="light"] .dashboard-card.ws-delete-mode:hover {
|
||||
background: rgba(220, 38, 38, 0.04);
|
||||
}
|
||||
[data-theme="light"] .dashboard-card.ws-delete-mode.ws-selected:hover {
|
||||
background: rgba(220, 38, 38, 0.08);
|
||||
}
|
||||
.ws-card-check {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
accent-color: var(--red);
|
||||
cursor: pointer;
|
||||
z-index: 1;
|
||||
opacity: 0;
|
||||
animation: ws-check-fadein 0.2s ease-out forwards;
|
||||
}
|
||||
@keyframes ws-check-fadein {
|
||||
to {
|
||||
opacity: 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;
|
||||
animation: ws-bar-slide 0.2s ease-out;
|
||||
}
|
||||
@keyframes ws-bar-slide {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-8px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.ws-card-check {
|
||||
animation: none;
|
||||
opacity: 1;
|
||||
}
|
||||
.ws-delete-bar.visible {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
.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);
|
||||
}
|
||||
.ws-delete-bar .ws-delete-selectall-btn {
|
||||
background: transparent;
|
||||
color: var(--fg-bright);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 6px 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
}
|
||||
.ws-delete-bar .ws-delete-selectall-btn:hover {
|
||||
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);
|
||||
}
|
||||
#ws-delete-buttons button.ws-delete-close {
|
||||
background: transparent;
|
||||
color: var(--fg-bright);
|
||||
border-color: var(--border);
|
||||
}
|
||||
/* .dashboard-cards / .dashboard-card / .card-title / .card-meta and the
|
||||
delete-mode + modal rules are owned by /shared/cards.css so console
|
||||
(Saved Coordinators) and ui/static (Saved Workstreams) share one source
|
||||
of truth. */
|
||||
|
||||
/* Server dashboard row — clickable */
|
||||
.dash-row {
|
||||
@@ -2686,38 +2546,13 @@ audio.media-player {
|
||||
}
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Verdict badges (intent judge)
|
||||
========================================================================== */
|
||||
.verdict-badge {
|
||||
padding: 4px 10px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 2px;
|
||||
/* No top separator — .ts-verdict-badge (chat.css) shrinks to
|
||||
max-content width, and a 1px border-top would extend only under
|
||||
the badge text and read as a truncated line. */
|
||||
}
|
||||
.verdict-low {
|
||||
color: var(--green);
|
||||
border-left: 3px solid var(--green);
|
||||
}
|
||||
.verdict-medium {
|
||||
color: var(--yellow);
|
||||
border-left: 3px solid var(--yellow);
|
||||
}
|
||||
.verdict-high {
|
||||
color: var(--red);
|
||||
border-left: 3px solid var(--red);
|
||||
}
|
||||
.verdict-critical {
|
||||
color: var(--red);
|
||||
border-left: 3px solid var(--red);
|
||||
background: rgba(255, 80, 80, 0.05);
|
||||
}
|
||||
/* Verdict-badge styling lives in the color-mix block further down
|
||||
in this file (.verdict-badge.verdict-{low,medium,high,critical}).
|
||||
The earlier flat-palette duplicate that lived here was removed —
|
||||
two competing .verdict-badge rule sets caused subtle cascade drift
|
||||
(the color-mix block won for backgrounds, the flat one won for the
|
||||
bare .verdict-low/medium/high/critical class names) which made
|
||||
tweaks fragile. Single source of truth now. */
|
||||
|
||||
.verdict-detail {
|
||||
padding: 6px 12px;
|
||||
|
||||
@@ -598,16 +598,16 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "ddgs"
|
||||
version = "9.14.1"
|
||||
version = "9.14.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "click" },
|
||||
{ name = "lxml" },
|
||||
{ name = "primp" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c9/f2/aa1f5af106ea0ef0351d11a2fe05d28618463160137326eeb3073b7d788b/ddgs-9.14.1.tar.gz", hash = "sha256:85b878225a622ba145aff33c0f2f0dceb90d6cfaa291af253021d10cb261a8bb", size = 57157, upload-time = "2026-04-20T12:09:21.313Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e6/31/4b8ad86fd97fba7cff52d9d7c59a002ddf9ef0ba8fa4d70b925190471c33/ddgs-9.14.2.tar.gz", hash = "sha256:a9e6ad5bd7357707163d1cf03dbbcc9413a5820738ba5176efe36955b32aab38", size = 57205, upload-time = "2026-05-03T19:45:30.229Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/4f/a0/c0b568acd6ec819ec94ecfd4eebd00edc855efab06e589ad17d0412ff4ce/ddgs-9.14.1-py3-none-any.whl", hash = "sha256:e6b853be092532add9c0d611c4b121f0b27092de66756401057c2100f6b1ab44", size = 67019, upload-time = "2026-04-20T12:09:19.867Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/94/e6/5d258f7bfb418a5d33c3a77fba327efd8bd6c5d834d06f07b4f229033c33/ddgs-9.14.2-py3-none-any.whl", hash = "sha256:47f5002ebe72d0e7d342d9ce9c0cd9d1125fa7b9ee38dc47069449f4a8382d37", size = 67058, upload-time = "2026-05-03T19:45:28.693Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -748,59 +748,59 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "greenlet"
|
||||
version = "3.4.0"
|
||||
version = "3.5.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/86/94/a5935717b307d7c71fe877b52b884c6af707d2d2090db118a03fbd799369/greenlet-3.4.0.tar.gz", hash = "sha256:f50a96b64dafd6169e595a5c56c9146ef80333e67d4476a65a9c55f400fc22ff", size = 195913, upload-time = "2026-04-08T17:08:00.863Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/3c/3f/dbf99fb14bfeb88c28f16729215478c0e265cacd6dc22270c8f31bb6892f/greenlet-3.5.0.tar.gz", hash = "sha256:d419647372241bc68e957bf38d5c1f98852155e4146bd1e4121adea81f4f01e4", size = 196995, upload-time = "2026-04-27T13:37:15.544Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/fb/c6/dba32cab7e3a625b011aa5647486e2d28423a48845a2998c126dd69c85e1/greenlet-3.4.0-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:805bebb4945094acbab757d34d6e1098be6de8966009ab9ca54f06ff492def58", size = 285504, upload-time = "2026-04-08T15:52:14.071Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/54/f4/7cb5c2b1feb9a1f50e038be79980dfa969aa91979e5e3a18fdbcfad2c517/greenlet-3.4.0-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:439fc2f12b9b512d9dfa681c5afe5f6b3232c708d13e6f02c845e0d9f4c2d8c6", size = 605476, upload-time = "2026-04-08T16:24:37.064Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/af/b66ab0b2f9a4c5a867c136bf66d9599f34f21a1bcca26a2884a29c450bd9/greenlet-3.4.0-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a70ed1cb0295bee1df57b63bf7f46b4e56a5c93709eea769c1fec1bb23a95875", size = 618336, upload-time = "2026-04-08T16:30:56.59Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6d/31/56c43d2b5de476f77d36ceeec436328533bff960a4cba9a07616e93063ab/greenlet-3.4.0-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8c5696c42e6bb5cfb7c6ff4453789081c66b9b91f061e5e9367fa15792644e76", size = 625045, upload-time = "2026-04-08T16:40:37.111Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/5c/8c5633ece6ba611d64bf2770219a98dd439921d6424e4e8cf16b0ac74ea5/greenlet-3.4.0-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c660bce1940a1acae5f51f0a064f1bc785d07ea16efcb4bc708090afc4d69e83", size = 613515, upload-time = "2026-04-08T15:56:32.478Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/80/ca/704d4e2c90acb8bdf7ae593f5cbc95f58e82de95cc540fb75631c1054533/greenlet-3.4.0-cp311-cp311-manylinux_2_39_riscv64.whl", hash = "sha256:89995ce5ddcd2896d89615116dd39b9703bfa0c07b583b85b89bf1b5d6eddf81", size = 419745, upload-time = "2026-04-08T16:43:04.022Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a9/df/950d15bca0d90a0e7395eb777903060504cdb509b7b705631e8fb69ff415/greenlet-3.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:ee407d4d1ca9dc632265aee1c8732c4a2d60adff848057cdebfe5fe94eb2c8a2", size = 1574623, upload-time = "2026-04-08T16:26:18.596Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/e7/0839afab829fcb7333c9ff6d80c040949510055d2d4d63251f0d1c7c804e/greenlet-3.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:956215d5e355fffa7c021d168728321fd4d31fd730ac609b1653b450f6a4bc71", size = 1639579, upload-time = "2026-04-08T15:57:29.231Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d9/2b/b4482401e9bcaf9f5c97f67ead38db89c19520ff6d0d6699979c6efcc200/greenlet-3.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:5cb614ace7c27571270354e9c9f696554d073f8aa9319079dcba466bbdead711", size = 238233, upload-time = "2026-04-08T17:02:54.286Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/4d/d8123a4e0bcd583d5cfc8ddae0bbe29c67aab96711be331a7cc935a35966/greenlet-3.4.0-cp311-cp311-win_arm64.whl", hash = "sha256:04403ac74fe295a361f650818de93be11b5038a78f49ccfb64d3b1be8fbf1267", size = 235045, upload-time = "2026-04-08T17:04:05.072Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/65/8b/3669ad3b3f247a791b2b4aceb3aa5a31f5f6817bf547e4e1ff712338145a/greenlet-3.4.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:1a54a921561dd9518d31d2d3db4d7f80e589083063ab4d3e2e950756ef809e1a", size = 286902, upload-time = "2026-04-08T15:52:12.138Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/38/3e/3c0e19b82900873e2d8469b590a6c4b3dfd2b316d0591f1c26b38a4879a5/greenlet-3.4.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:16dec271460a9a2b154e3b1c2fa1050ce6280878430320e85e08c166772e3f97", size = 606099, upload-time = "2026-04-08T16:24:38.408Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b5/33/99fef65e7754fc76a4ed14794074c38c9ed3394a5bd129d7f61b705f3168/greenlet-3.4.0-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:90036ce224ed6fe75508c1907a77e4540176dcf0744473627785dd519c6f9996", size = 618837, upload-time = "2026-04-08T16:30:58.298Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/44/57/eae2cac10421feae6c0987e3dc106c6d86262b1cb379e171b017aba893a6/greenlet-3.4.0-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6f0def07ec9a71d72315cf26c061aceee53b306c36ed38c35caba952ea1b319d", size = 624901, upload-time = "2026-04-08T16:40:38.981Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/36/f7/229f3aed6948faa20e0616a0b8568da22e365ede6a54d7d369058b128afd/greenlet-3.4.0-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a1c4f6b453006efb8310affb2d132832e9bbb4fc01ce6df6b70d810d38f1f6dc", size = 615062, upload-time = "2026-04-08T15:56:33.766Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/8a/0e73c9b94f31d1cc257fe79a0eff621674141cdae7d6d00f40de378a1e42/greenlet-3.4.0-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:0e1254cf0cbaa17b04320c3a78575f29f3c161ef38f59c977108f19ffddaf077", size = 423927, upload-time = "2026-04-08T16:43:05.293Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/08/97/d988180011aa40135c46cd0d0cf01dd97f7162bae14139b4a3ef54889ba5/greenlet-3.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:9b2d9a138ffa0e306d0e2b72976d2fb10b97e690d40ab36a472acaab0838e2de", size = 1573511, upload-time = "2026-04-08T16:26:20.058Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d4/0f/a5a26fe152fb3d12e6a474181f6e9848283504d0afd095f353d85726374b/greenlet-3.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8424683caf46eb0eb6f626cb95e008e8cc30d0cb675bdfa48200925c79b38a08", size = 1640396, upload-time = "2026-04-08T15:57:30.88Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/42/cf/bb2c32d9a100e36ee9f6e38fad6b1e082b8184010cb06259b49e1266ca01/greenlet-3.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:a0a53fb071531d003b075c444014ff8f8b1a9898d36bb88abd9ac7b3524648a2", size = 238892, upload-time = "2026-04-08T17:03:10.094Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/47/6c41314bac56e71436ce551c7fbe3cc830ed857e6aa9708dbb9c65142eb6/greenlet-3.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:f38b81880ba28f232f1f675893a39cf7b6db25b31cc0a09bb50787ecf957e85e", size = 235599, upload-time = "2026-04-08T15:52:54.3Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/75/7e9cd1126a1e1f0cd67b0eda02e5221b28488d352684704a78ed505bd719/greenlet-3.4.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:43748988b097f9c6f09364f260741aa73c80747f63389824435c7a50bfdfd5c1", size = 285856, upload-time = "2026-04-08T15:52:45.82Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/c4/3e2df392e5cb199527c4d9dbcaa75c14edcc394b45040f0189f649631e3c/greenlet-3.4.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5566e4e2cd7a880e8c27618e3eab20f3494452d12fd5129edef7b2f7aa9a36d1", size = 610208, upload-time = "2026-04-08T16:24:39.674Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/da/af/750cdfda1d1bd30a6c28080245be8d0346e669a98fdbae7f4102aa95fff3/greenlet-3.4.0-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1054c5a3c78e2ab599d452f23f7adafef55062a783a8e241d24f3b633ba6ff82", size = 621269, upload-time = "2026-04-08T16:30:59.767Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/93/c8c508d68ba93232784bbc1b5474d92371f2897dfc6bc281b419f2e0d492/greenlet-3.4.0-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:98eedd1803353daf1cd9ef23eef23eda5a4d22f99b1f998d273a8b78b70dd47f", size = 628455, upload-time = "2026-04-08T16:40:40.698Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/54/78/0cbc693622cd54ebe25207efbb3a0eb07c2639cb8594f6e3aaaa0bb077a8/greenlet-3.4.0-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f82cb6cddc27dd81c96b1506f4aa7def15070c3b2a67d4e46fd19016aacce6cf", size = 617549, upload-time = "2026-04-08T15:56:34.893Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7f/46/cfaaa0ade435a60550fd83d07dfd5c41f873a01da17ede5c4cade0b9bab8/greenlet-3.4.0-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:b7857e2202aae67bc5725e0c1f6403c20a8ff46094ece015e7d474f5f7020b55", size = 426238, upload-time = "2026-04-08T16:43:06.865Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/c0/8966767de01343c1ff47e8b855dc78e7d1a8ed2b7b9c83576a57e289f81d/greenlet-3.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:227a46251ecba4ff46ae742bc5ce95c91d5aceb4b02f885487aff269c127a729", size = 1575310, upload-time = "2026-04-08T16:26:21.671Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/38/bcdc71ba05e9a5fda87f63ffc2abcd1f15693b659346df994a48c968003d/greenlet-3.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5b99e87be7eba788dd5b75ba1cde5639edffdec5f91fe0d734a249535ec3408c", size = 1640435, upload-time = "2026-04-08T15:57:32.572Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a1/c2/19b664b7173b9e4ef5f77e8cef9f14c20ec7fce7920dc1ccd7afd955d093/greenlet-3.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:849f8bc17acd6295fcb5de8e46d55cc0e52381c56eaf50a2afd258e97bc65940", size = 238760, upload-time = "2026-04-08T17:04:03.878Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9b/96/795619651d39c7fbd809a522f881aa6f0ead504cc8201c3a5b789dfaef99/greenlet-3.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:9390ad88b652b1903814eaabd629ca184db15e0eeb6fe8a390bbf8b9106ae15a", size = 235498, upload-time = "2026-04-08T17:05:00.584Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/78/02/bde66806e8f169cf90b14d02c500c44cdbe02c8e224c9c67bafd1b8cadd1/greenlet-3.4.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:10a07aca6babdd18c16a3f4f8880acfffc2b88dfe431ad6aa5f5740759d7d75e", size = 286291, upload-time = "2026-04-08T17:09:34.307Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/1f/39da1c336a87d47c58352fb8a78541ce63d63ae57c5b9dae1fe02801bbc2/greenlet-3.4.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:076e21040b3a917d3ce4ad68fb5c3c6b32f1405616c4a57aa83120979649bd3d", size = 656749, upload-time = "2026-04-08T16:24:41.721Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d3/6c/90ee29a4ee27af7aa2e2ec408799eeb69ee3fcc5abcecac6ddd07a5cd0f2/greenlet-3.4.0-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e82689eea4a237e530bb5cb41b180ef81fa2160e1f89422a67be7d90da67f615", size = 669084, upload-time = "2026-04-08T16:31:01.372Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/4a/74078d3936712cff6d3c91a930016f476ce4198d84e224fe6d81d3e02880/greenlet-3.4.0-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:06c2d3b89e0c62ba50bd7adf491b14f39da9e7e701647cb7b9ff4c99bee04b19", size = 673405, upload-time = "2026-04-08T16:40:42.527Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/07/49/d4cad6e5381a50947bb973d2f6cf6592621451b09368b8c20d9b8af49c5b/greenlet-3.4.0-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4df3b0b2289ec686d3c821a5fee44259c05cfe824dd5e6e12c8e5f5df23085cf", size = 665621, upload-time = "2026-04-08T15:56:35.995Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/79/3e/df8a83ab894751bc31e1106fdfaa80ca9753222f106b04de93faaa55feb7/greenlet-3.4.0-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:070b8bac2ff3b4d9e0ff36a0d19e42103331d9737e8504747cd1e659f76297bd", size = 471670, upload-time = "2026-04-08T16:43:08.512Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/31/d1edd54f424761b5d47718822f506b435b6aab2f3f93b465441143ea5119/greenlet-3.4.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8bff29d586ea415688f4cec96a591fcc3bf762d046a796cdadc1fdb6e7f2d5bf", size = 1622259, upload-time = "2026-04-08T16:26:23.201Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/c6/6d3f9cdcb21c4e12a79cb332579f1c6aa1af78eb68059c5a957c7812d95e/greenlet-3.4.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8a569c2fb840c53c13a2b8967c63621fafbd1a0e015b9c82f408c33d626a2fda", size = 1686916, upload-time = "2026-04-08T15:57:34.282Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/63/45/c1ca4a1ad975de4727e52d3ffe641ae23e1d7a8ffaa8ff7a0477e1827b92/greenlet-3.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:207ba5b97ea8b0b60eb43ffcacf26969dd83726095161d676aac03ff913ee50d", size = 239821, upload-time = "2026-04-08T17:03:48.423Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/71/c4/6f621023364d7e85a4769c014c8982f98053246d142420e0328980933ceb/greenlet-3.4.0-cp314-cp314-win_arm64.whl", hash = "sha256:f8296d4e2b92af34ebde81085a01690f26a51eb9ac09a0fcadb331eb36dbc802", size = 236932, upload-time = "2026-04-08T17:04:33.551Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d4/8f/18d72b629783f5e8d045a76f5325c1e938e659a9e4da79c7dcd10169a48d/greenlet-3.4.0-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:d70012e51df2dbbccfaf63a40aaf9b40c8bed37c3e3a38751c926301ce538ece", size = 294681, upload-time = "2026-04-08T15:52:35.778Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/ad/5fa86ec46769c4153820d58a04062285b3b9e10ba3d461ee257b68dcbf53/greenlet-3.4.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a58bec0751f43068cd40cff31bb3ca02ad6000b3a51ca81367af4eb5abc480c8", size = 658899, upload-time = "2026-04-08T16:24:43.32Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/43/f0/4e8174ca0e87ae748c409f055a1ba161038c43cc0a5a6f1433a26ac2e5bf/greenlet-3.4.0-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:05fa0803561028f4b2e3b490ee41216a842eaee11aed004cc343a996d9523aa2", size = 665284, upload-time = "2026-04-08T16:31:02.833Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ef/92/466b0d9afd44b8af623139a3599d651c7564fa4152f25f117e1ee5949ffb/greenlet-3.4.0-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c4cd56a9eb7a6444edbc19062f7b6fbc8f287c663b946e3171d899693b1c19fa", size = 665872, upload-time = "2026-04-08T16:40:43.912Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/19/da/991cf7cd33662e2df92a1274b7eb4d61769294d38a1bba8a45f31364845e/greenlet-3.4.0-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e60d38719cb80b3ab5e85f9f1aed4960acfde09868af6762ccb27b260d68f4ed", size = 661861, upload-time = "2026-04-08T15:56:37.269Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/14/3395a7ef3e260de0325152ddfe19dffb3e49fe10873b94654352b53ad48e/greenlet-3.4.0-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:1f85f204c4d54134ae850d401fa435c89cd667d5ce9dc567571776b45941af72", size = 489237, upload-time = "2026-04-08T16:43:09.993Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/36/c5/6c2c708e14db3d9caea4b459d8464f58c32047451142fe2cfd90e7458f41/greenlet-3.4.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7f50c804733b43eded05ae694691c9aa68bca7d0a867d67d4a3f514742a2d53f", size = 1622182, upload-time = "2026-04-08T16:26:24.777Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/4c/50c5fed19378e11a29fabab1f6be39ea95358f4a0a07e115a51ca93385d8/greenlet-3.4.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2d4f0635dc4aa638cda4b2f5a07ae9a2cff9280327b581a3fcb6f317b4fbc38a", size = 1685050, upload-time = "2026-04-08T15:57:36.453Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/db/72/85ae954d734703ab48e622c59d4ce35d77ce840c265814af9c078cacc7aa/greenlet-3.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:1a4a48f24681300c640f143ba7c404270e1ebbbcf34331d7104a4ff40f8ea705", size = 245554, upload-time = "2026-04-08T17:03:50.044Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/0f/a91f143f356523ff682309732b175765a9bc2836fd7c081c2c67fedc1ad4/greenlet-3.5.0-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:8f1cc966c126639cd152fdaa52624d2655f492faa79e013fea161de3e6dda082", size = 284726, upload-time = "2026-04-27T12:20:51.402Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/95/82/800646c7ffc5dbabd75ddd2f6b519bb898c0c9c969e5d0473bfe5d20bcce/greenlet-3.5.0-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:362624e6a8e5bca3b8233e45eef33903a100e9539a2b995c364d595dbc4018b3", size = 604264, upload-time = "2026-04-27T12:52:39.494Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ca/ac/354867c0bba812fc33b15bc55aedafedd0aee3c7dd91dfca22444157dc0c/greenlet-3.5.0-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5ecd83806b0f4c2f53b1018e0005cd82269ea01d42befc0368730028d850ed1c", size = 616099, upload-time = "2026-04-27T12:59:39.623Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/ab/192090c4a5b30df148c22bf4b8895457d739a7c7c5a7b9c41e5dd7f537f2/greenlet-3.5.0-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fa94cb2288681e3a11645958f1871d48ee9211bd2f66628fdace505927d6e564", size = 623976, upload-time = "2026-04-27T13:02:37.363Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/b0/815bece7399e01cadb69014219eebd0042339875c59a59b0820a46ece356/greenlet-3.5.0-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0ff251e9a0279522e62f6176412869395a64ddf2b5c5f782ff609a8216a4e662", size = 615198, upload-time = "2026-04-27T12:25:25.928Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/24/11/05eb2b9b188c6df7d68a89c99134d644a7af616a40b9808e8e6ced315d5d/greenlet-3.5.0-cp311-cp311-manylinux_2_39_riscv64.whl", hash = "sha256:64d6ac45f7271f48e45f67c95b54ef73534c52ec041fcda8edf520c6d811f4bc", size = 418379, upload-time = "2026-04-27T13:05:12.755Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/80/3b2c0a895d6698f6ddb31b07942ebfa982f3e30888bc5546a5b5990de8b2/greenlet-3.5.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6d874e79afd41a96e11ff4c5d0bc90a80973e476fda1c2c64985667397df432b", size = 1574927, upload-time = "2026-04-27T12:53:25.81Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/44/0e/f354af514a4c61454dbc68e44d47544a5a4d6317e30b77ddfa3a09f4c5f3/greenlet-3.5.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0ed006e4b86c59de7467eb2601cd1b77b5a7d657d1ee55e30fe30d76451edba4", size = 1642683, upload-time = "2026-04-27T12:25:23.9Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/6a/87f38255201e993a1915265ebb80cd7c2c78b04a45744995abbf6b259fd8/greenlet-3.5.0-cp311-cp311-win_amd64.whl", hash = "sha256:703cb211b820dbffbbc55a16bfc6e4583a6e6e990f33a119d2cc8b83211119c8", size = 238115, upload-time = "2026-04-27T12:21:48.845Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e3/f8/450fe3c5938fa737ea4d22699772e6e34e8e24431a47bf4e8a1ceed4a98e/greenlet-3.5.0-cp311-cp311-win_arm64.whl", hash = "sha256:6c18dfb59c70f5a94acd271c72e90128c3c776e41e5f07767908c8c1b74ad339", size = 235017, upload-time = "2026-04-27T12:22:26.768Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ef/32/f2ce6d4cac3e55bc6173f92dbe627e782e1850f89d986c3606feb63aafa7/greenlet-3.5.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:db2910d3c809444e0a20147361f343fe2798e106af8d9d8506f5305302655a9f", size = 286228, upload-time = "2026-04-27T12:20:34.421Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/aa/caed9e5adf742315fc7be2a84196373aab4816e540e38ba0d76cb7584d68/greenlet-3.5.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3ec9ea74e7268ace7f9aab1b1a4e730193fc661b39a993cd91c606c32d4a3628", size = 601775, upload-time = "2026-04-27T12:52:41.045Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/af/90ae08497400a941595d12774447f752d3dfe0fbb012e35b76bc5c0ff37e/greenlet-3.5.0-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54d243512da35485fc7a6bf3c178fdda6327a9d6506fcdd62b1abd1e41b2927b", size = 614436, upload-time = "2026-04-27T12:59:41.595Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3f/e9/4eeadf8cb3403ac274245ba75f07844abc7fa5f6787583fc9156ba741e0f/greenlet-3.5.0-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:41353ec2ecedf7aa8f682753a41919f8718031a6edac46b8d3dc7ed9e1ceb136", size = 620610, upload-time = "2026-04-27T13:02:39.194Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/e0/2e13df68f367e2f9960616927d60857dd7e56aaadd59a47c644216b2f920/greenlet-3.5.0-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d280a7f5c331622c69f97eb167f33577ff2d1df282c41cd15907fc0a3ca198c", size = 611388, upload-time = "2026-04-27T12:25:28.008Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ee/ef/f913b3c0eb7d26d86a2401c5e1546c9d46b657efee724b06f6f4ac5d8824/greenlet-3.5.0-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:58c1c374fe2b3d852f9b6b11a7dff4c85404e51b9a596fd9e89cf904eb09866d", size = 422775, upload-time = "2026-04-27T13:05:14.261Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/82/f7/393c64055132ac0d488ef6be549253b7e6274194863967ddc0bc8f5b87b8/greenlet-3.5.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1eb67d5adefb5bd2e182d42678a328979a209e4e82eb93575708185d31d1f588", size = 1570768, upload-time = "2026-04-27T12:53:28.099Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/4b/eaf7735253522cf56d1b74d672a58f54fc114702ceaf05def59aae72f6e1/greenlet-3.5.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2628d6c86f6cb0cb45e0c3c54058bbec559f57eaae699447748cb3928150577e", size = 1635983, upload-time = "2026-04-27T12:25:26.903Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4c/fe/4fb3a0805bd5165da5ebf858da7cc01cce8061674106d2cf5bdab32cbfde/greenlet-3.5.0-cp312-cp312-win_amd64.whl", hash = "sha256:d4d9f0624c775f2dfc56ba54d515a8c771044346852a918b405914f6b19d7fd8", size = 238840, upload-time = "2026-04-27T12:23:54.806Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/cb/baa584cb00532126ffe12d9787db0a60c5a4f55c27bfe2666df5d4c30a32/greenlet-3.5.0-cp312-cp312-win_arm64.whl", hash = "sha256:83ed9f27f1680b50e89f40f6df348a290ea234b249a4003d366663a12eab94f2", size = 235615, upload-time = "2026-04-27T12:21:38.57Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/58/fc576f99037ce19c5aa16628e4c3226b6d1419f72a62c79f5f40576e6eb3/greenlet-3.5.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:5a5ed18de6a0f6cc7087f1563f6bd93fc7df1c19165ca01e9bde5a5dc281d106", size = 285066, upload-time = "2026-04-27T12:23:05.033Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4a/ba/b28ddbe6bfad6a8ac196ef0e8cff37bc65b79735995b9e410923fffeeb70/greenlet-3.5.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a717fbc46d8a354fa675f7c1e813485b6ba3885f9bef0cd56e5ba27d758ff5b", size = 604414, upload-time = "2026-04-27T12:52:42.358Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/09/06/4b69f8f0b67603a8be2790e55107a190b376f2627fe0eaf5695d85ffb3cd/greenlet-3.5.0-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ddc090c5c1792b10246a78e8c2163ebbe04cf877f9d785c230a7b27b39ad038e", size = 617349, upload-time = "2026-04-27T12:59:43.32Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/15/a643b4ecd09969e30b8a150d5919960caae0abe4f5af75ab040b1ab85e78/greenlet-3.5.0-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4964101b8585c144cbda5532b1aa644255126c08a265dae90c16e7a0e63aaa9d", size = 623234, upload-time = "2026-04-27T13:02:40.611Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/17/a3918541fd0ddefe024a69de6d16aa7b46d36ac19562adaa63c7fa180eff/greenlet-3.5.0-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2094acd54b272cb6eae8c03dd87b3fa1820a4cef18d6889c378d503500a1dc13", size = 613927, upload-time = "2026-04-27T12:25:30.28Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/77/18/3b13d5ef1275b0ffaf933b05efa21408ac4ca95823c7411d79682e4fdcff/greenlet-3.5.0-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:7022615368890680e67b9965d33f5773aade330d5343bbe25560135aaa849eae", size = 425243, upload-time = "2026-04-27T13:05:15.689Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ee/e1/bd0af6213c7dd33175d8a462d4c1fe1175124ebed4855bc1475a5b5242c2/greenlet-3.5.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5e05ba267789ea87b5a155cf0e810b1ab88bf18e9e8740813945ceb8ee4350ba", size = 1570893, upload-time = "2026-04-27T12:53:29.483Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9b/2a/0789702f864f5382cb476b93d7a9c823c10472658102ccd65f415747d2e2/greenlet-3.5.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0ecec963079cd58cbd14723582384f11f166fd58883c15dcbfb342e0bc9b5846", size = 1636060, upload-time = "2026-04-27T12:25:28.845Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/8f/22bf9df92bbff0eb07842b60f7e63bf7675a9742df628437a9f02d09137f/greenlet-3.5.0-cp313-cp313-win_amd64.whl", hash = "sha256:728d9667d8f2f586644b748dbd9bb67e50d6a9381767d1357714ea6825bb3bf5", size = 238740, upload-time = "2026-04-27T12:24:01.341Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b6/b7/9c5c3d653bd4ff614277c049ac676422e2c557db47b4fe43e6313fc005dc/greenlet-3.5.0-cp313-cp313-win_arm64.whl", hash = "sha256:47422135b1d308c14b2c6e758beedb1acd33bb91679f5670edf77bf46244722b", size = 235525, upload-time = "2026-04-27T12:23:12.308Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/94/5e/a70f31e3e8d961c4ce589c15b28e4225d63704e431a23932a3808cbcc867/greenlet-3.5.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:f35807464c4c58c55f0d31dfa83c541a5615d825c2fe3d2b95360cf7c4e3c0a8", size = 285564, upload-time = "2026-04-27T12:23:08.555Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/af/a6/046c0a28e21833e4086918218cfb3d8bed51c075a1b700f20b9d7861c0f4/greenlet-3.5.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55fa7ea52771be44af0de27d8b80c02cd18c2c3cddde6c847ecebdf72418b6a1", size = 651166, upload-time = "2026-04-27T12:52:43.644Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/f8/4af27f71c5ff32a7fbc516adb46370d9c4ae2bc7bd3dc7d066ac542b4b15/greenlet-3.5.0-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a97e4821aa710603f94de0da25f25096454d78ffdace5dc77f3a006bc01abba3", size = 663792, upload-time = "2026-04-27T12:59:44.93Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fb/89/2dadb89793c37ee8b4c237857188293e9060dc085f19845c292e00f8e091/greenlet-3.5.0-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bf2d8a80bec89ab46221ae45c5373d5ba0bd36c19aa8508e85c6cd7e5106cd37", size = 668086, upload-time = "2026-04-27T13:02:42.314Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a3/59/1bd6d7428d6ed9106efbb8c52310c60fd04f6672490f452aeaa3829aa436/greenlet-3.5.0-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8f52a464e4ed91780bdfbbdd2b97197f3accaa629b98c200f4dffada759f3ae7", size = 660933, upload-time = "2026-04-27T12:25:33.276Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/82/35/75722be7e26a2af4cbd2dc35b0ed382dacf9394b7e75551f76ed1abe87f2/greenlet-3.5.0-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:1bae92a1dd94c5f9d9493c3a212dd874c202442047cf96446412c862feca83a2", size = 470799, upload-time = "2026-04-27T13:05:17.094Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/83/e4/b903e5a5fae1e8a28cdd32a0cfbfd560b668c25b692f67768822ddc5f40f/greenlet-3.5.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:762612baf1161ccb8437c0161c668a688223cba28e1bf038f4eb47b13e39ccdf", size = 1618401, upload-time = "2026-04-27T12:53:31.062Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0e/e3/5ec408a329acb854fb607a122e1ee5fb3ff649f9a97952948a90803c0d8e/greenlet-3.5.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:57a43c6079a89713522bc4bcb9f75070ecf5d3dbad7792bfe42239362cbf2a16", size = 1682038, upload-time = "2026-04-27T12:25:31.838Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/91/20/6b165108058767ee643c55c5c4904d591a830ee2b3c7dbd359828fbc829f/greenlet-3.5.0-cp314-cp314-win_amd64.whl", hash = "sha256:3bc59be3945ae9750b9e7d45067d01ae3fe90ea5f9ade99239dabdd6e28a5033", size = 239835, upload-time = "2026-04-27T12:24:54.136Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4e/62/1c498375cee177b55d980c1db319f26470e5309e54698c8f8fc06c0fd539/greenlet-3.5.0-cp314-cp314-win_arm64.whl", hash = "sha256:a96fcee45e03fe30a62669fd16ab5c9d3c172660d3085605cb1e2d1280d3c988", size = 236862, upload-time = "2026-04-27T12:23:24.957Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/78/a8/4522939255bb5409af4e87132f915446bf3622c2c292d14d3c38d128ae82/greenlet-3.5.0-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:a10a732421ab4fec934783ce3e54763470d0181db6e3468f9103a275c3ed1853", size = 293614, upload-time = "2026-04-27T12:24:12.874Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/15/5e/8744c52e2c027b5a8772a01561934c8835f869733e101f62075c60430340/greenlet-3.5.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7fc391b1566f2907d17aaebe78f8855dc45675159a775fcf9e61f8ee0078e87f", size = 650723, upload-time = "2026-04-27T12:52:45.412Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/00/ef/7b4c39c03cf46ceca512c5d3f914afd85aa30b2cc9a93015b0dd73e4be6c/greenlet-3.5.0-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:680bd0e7ad5e8daa8a4aa89f68fd6adc834b8a8036dc256533f7e08f4a4b01f7", size = 656529, upload-time = "2026-04-27T12:59:46.295Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/5c/0602239503b124b70e39355cbdb39361ecfe65b87a5f2f63752c32f5286f/greenlet-3.5.0-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1aa4ce8debcd4ea7fb2e150f3036588c41493d1d52c43538924ae1819003f4ce", size = 657015, upload-time = "2026-04-27T13:02:43.973Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/b5/c7768f352f5c010f92064d0063f987e7dc0cd290a6d92a34109015ce4aa1/greenlet-3.5.0-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ddb36c7d6c9c0a65f18c7258634e0c416c6ab59caac8c987b96f80c2ebda0112", size = 654364, upload-time = "2026-04-27T12:25:35.64Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/38/51/8699f865f125dc952384cb432b0f7138aa4d8f2969a7d12d0df5b94d054d/greenlet-3.5.0-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:728a73687e39ae9ca34e4694cbf2f049d3fbc7174639468d0f67200a97d8f9e2", size = 488275, upload-time = "2026-04-27T13:05:18.28Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ef/d0/079ebe12e4b1fc758857ce5be1a5e73f06870f2101e52611d1e71925ce54/greenlet-3.5.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e5ddf316ced87539144621453c3aef229575825fe60c604e62bedc4003f372b2", size = 1614204, upload-time = "2026-04-27T12:53:32.618Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6d/89/6c2fb63df3596552d20e58fb4d96669243388cf680cff222758812c7bfaa/greenlet-3.5.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:4a448128607be0de65342dc9b31be7f948ef4cc0bc8832069350abefd310a8f2", size = 1675480, upload-time = "2026-04-27T12:25:34.168Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/15/32/77ee8a6c1564fc345a491a4e85b3bf360e4cf26eac98c4532d2fdb96e01f/greenlet-3.5.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d60097128cb0a1cab9ea541186ea13cd7b847b8449a7787c2e2350da0cb82d86", size = 245324, upload-time = "2026-04-27T12:24:40.295Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1174,14 +1174,14 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "mako"
|
||||
version = "1.3.11"
|
||||
version = "1.3.12"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "markupsafe" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/59/8a/805404d0c0b9f3d7a326475ca008db57aea9c5c9f2e1e39ed0faa335571c/mako-1.3.11.tar.gz", hash = "sha256:071eb4ab4c5010443152255d77db7faa6ce5916f35226eb02dc34479b6858069", size = 399811, upload-time = "2026-04-14T20:19:51.493Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/00/62/791b31e69ae182791ec67f04850f2f062716bbd205483d63a215f3e062d3/mako-1.3.12.tar.gz", hash = "sha256:9f778e93289bd410bb35daadeb4fc66d95a746f0b75777b942088b7fd7af550a", size = 400219, upload-time = "2026-04-28T19:01:08.512Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/68/a5/19d7aaa7e433713ffe881df33705925a196afb9532efc8475d26593921a6/mako-1.3.11-py3-none-any.whl", hash = "sha256:e372c6e333cf004aa736a15f425087ec977e1fcbd2966aae7f17c8dc1da27a77", size = 78503, upload-time = "2026-04-14T20:19:53.233Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/b1/a0ec7a5a9db730a08daef1fdfb8090435b82465abbf758a596f0ea88727e/mako-1.3.12-py3-none-any.whl", hash = "sha256:8f61569480282dbf557145ce441e4ba888be453c30989f879f0d652e39f53ea9", size = 78521, upload-time = "2026-04-28T19:01:10.393Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1549,7 +1549,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "openai"
|
||||
version = "2.32.0"
|
||||
version = "2.33.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "anyio" },
|
||||
@@ -1561,9 +1561,9 @@ dependencies = [
|
||||
{ name = "tqdm" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ed/59/bdcc6b759b8c42dd73afaf5bf8f902c04b37987a5514dbc1c64dba390fef/openai-2.32.0.tar.gz", hash = "sha256:c54b27a9e4cb8d51f0dd94972ffd1a04437efeb259a9e60d8922b8bd26fe55e0", size = 693286, upload-time = "2026-04-15T22:28:19.434Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f0/ee/d056c82f63c05f06baac0cffb4a90952d8274f90c49dfe244f20497b9bbd/openai-2.33.0.tar.gz", hash = "sha256:f850c435e2a4685bba3295bd54912dd26315d9c1b7733068186134d6e0599f9a", size = 693254, upload-time = "2026-04-28T14:04:42.428Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/c1/d6e64ccd0536bf616556f0cad2b6d94a8125f508d25cfd814b1d2db4e2f1/openai-2.32.0-py3-none-any.whl", hash = "sha256:4dcc9badeb4bf54ad0d187453742f290226d30150890b7890711bda4f32f192f", size = 1162570, upload-time = "2026-04-15T22:28:17.714Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7d/32/37734d769bc8b42e4938785313cc05aade6cb0fa72479d3220a0d61a4e78/openai-2.33.0-py3-none-any.whl", hash = "sha256:03ac37d70e8c9e3a8124214e3afa785e2cbc12e627fbd98177a086ef2fd87ad5", size = 1162695, upload-time = "2026-04-28T14:04:40.482Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1732,15 +1732,15 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "psycopg"
|
||||
version = "3.3.3"
|
||||
version = "3.3.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
||||
{ name = "tzdata", marker = "sys_platform == 'win32'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d3/b6/379d0a960f8f435ec78720462fd94c4863e7a31237cf81bf76d0af5883bf/psycopg-3.3.3.tar.gz", hash = "sha256:5e9a47458b3c1583326513b2556a2a9473a1001a56c9efe9e587245b43148dd9", size = 165624, upload-time = "2026-02-18T16:52:16.546Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/db/2f/cb91e5502ec9de1de6f1b76cfbf69531932725361168bb06963620c77e2e/psycopg-3.3.4.tar.gz", hash = "sha256:e21207764952cff81b6b8bdacad9a3939f2793367fdac2987b3aac36a651b5bc", size = 165799, upload-time = "2026-05-01T23:31:55.179Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c8/5b/181e2e3becb7672b502f0ed7f16ed7352aca7c109cfb94cf3878a9186db9/psycopg-3.3.3-py3-none-any.whl", hash = "sha256:f96525a72bcfade6584ab17e89de415ff360748c766f0106959144dcbb38c698", size = 212768, upload-time = "2026-02-18T16:46:27.365Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/e0/7b3dee031daae7743609ce3c746565d4a3ed7c2c186479eb48e34e838c64/psycopg-3.3.4-py3-none-any.whl", hash = "sha256:b6bbc25ccf05c8fad3b061d9db2ef0909a555171b84b07f29458a447253d679a", size = 213001, upload-time = "2026-05-01T23:20:50.816Z" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
@@ -1750,53 +1750,53 @@ binary = [
|
||||
|
||||
[[package]]
|
||||
name = "psycopg-binary"
|
||||
version = "3.3.3"
|
||||
version = "3.3.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/be/c0/b389119dd754483d316805260f3e73cdcad97925839107cc7a296f6132b1/psycopg_binary-3.3.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a89bb9ee11177b2995d87186b1d9fa892d8ea725e85eab28c6525e4cc14ee048", size = 4609740, upload-time = "2026-02-18T16:47:51.093Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cf/e3/9976eef20f61840285174d360da4c820a311ab39d6b82fa09fbb545be825/psycopg_binary-3.3.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:9f7d0cf072c6fbac3795b08c98ef9ea013f11db609659dcfc6b1f6cc31f9e181", size = 4676837, upload-time = "2026-02-18T16:47:55.523Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/f2/d28ba2f7404fd7f68d41e8a11df86313bd646258244cb12a8dd83b868a97/psycopg_binary-3.3.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:90eecd93073922f085967f3ed3a98ba8c325cbbc8c1a204e300282abd2369e13", size = 5497070, upload-time = "2026-02-18T16:47:59.929Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/de/2f/6c5c54b815edeb30a281cfcea96dc93b3bb6be939aea022f00cab7aa1420/psycopg_binary-3.3.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:dac7ee2f88b4d7bb12837989ca354c38d400eeb21bce3b73dac02622f0a3c8d6", size = 5172410, upload-time = "2026-02-18T16:48:05.665Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/51/75/8206c7008b57de03c1ada46bd3110cc3743f3fd9ed52031c4601401d766d/psycopg_binary-3.3.3-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b62cf8784eb6d35beaee1056d54caf94ec6ecf2b7552395e305518ab61eb8fd2", size = 6763408, upload-time = "2026-02-18T16:48:13.541Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d4/5a/ea1641a1e6c8c8b3454b0fcb43c3045133a8b703e6e824fae134088e63bd/psycopg_binary-3.3.3-cp311-cp311-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a39f34c9b18e8f6794cca17bfbcd64572ca2482318db644268049f8c738f35a6", size = 5006255, upload-time = "2026-02-18T16:48:22.176Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/fb/538df099bf55ae1637d52d7ccb6b9620b535a40f4c733897ac2b7bb9e14c/psycopg_binary-3.3.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:883d68d48ca9ff3cb3d10c5fdebea02c79b48eecacdddbf7cce6e7cdbdc216b8", size = 4532694, upload-time = "2026-02-18T16:48:27.338Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a1/d1/00780c0e187ea3c13dfc53bd7060654b2232cd30df562aac91a5f1c545ac/psycopg_binary-3.3.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:cab7bc3d288d37a80aa8c0820033250c95e40b1c2b5c57cf59827b19c2a8b69d", size = 4222833, upload-time = "2026-02-18T16:48:31.221Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/34/a07f1ff713c51d64dc9f19f2c32be80299a2055d5d109d5853662b922cb4/psycopg_binary-3.3.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:56c767007ca959ca32f796b42379fc7e1ae2ed085d29f20b05b3fc394f3715cc", size = 3952818, upload-time = "2026-02-18T16:48:35.869Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d3/67/d33f268a7759b4445f3c9b5a181039b01af8c8263c865c1be7a6444d4749/psycopg_binary-3.3.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:da2f331a01af232259a21573a01338530c6016dcfad74626c01330535bcd8628", size = 4258061, upload-time = "2026-02-18T16:48:41.365Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b4/3b/0d8d2c5e8e29ccc07d28c8af38445d9d9abcd238d590186cac82ee71fc84/psycopg_binary-3.3.3-cp311-cp311-win_amd64.whl", hash = "sha256:19f93235ece6dbfc4036b5e4f6d8b13f0b8f2b3eeb8b0bd2936d406991bcdd40", size = 3558915, upload-time = "2026-02-18T16:48:46.679Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/90/15/021be5c0cbc5b7c1ab46e91cc3434eb42569f79a0592e67b8d25e66d844d/psycopg_binary-3.3.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6698dbab5bcef8fdb570fc9d35fd9ac52041771bfcfe6fd0fc5f5c4e36f1e99d", size = 4591170, upload-time = "2026-02-18T16:48:55.594Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/54/a60211c346c9a2f8c6b272b5f2bbe21f6e11800ce7f61e99ba75cf8b63e1/psycopg_binary-3.3.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:329ff393441e75f10b673ae99ab45276887993d49e65f141da20d915c05aafd8", size = 4670009, upload-time = "2026-02-18T16:49:03.608Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/53/ac7c18671347c553362aadbf65f92786eef9540676ca24114cc02f5be405/psycopg_binary-3.3.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:eb072949b8ebf4082ae24289a2b0fd724da9adc8f22743409d6fd718ddb379df", size = 5469735, upload-time = "2026-02-18T16:49:10.128Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7f/c3/4f4e040902b82a344eff1c736cde2f2720f127fe939c7e7565706f96dd44/psycopg_binary-3.3.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:263a24f39f26e19ed7fc982d7859a36f17841b05bebad3eb47bb9cd2dd785351", size = 5152919, upload-time = "2026-02-18T16:49:16.335Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/e7/d929679c6a5c212bcf738806c7c89f5b3d0919f2e1685a0e08d6ff877945/psycopg_binary-3.3.3-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5152d50798c2fa5bd9b68ec68eb68a1b71b95126c1d70adaa1a08cd5eefdc23d", size = 6738785, upload-time = "2026-02-18T16:49:22.687Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/69/b0/09703aeb69a9443d232d7b5318d58742e8ca51ff79f90ffe6b88f1db45e7/psycopg_binary-3.3.3-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9d6a1e56dd267848edb824dbeb08cf5bac649e02ee0b03ba883ba3f4f0bd54f2", size = 4979008, upload-time = "2026-02-18T16:49:27.313Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cc/a6/e662558b793c6e13a7473b970fee327d635270e41eded3090ef14045a6a5/psycopg_binary-3.3.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:73eaaf4bb04709f545606c1db2f65f4000e8a04cdbf3e00d165a23004692093e", size = 4508255, upload-time = "2026-02-18T16:49:31.575Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/7f/0f8b2e1d5e0093921b6f324a948a5c740c1447fbb45e97acaf50241d0f39/psycopg_binary-3.3.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:162e5675efb4704192411eaf8e00d07f7960b679cd3306e7efb120bb8d9456cc", size = 4189166, upload-time = "2026-02-18T16:49:35.801Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/ec/ce2e91c33bc8d10b00c87e2f6b0fb570641a6a60042d6a9ae35658a3a797/psycopg_binary-3.3.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:fab6b5e37715885c69f5d091f6ff229be71e235f272ebaa35158d5a46fd548a0", size = 3924544, upload-time = "2026-02-18T16:49:41.129Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c5/2f/7718141485f73a924205af60041c392938852aa447a94c8cbd222ff389a1/psycopg_binary-3.3.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a4aab31bd6d1057f287c96c0effca3a25584eb9cc702f282ecb96ded7814e830", size = 4235297, upload-time = "2026-02-18T16:49:46.726Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/57/f9/1add717e2643a003bbde31b1b220172e64fbc0cb09f06429820c9173f7fc/psycopg_binary-3.3.3-cp312-cp312-win_amd64.whl", hash = "sha256:59aa31fe11a0e1d1bcc2ce37ed35fe2ac84cd65bb9036d049b1a1c39064d0f14", size = 3547659, upload-time = "2026-02-18T16:49:52.999Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/03/0a/cac9fdf1df16a269ba0e5f0f06cac61f826c94cadb39df028cdfe19d3a33/psycopg_binary-3.3.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:05f32239aec25c5fb15f7948cffdc2dc0dac098e48b80a140e4ba32b572a2e7d", size = 4590414, upload-time = "2026-02-18T16:50:01.441Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9c/c0/d8f8508fbf440edbc0099b1abff33003cd80c9e66eb3a1e78834e3fb4fb9/psycopg_binary-3.3.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7c84f9d214f2d1de2fafebc17fa68ac3f6561a59e291553dfc45ad299f4898c1", size = 4669021, upload-time = "2026-02-18T16:50:08.803Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/04/05/097016b77e343b4568feddf12c72171fc513acef9a4214d21b9478569068/psycopg_binary-3.3.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e77957d2ba17cada11be09a5066d93026cdb61ada7c8893101d7fe1c6e1f3925", size = 5467453, upload-time = "2026-02-18T16:50:14.985Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/91/23/73244e5feb55b5ca109cede6e97f32ef45189f0fdac4c80d75c99862729d/psycopg_binary-3.3.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:42961609ac07c232a427da7c87a468d3c82fee6762c220f38e37cfdacb2b178d", size = 5151135, upload-time = "2026-02-18T16:50:24.82Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/11/49/5309473b9803b207682095201d8708bbc7842ddf3f192488a69204e36455/psycopg_binary-3.3.3-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ae07a3114313dd91fce686cab2f4c44af094398519af0e0f854bc707e1aeedf1", size = 6737315, upload-time = "2026-02-18T16:50:35.106Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d4/5d/03abe74ef34d460b33c4d9662bf6ec1dd38888324323c1a1752133c10377/psycopg_binary-3.3.3-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d257c58d7b36a621dcce1d01476ad8b60f12d80eb1406aee4cf796f88b2ae482", size = 4979783, upload-time = "2026-02-18T16:50:42.067Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/6c/3fbf8e604e15f2f3752900434046c00c90bb8764305a1b81112bff30ba24/psycopg_binary-3.3.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:07c7211f9327d522c9c47560cae00a4ecf6687f4e02d779d035dd3177b41cb12", size = 4509023, upload-time = "2026-02-18T16:50:50.116Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9c/6b/1a06b43b7c7af756c80b67eac8bfaa51d77e68635a8a8d246e4f0bb7604a/psycopg_binary-3.3.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:8e7e9eca9b363dbedeceeadd8be97149d2499081f3c52d141d7cd1f395a91f83", size = 4185874, upload-time = "2026-02-18T16:50:55.97Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/d3/bf49e3dcaadba510170c8d111e5e69e5ae3f981c1554c5bb71c75ce354bb/psycopg_binary-3.3.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:cb85b1d5702877c16f28d7b92ba030c1f49ebcc9b87d03d8c10bf45a2f1c7508", size = 3925668, upload-time = "2026-02-18T16:51:03.299Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/92/0aac830ed6a944fe334404e1687a074e4215630725753f0e3e9a9a595b62/psycopg_binary-3.3.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4d4606c84d04b80f9138d72f1e28c6c02dc5ae0c7b8f3f8aaf89c681ce1cd1b1", size = 4234973, upload-time = "2026-02-18T16:51:09.097Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2e/96/102244653ee5a143ece5afe33f00f52fe64e389dfce8dbc87580c6d70d3d/psycopg_binary-3.3.3-cp313-cp313-win_amd64.whl", hash = "sha256:74eae563166ebf74e8d950ff359be037b85723d99ca83f57d9b244a871d6c13b", size = 3551342, upload-time = "2026-02-18T16:51:13.892Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a2/71/7a57e5b12275fe7e7d84d54113f0226080423a869118419c9106c083a21c/psycopg_binary-3.3.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:497852c5eaf1f0c2d88ab74a64a8097c099deac0c71de1cbcf18659a8a04a4b2", size = 4607368, upload-time = "2026-02-18T16:51:19.295Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/04/cb834f120f2b2c10d4003515ef9ca9d688115b9431735e3936ae48549af8/psycopg_binary-3.3.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:258d1ea53464d29768bf25930f43291949f4c7becc706f6e220c515a63a24edd", size = 4687047, upload-time = "2026-02-18T16:51:23.84Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/40/e9/47a69692d3da9704468041aa5ed3ad6fc7f6bb1a5ae788d261a26bbca6c7/psycopg_binary-3.3.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:111c59897a452196116db12e7f608da472fbff000693a21040e35fc978b23430", size = 5487096, upload-time = "2026-02-18T16:51:29.645Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/b6/0e0dd6a2f802864a4ae3dbadf4ec620f05e3904c7842b326aafc43e5f464/psycopg_binary-3.3.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:17bb6600e2455993946385249a3c3d0af52cd70c1c1cdbf712e9d696d0b0bf1b", size = 5168720, upload-time = "2026-02-18T16:51:36.499Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/0d/977af38ac19a6b55d22dff508bd743fd7c1901e1b73657e7937c7cccb0a3/psycopg_binary-3.3.3-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:642050398583d61c9856210568eb09a8e4f2fe8224bf3be21b67a370e677eead", size = 6762076, upload-time = "2026-02-18T16:51:43.167Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/34/40/912a39d48322cf86895c0eaf2d5b95cb899402443faefd4b09abbba6b6e1/psycopg_binary-3.3.3-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:533efe6dc3a7cba5e2a84e38970786bb966306863e45f3db152007e9f48638a6", size = 4997623, upload-time = "2026-02-18T16:51:47.707Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/0c/c14d0e259c65dc7be854d926993f151077887391d5a081118907a9d89603/psycopg_binary-3.3.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:5958dbf28b77ce2033482f6cb9ef04d43f5d8f4b7636e6963d5626f000efb23e", size = 4532096, upload-time = "2026-02-18T16:51:51.421Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/39/21/8b7c50a194cfca6ea0fd4d1f276158307785775426e90700ab2eba5cd623/psycopg_binary-3.3.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:a6af77b6626ce92b5817bf294b4d45ec1a6161dba80fc2d82cdffdd6814fd023", size = 4208884, upload-time = "2026-02-18T16:51:57.336Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/2c/a4981bf42cf30ebba0424971d7ce70a222ae9b82594c42fc3f2105d7b525/psycopg_binary-3.3.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:47f06fcbe8542b4d96d7392c476a74ada521c5aebdb41c3c0155f6595fc14c8d", size = 3944542, upload-time = "2026-02-18T16:52:04.266Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/60/e9/b7c29b56aa0b85a4e0c4d89db691c1ceef08f46a356369144430c155a2f5/psycopg_binary-3.3.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e7800e6c6b5dc4b0ca7cc7370f770f53ac83886b76afda0848065a674231e856", size = 4254339, upload-time = "2026-02-18T16:52:10.444Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/5a/291d89f44d3820fffb7a04ebc8f3ef5dda4f542f44a5daea0c55a84abf45/psycopg_binary-3.3.3-cp314-cp314-win_amd64.whl", hash = "sha256:165f22ab5a9513a3d7425ffb7fcc7955ed8ccaeef6d37e369d6cc1dff1582383", size = 3652796, upload-time = "2026-02-18T16:52:14.02Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b6/82/df3312c0ca083d5b43b352f27d4dd8b1e614bd334473074715d9e0000da4/psycopg_binary-3.3.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:612a627d733f695b1de1f9b4bd511c15f999a5d8b915d444bbd7dd71cf3370da", size = 4609813, upload-time = "2026-05-01T23:26:30.612Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1f/b5/d74d542458d3e8ac0571d8a88f57ca369999b9a82f4fa528052d0d7d3e4c/psycopg_binary-3.3.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:13a7f380824c35896dcac7fe0f61440f7ca49d6dc73f3c13a9a4471e6a3b302e", size = 4676799, upload-time = "2026-05-01T23:26:38.475Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/09/67/06bab9c60671999f4c6ceff1b334f3ac1f9fc5789eb467c714623ea21de9/psycopg_binary-3.3.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:276904e3452d6a23d474ef9a21eee19f20eed3d53ddd2576af033827e0ba0992", size = 5497050, upload-time = "2026-05-01T23:26:47.061Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/72/9b/023433e2b20f970de1e22d29132a95281277646da0b2e2879dd4ee94b8c1/psycopg_binary-3.3.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ab8cca8ef8fb1ccf5b048ae5bd78ba55b9e4b5d472e3ce5ca39ff4d2a9c249e4", size = 5172428, upload-time = "2026-05-01T23:26:56.708Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/08/cd/ae16da8fde228a38b2fe9269bbc13cf89e0186173f2265600f02d6a71e64/psycopg_binary-3.3.4-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7465bfe6087d2d5b42d4c53b9b11ca9f218e477317a4a162a10e3c19e984ba8e", size = 6762746, upload-time = "2026-05-01T23:27:07.023Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4f/81/0ba09fa5f5f88779093a2541a8e02489825721f258ab88058b11d68b3eb5/psycopg_binary-3.3.4-cp311-cp311-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:22cdbf5f91ef7bb91fe0c5757e1962d3127a8010256eefd9c61fcaf441802097", size = 5006033, upload-time = "2026-05-01T23:27:12.221Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/73/6a/629136040cc3497adb442a305710b5913f2a754d4630fc3d3717c4c0df65/psycopg_binary-3.3.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e2631da29253a98bd496e6c4813b24e09a4fe3fb2a9e88513305d6f8747cce95", size = 4534175, upload-time = "2026-05-01T23:27:18.248Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7c/32/1027f843c6dc2d5d51960ee62cc0c2cf755a4c39455aff1371173edbef7d/psycopg_binary-3.3.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:7f7668f30b9dd5163197e5cbf4e0efd54e00f0a859cc566ce56cfc31f4054839", size = 4224203, upload-time = "2026-05-01T23:27:24.3Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/e1/380a724d9093c74adb14d4fce920ea8327838abb61f760b1448586b14a8e/psycopg_binary-3.3.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:cffc3408d77a27973f33e5d909b624cce683db5fc25964b02fe0aae7886c1007", size = 3954509, upload-time = "2026-05-01T23:27:30.815Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/db/cd/895893ae575a09c97ccfd5def070d88993d955ef34df45a881fd5ff506d6/psycopg_binary-3.3.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0579252a1202cd73e4da137a1426e2dae993ae44e757605344282af3a082848c", size = 4259551, upload-time = "2026-05-01T23:27:38.828Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dd/c6/2330a20794e37a3ec609ef2fd8522919ec7a4395a1abf979a8e2d1775cd5/psycopg_binary-3.3.4-cp311-cp311-win_amd64.whl", hash = "sha256:41f2ec0fea529832982bcb6c9415de3c86264ebe562b77a467c0fbcd7efbba8d", size = 3572054, upload-time = "2026-05-01T23:27:45.455Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/95/7d/03818e13ba7f36de93573c93ee3482006d3dfa8b0f8d28df511bad0a1a92/psycopg_binary-3.3.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5ab28a2a7649df3b72e6b674b4c190e448e8e77cf496a65bd846472048de2089", size = 4591122, upload-time = "2026-05-01T23:27:56.162Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/b9/11b341edf8d54e2694726b273fe9652b254d989f4f63e3ac6816ad6b55f4/psycopg_binary-3.3.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6402a9d8146cf4b3974ded3fd28a971e83dc6a0333eb7822524a3aa20b546578", size = 4669943, upload-time = "2026-05-01T23:28:04.522Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/18/4665bacd65e7865b4372fcd8abb8b9186ada4b0025f8c2ca691b364a556c/psycopg_binary-3.3.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:580ae30a5f95ccd90008ec697d3ed6a4a2047a516407ad904283fa42086936e9", size = 5469697, upload-time = "2026-05-01T23:28:11.337Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7c/b1/b83136c6e510593d9b0c759ba5384337bc4ad82d19fda675adc4b2703c84/psycopg_binary-3.3.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e7510c37550f91a187e3660a8cc50d4b760f8c3b8b2f89ebc5698cd2c7f2c85d", size = 5152995, upload-time = "2026-05-01T23:28:20.529Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/67/8d/a9821e2a648afe6091989929982a3b0f00b2631a859cb81379728f08fb75/psycopg_binary-3.3.4-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:77df19583501ea288eaf15ac0fe7ad01e6d8091a91d5c41df5c718f307d8e31b", size = 6738180, upload-time = "2026-05-01T23:28:30.654Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/58/2e349e8d23905dc2317b80ac65f48fb6f821a4777a4e994a60da91c4850f/psycopg_binary-3.3.4-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:018fbed325936da502feb546642c982dcc4b9ffdea32dfef78dbf3b7f7ad4070", size = 4978828, upload-time = "2026-05-01T23:28:37.277Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/45/48/57b00d03b4721878326122a1f1e6b0a90b85bcaec56b5b2f8ea6cfa45235/psycopg_binary-3.3.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:17a21953a9e5ff3a16dab692625a3676e2f101db5e40072f39dbee2250194d68", size = 4509757, upload-time = "2026-05-01T23:28:43.078Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/25/37/33b47d8c007df69aec500df5889767c4d313748e8e9e27a2fef8a6dabcee/psycopg_binary-3.3.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:eb05ee1c2b817d27c537333224c9e83c7afb86fe7296ba970990068baf819b16", size = 4190546, upload-time = "2026-05-01T23:28:50.016Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ca/c6/32b0835dbc2122617902b649d76a91c1e75406e76bf3d595b0c3bb5ffad6/psycopg_binary-3.3.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:773d573e11f437ce0bdb95b7c18dc58390494f96d43f8b45b9760436114f7652", size = 3926197, upload-time = "2026-05-01T23:28:55.55Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cd/68/d190ef0c0c5b16ded07831dabc8ddd412f4cdab07ec6e30ed38d9bda0e1f/psycopg_binary-3.3.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:71e55ccbdfae79a2ed9c6369c3008a3025817ff9d7e27b32a2d84e2a4267e66e", size = 4236627, upload-time = "2026-05-01T23:29:05.336Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/25/8f/81dcbc2e8454b74d14881275ea45f00791052dac531a9fa8be1730d1685b/psycopg_binary-3.3.4-cp312-cp312-win_amd64.whl", hash = "sha256:494ca54901be8cf9eb7e02c25b731f2317c378efa44f43e8f9bd0e1184ae7be4", size = 3560782, upload-time = "2026-05-01T23:29:11.967Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/09/43/13e9c406fbbf354580476e248a16b64802a376873ebe6339e30bb655572d/psycopg_binary-3.3.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:fbd1d4ed566895ad2d3bf4ddfd8bae90026930ddf29df3b9d91d32c8c47866a7", size = 4590377, upload-time = "2026-05-01T23:29:18.782Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/be/2923cd7c3683e7afdecf4f10796a18de02f5c5ddc0969aa2ad0a8cdd3bbd/psycopg_binary-3.3.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:75a9067e236f9b9ae3535b66fe99bddb33d39c0de10112e49b9ab11eee53dc31", size = 4669023, upload-time = "2026-05-01T23:29:25.884Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/96/a0/2c913d6fe13d6a8bd13597d36739bf47af063ad9399e402cfecab16f3c1e/psycopg_binary-3.3.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:b56b603ebcea8aa10b46228b8410ba7f13e7c2ee54389d4d9be0927fd8ce2a70", size = 5467423, upload-time = "2026-05-01T23:29:33.416Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e7/38/205d10bc1ad0df4a21c5c51659126bd3ea0ef98fcad1e852f78c249bb9c3/psycopg_binary-3.3.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c677c4ad433cb7150c8cd304a0769ae3bcfbe5ea0676eb53faa7b1443b16d0d3", size = 5151137, upload-time = "2026-05-01T23:29:42.013Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/36/fc/f0381ddcd45eff3bb70dbca6823a996048d7f507b2ec3fc92c6fabc0fe87/psycopg_binary-3.3.4-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:26df2717e59c0473e4465a97dfb1b7afebaa479277870fd5784d1436470db47c", size = 6736671, upload-time = "2026-05-01T23:29:51.626Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/95/40/fa545ae152c24327651e5624e4902121e808270be36c10b12e9939be09bc/psycopg_binary-3.3.4-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1dc1f79fd16bb1f3f4421417a514607539f17804d95c7ed617265369d1981cae", size = 4979601, upload-time = "2026-05-01T23:29:56.961Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/e4/2f8a47ee97f90cd2b933d0463081d35631ff419de2b8c984a5f369857de0/psycopg_binary-3.3.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:136f199a407b5348b9b857c504aff60c77622a28482e7195839ce1b51238c4cc", size = 4510513, upload-time = "2026-05-01T23:30:07.243Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0e/0e/94e842ff4a7f98ed162580ca2e8b8864b28c1e0350f2443f8ee47f821167/psycopg_binary-3.3.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:b6f5a29e9c775b9f12a1a717aa7a2c80f9e1db6f27ba44a5b59c80ac61d2ffcf", size = 4187243, upload-time = "2026-05-01T23:30:15.352Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d0/83/fc6c174b672e29b7de996ea77b6cbddf46c891751c3355f6974292baa6b4/psycopg_binary-3.3.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:ee17a2cf4943cde261adfad1bbc5bf38d6b3776d7afff74c7cabcbeaeb08c260", size = 3927347, upload-time = "2026-05-01T23:30:21.186Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e9/65/768364d4a97a15b1a7f47ba52688c1686f22941d8332a8398cefc468e25f/psycopg_binary-3.3.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5c4ab71be17bdca30cb34c34c4e1496e2f5d6f20c199c12bad226070b22ef9bf", size = 4236393, upload-time = "2026-05-01T23:30:26.211Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bd/3b/218efbc9e645becd80cdf651acda05f85cfe546b7a9c0458c7cbc8fe1f74/psycopg_binary-3.3.4-cp313-cp313-win_amd64.whl", hash = "sha256:dbfdb9b6cc79f31104a7b162a2b921b765fcc62af6c00540a167a8de47e4ed38", size = 3564592, upload-time = "2026-05-01T23:30:31.764Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/48/a6/828c9185701dab71b234c2a76c38a08b098ebfec5020716b4e93807492b5/psycopg_binary-3.3.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:28b7398fdd19db3232c884fb24550bdfe951221f510e195e233299e4c9b78f97", size = 4607292, upload-time = "2026-05-01T23:30:38.962Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/58/5b40dbc9d839045c9dae956960e4fb6d20bcabe6c59a2aa34fc3a371913f/psycopg_binary-3.3.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1fbaa292a3c8bb61b45df1ad3da1908ccee7cb889db9425e3557d9e34e2a4829", size = 4687023, upload-time = "2026-05-01T23:30:47.227Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/85/a9/793f0ac107a9003b48441d0d1f9f616d96e0f37458dd8dc12528ceff55fb/psycopg_binary-3.3.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:94596f9e7633ee3f6440711d43bb70aa31cc0a46a900ab8b4201a366ace5c9e7", size = 5486985, upload-time = "2026-05-01T23:30:55.517Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8f/26/42e8533497e2592334f68ec529cf5f840f7fa4e99575a4bb61aa184dbfbf/psycopg_binary-3.3.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8c0056529e68dbe9184cd4019a1f3d8f3a4ead2f6fc7a5afcf27d3314edd1277", size = 5168745, upload-time = "2026-05-01T23:31:01.904Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/15/af/b7151776cc08d5935d45c833ec818a9beb417cf7c08239af1aafbdae78ee/psycopg_binary-3.3.4-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c09aad7051326e7603c14e50636db9c01f78272dc54b3accff03d46370461e6", size = 6761486, upload-time = "2026-05-01T23:31:14.511Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d0/ed/c92533b9124712d592cbf1cd6c76da933a2e0acea81dfe1fbe7e735f0cff/psycopg_binary-3.3.4-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:514404ed543efd620c85602b747df2a23cf1241b4067199e1a66f2d2757aaa41", size = 4997427, upload-time = "2026-05-01T23:31:20.901Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a2/23/ccadfd0de416aa188356daa199453af24087b042e296088706d190ae0295/psycopg_binary-3.3.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:46893c26858be12cc49ca4226ed6a60b4bfccadd946b3bebb783a60b38788228", size = 4533549, upload-time = "2026-05-01T23:31:26.204Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/a0/c8f43cee36386f7bc891ab41a9d31ea07cf9826038e732da79f26b1e5f34/psycopg_binary-3.3.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:df1d567fc430f6df15c9fcf67d87685fc49bdb325adc0db5af1adfb2f44eb5c9", size = 4210256, upload-time = "2026-05-01T23:31:33.884Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4e/2c/c1547871be3790676e8868b38655496422f94f0978dfb66b74bdba2f1676/psycopg_binary-3.3.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:6b9016b1714da4dd5ecaaa75b82098aa5a0b87854ce9b092e21c27c4ae23e014", size = 3946204, upload-time = "2026-05-01T23:31:39.626Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/b1/f6670f00fa7ea601584623f6c11602ab92117d83eaff885e0210f6de7418/psycopg_binary-3.3.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:47c656a8a7ba6eb0cff1801a4caaa9c8bdc12d03080e273aff1c8ac39971a77e", size = 4255811, upload-time = "2026-05-01T23:31:44.986Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/e6/5fff07a70d1f945ed90ae131c3bd76cab32beff7c58c6db15ad5820b6d1f/psycopg_binary-3.3.4-cp314-cp314-win_amd64.whl", hash = "sha256:c37e024c07308cd06cf3ec51bfd0e7f6157585a4d84d1bce4a7f5f7913719bf8", size = 3666849, upload-time = "2026-05-01T23:31:51.165Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2027,11 +2027,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "python-multipart"
|
||||
version = "0.0.26"
|
||||
version = "0.0.27"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/88/71/b145a380824a960ebd60e1014256dbb7d2253f2316ff2d73dfd8928ec2c3/python_multipart-0.0.26.tar.gz", hash = "sha256:08fadc45918cd615e26846437f50c5d6d23304da32c341f289a617127b081f17", size = 43501, upload-time = "2026-04-10T14:09:59.473Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/69/9b/f23807317a113dc36e74e75eb265a02dd1a4d9082abc3c1064acd22997c4/python_multipart-0.0.27.tar.gz", hash = "sha256:9870a6a8c5a20a5bf4f07c017bd1489006ff8836cff097b6933355ee2b49b602", size = 44043, upload-time = "2026-04-27T10:51:26.649Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/9a/22/f1925cdda983ab66fc8ec6ec8014b959262747e58bdca26a4e3d1da29d56/python_multipart-0.0.26-py3-none-any.whl", hash = "sha256:c0b169f8c4484c13b0dcf2ef0ec3a4adb255c4b7d18d8e420477d2b1dd03f185", size = 28847, upload-time = "2026-04-10T14:09:58.131Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/78/4126abcbdbd3c559d43e0db7f7b9173fc6befe45d39a2856cc0b8ec2a5a6/python_multipart-0.0.27-py3-none-any.whl", hash = "sha256:6fccfad17a27334bd0193681b369f476eda3409f17381a2d65aa7df3f7275645", size = 29254, upload-time = "2026-04-27T10:51:24.997Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2533,7 +2533,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "turnstone"
|
||||
version = "1.5.0"
|
||||
version = "1.5.7"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "alembic" },
|
||||
|
||||
Reference in New Issue
Block a user