diff --git a/docs/api-reference.md b/docs/api-reference.md index e83c6c0b..ef587e59 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -515,8 +515,8 @@ Returns a list of all active workstreams. ```json { "workstreams": [ - {"id": "abc123", "name": "default", "state": "idle", "session_id": "a1b2c3d4e5f6"}, - {"id": "def456", "name": "hacker-news", "state": "thinking", "session_id": "c5d6e7f8a9b0"} + {"id": "abc123", "name": "default", "state": "idle"}, + {"id": "def456", "name": "hacker-news", "state": "thinking"} ] } ``` @@ -528,22 +528,21 @@ Each workstream object: | `id` | string | Unique workstream routing identifier | | `name` | string | Display name (alias if set, otherwise `ws-xxxx`) | | `state` | string | Current state (see state values above) | -| `session_id` | string/null | Session ID of the workstream's `ChatSession`, used for deduplication against `/v1/api/sessions` | --- -### `GET /v1/api/sessions` +### `GET /v1/api/workstreams/saved` -Returns a list of saved sessions from the database, ordered by most recently +Returns a list of saved workstreams from the database, ordered by most recently updated. **Response:** ```json { - "sessions": [ + "workstreams": [ { - "session_id": "a1b2c3d4e5f6", + "ws_id": "a1b2c3d4e5f6", "alias": "refactor", "title": "JWT Authentication Refactor", "created": "2026-03-01 10:00:00", @@ -554,18 +553,16 @@ updated. } ``` -Each session object: +Each saved workstream object: | Field | Type | Description | |-----------------|-------------|--------------------------------------------| -| `session_id` | string | Unique 32-char hex UUID session identifier | +| `ws_id` | string | Unique workstream identifier | | `alias` | string/null | User-assigned short name | | `title` | string/null | LLM-generated title | -| `created` | string | ISO timestamp of session creation | +| `created` | string | ISO timestamp of workstream creation | | `updated` | string | ISO timestamp of last message | -| `message_count` | int | Number of messages in the session | -| `node_id` | string/null | Server node that created the session | -| `ws_id` | string/null | Workstream the session belongs to | +| `message_count` | int | Number of messages in the workstream | --- @@ -721,7 +718,7 @@ All fields are optional. The body can be empty or an empty JSON object. | `name` | string | auto | Workstream display name | | `model` | string | default | Model alias from the registry (`[models.*]`) | | `auto_approve` | bool | false | Auto-approve all tool calls for this workstream | -| `resume_session` | string | "" | Session ID to resume atomically during creation (empty = fresh)| +| `resume_ws` | string | "" | Workstream ID to resume atomically during creation (empty = fresh)| **Response (success):** diff --git a/docs/architecture.md b/docs/architecture.md index 925e9ba8..ec0f4047 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -472,7 +472,7 @@ independently, then returns the final content as the tool result. - **task**: uses `self._task_tools` (`TASK_AGENT_TOOLS` + MCP tools) - **plan**: uses `self._agent_tools` (`AGENT_TOOLS` + MCP tools). Writes output - to `.plan-.md` — unique per `ChatSession` so concurrent workstreams + to `.plan-.md` — unique per `ChatSession` so concurrent workstreams don't collide. On repeat invocations the prior `plan` tool call and its result are forwarded from `self.messages` so the agent refines the existing plan rather than starting over. Planning instructions are injected as a developer message @@ -688,8 +688,11 @@ memories created TEXT NOT NULL updated TEXT NOT NULL -sessions - session_id TEXT PRIMARY KEY +workstreams + ws_id TEXT PRIMARY KEY + node_id TEXT NOT NULL + name TEXT NOT NULL + state TEXT NOT NULL DEFAULT 'idle' alias TEXT UNIQUE -- user-assigned short name (nullable) title TEXT -- LLM-generated title (nullable) created TEXT NOT NULL @@ -697,7 +700,7 @@ sessions conversations id INTEGER PRIMARY KEY AUTOINCREMENT - session_id TEXT NOT NULL + ws_id TEXT NOT NULL timestamp TEXT NOT NULL role TEXT NOT NULL -- user | assistant | tool_call | tool_result content TEXT @@ -706,8 +709,8 @@ conversations tool_call_id TEXT -- links tool_call ↔ tool_result for resume provider_data TEXT -- raw provider content (e.g. Anthropic encrypted) -session_config - session_id TEXT NOT NULL -- composite PK with key +workstream_config + ws_id TEXT NOT NULL -- composite PK with key key TEXT NOT NULL value TEXT @@ -722,22 +725,20 @@ and are the single source of truth for both backends and Alembic migrations. | Method | Purpose | |--------|---------| -| `register_session(session_id, title, node_id, ws_id)` | Create a sessions row (no-op if exists) | -| `save_message(session_id, role, content, ...)` | Log a message to conversations | -| `load_session_messages(session_id)` | Reconstruct OpenAI message format from DB rows | -| `list_sessions(limit)` | List sessions with >=1 message, ordered by updated DESC | -| `delete_session(session_id)` | Delete session and all its messages | -| `prune_sessions(retention_days)` | Remove empty sessions and old unnamed sessions | -| `resolve_session(alias_or_id)` | Resolve alias, exact id, or id prefix to full session_id | -| `save_session_config(session_id, config)` | Persist session configuration key/value pairs | -| `load_session_config(session_id)` | Retrieve session configuration | -| `set_session_alias(session_id, alias)` | Set user-friendly alias (returns False if taken) | -| `get_session_name(session_id)` | Return alias if set, else title, else None | -| `update_session_title(session_id, title)` | Set/update LLM-generated title | | `register_workstream(ws_id, node_id, name, state)` | Create a workstreams row (no-op if exists) | +| `save_message(ws_id, role, content, ...)` | Log a message to conversations | +| `load_messages(ws_id)` | Reconstruct OpenAI message format from DB rows | +| `list_workstreams_with_history(limit)` | List workstreams with >=1 message, ordered by updated DESC | +| `delete_workstream(ws_id)` | Delete workstream and cascade conversations + config | +| `prune_workstreams(retention_days)` | Remove empty workstreams and old unnamed workstreams | +| `resolve_workstream(alias_or_id)` | Resolve alias, exact id, or id prefix to full ws_id | +| `save_workstream_config(ws_id, config)` | Persist workstream configuration key/value pairs | +| `load_workstream_config(ws_id)` | Retrieve workstream configuration | +| `set_workstream_alias(ws_id, alias)` | Set user-friendly alias (returns False if taken) | +| `get_workstream_display_name(ws_id)` | Return alias if set, else title, else None | +| `update_workstream_title(ws_id, title)` | Set/update LLM-generated title | | `update_workstream_state(ws_id, state)` | Update workstream state and bump timestamp | | `update_workstream_name(ws_id, name)` | Update workstream display name | -| `delete_workstream(ws_id)` | Delete a workstream row | | `list_workstreams(node_id, limit)` | List workstreams, optionally by node | | `kv_get(key)` / `kv_set(key, value)` / `kv_delete(key)` | Generic key-value store (backs memories table) | | `kv_list()` / `kv_search(query)` | List or search key-value pairs | @@ -757,59 +758,59 @@ pool_size = 5 # PostgreSQL connection pool size Environment variables: `TURNSTONE_DB_BACKEND`, `TURNSTONE_DB_URL`, `TURNSTONE_DB_PATH`. -### Session Persistence and Resume +### Persistence and Resume -Each `ChatSession` generates a full 32-char hex UUID `_session_id` on creation -and registers it in the `sessions` table with the server's `node_id` and the -owning `ws_id`. Messages are saved to `conversations` as they happen via -`save_message()`. Workstreams are persisted to the `workstreams` table on -creation, with state changes tracked via `update_workstream_state()`. +`ws_id` is the sole persistent identity for both routing and conversation +history. There is no separate `session_id` — the `workstreams` table holds +alias, title, and state alongside the routing fields (`node_id`, `name`). +Messages are saved to `conversations` (keyed by `ws_id`) as they happen +via `save_message()`. Workstream state changes are tracked via +`update_workstream_state()`. **Auto-titling:** After the first complete exchange (user message + assistant response), a background thread calls the LLM with a title-generation prompt (`reasoning_effort: "low"`, `max_completion_tokens: 200`). The generated -title (3-8 words) is stored in `sessions.title`. +title (3-8 words) is stored in `workstreams.title`. -**Resume flow:** `ChatSession.resume_session(session_id)` calls -`load_session_messages()` which reconstructs the OpenAI message format from -database rows: +**Resume flow:** `ChatSession.resume(ws_id)` calls `load_messages()` which +reconstructs the OpenAI message format from database rows: - `user` and `assistant` rows map directly - Consecutive `tool_call` rows are grouped into one assistant message's `tool_calls` array, paired with subsequent `tool_result` rows via `tool_call_id` (or positional matching for legacy data) -- **Interrupted session repair:** If the last assistant message has - `tool_calls` but fewer tool results than expected (session was +- **Interrupted conversation repair:** If the last assistant message has + `tool_calls` but fewer tool results than expected (conversation was interrupted mid-execution), the incomplete turn is stripped so the LLM can re-generate cleanly -- The session adopts the old `_session_id`, so new messages continue in - the same session +- The `ChatSession` adopts the resumed `_ws_id`, so new messages continue + in the same workstream **Config persistence:** LLM-affecting parameters (`temperature`, `reasoning_effort`, `max_tokens`, `instructions`, `creative_mode`) are -persisted to the `session_config` table on creation and whenever changed -via slash commands. `resume_session()` restores these values so resumed -sessions behave identically to the original. +persisted to the `workstream_config` table on creation and whenever changed +via slash commands. `resume()` restores these values so resumed workstreams +behave identically to the original. **`/clear` vs `/new`:** `/clear` wipes in-memory context but preserves -messages in the database for future resume. `/new` starts a fresh session -(new `_session_id`), leaving the old session resumable. +messages in the database for future resume. `/new` starts a fresh workstream +(new `_ws_id`), leaving the old workstream resumable. -**Resolution:** `resolve_session()` accepts aliases, exact session IDs, or -session ID prefixes, enabling `turnstone --resume refactor` or `/resume abc12`. +**Resolution:** `resolve_workstream()` accepts aliases, exact workstream IDs, +or ID prefixes, enabling `turnstone --resume refactor` or `/resume abc12`. -**Session listing:** `list_sessions()` only returns sessions that have at -least one saved message (`WHERE EXISTS` on `conversations`). Sessions -registered but never used (e.g., from process startup) are invisible until -a message is sent. +**Workstream listing:** `list_workstreams_with_history()` only returns +workstreams that have at least one saved message (`WHERE EXISTS` on +`conversations`). Workstreams registered but never used (e.g., from process +startup) are invisible until a message is sent. -**Session pruning:** `prune_sessions(retention_days, log_fn)` runs once at -startup (CLI and server). It removes: -- Sessions with no messages (orphaned registrations) -- Unnamed sessions (`alias IS NULL`) older than `retention_days` days (default 90) +**Workstream pruning:** `prune_workstreams(retention_days, log_fn)` runs once +at startup (CLI and server). It removes: +- Workstreams with no messages (orphaned registrations) +- Unnamed workstreams (`alias IS NULL`) older than `retention_days` days (default 90) -Named (aliased) sessions are never age-pruned. Configure with -`--session-retention-days N` (0 = disable age pruning). +Named (aliased) workstreams are never age-pruned. Configure with +`--retention-days N` (0 = disable age pruning). --- @@ -947,7 +948,7 @@ Three hierarchical scopes control endpoint access: | Scope | Grants | Endpoints | |-------|--------|-----------| -| `read` | SSE streams, workstream listing, sessions | GET endpoints | +| `read` | SSE streams, workstream listing, history | GET endpoints | | `write` | `read` + send, command, workstream create/close | POST to `/api/send`, `/api/command`, etc. | | `approve` | `write` + tool approval, admin operations | POST to `/api/approve`, `/api/admin/*` | @@ -1239,7 +1240,7 @@ typed event dataclasses. **Two client pairs** (sync + async): -- `TurnstoneServer` / `AsyncTurnstoneServer` — server API (workstreams, chat, streaming, sessions) +- `TurnstoneServer` / `AsyncTurnstoneServer` — server API (workstreams, chat, streaming) - `TurnstoneConsole` / `AsyncTurnstoneConsole` — console API (cluster overview, nodes, workstreams) **Design**: async-first with thin sync wrappers. `_BaseClient` provides httpx @@ -1279,11 +1280,11 @@ The `ChannelRouter` manages bidirectional routing: it maps platform channel/thread IDs to turnstone workstream IDs, handles workstream creation and stale-route recovery, and resolves platform users to turnstone identities via the `channel_users` table. When an evicted -workstream is reactivated, the router uses atomic session resume via the -`resume_session` field on `CreateWorkstreamMessage` — the server resumes -the old session during workstream creation in a single HTTP request, -eliminating ordering fragility. The bridge emits a `SessionResumedEvent` -to confirm success. +workstream is reactivated, the router uses atomic resume via the +`resume_ws` field on `CreateWorkstreamMessage` — the server resumes +the old workstream's conversation during creation in a single HTTP +request, eliminating ordering fragility. The bridge emits a +`WorkstreamResumedEvent` to confirm success. Discord ships as the first adapter. See [channels.md](channels.md) for setup instructions, configuration reference, and the adapter development diff --git a/docs/channels.md b/docs/channels.md index a0e94341..b4c869f4 100644 --- a/docs/channels.md +++ b/docs/channels.md @@ -136,11 +136,11 @@ An admin can also force-link or unlink users via the console admin panel 1.5 seconds. - If the workstream is evicted for capacity, the next message in the thread auto-creates a new workstream and atomically resumes the - previous session via the `resume_session` field on - `CreateWorkstreamMessage`. The server resumes the session during - workstream creation (same HTTP request), and the bridge emits a - `SessionResumedEvent` back to the channel. The thread receives a - *"Session resumed: {name} ({count} messages restored)"* confirmation. + previous workstream via the `resume_ws` field on + `CreateWorkstreamMessage`. The server resumes the workstream during + creation (same HTTP request), and the bridge emits a + `WorkstreamResumedEvent` back to the channel. The thread receives a + *"Resumed: {name} ({count} messages restored)"* confirmation. ### Slash Commands @@ -232,14 +232,12 @@ See [Security: Database Schema](security.md#database-schema) for the 3. **Eviction** — the server evicts an idle workstream for capacity. The route is preserved and the thread stays open. 4. **Reactivation** — the next message in the thread detects the stale - route (no MQ owner), looks up the old session via - `get_session_id_by_ws()`, and creates a new workstream with - `resume_session` set atomically on the `CreateWorkstreamMessage`. The - server resumes the session during creation (no separate command - needed). The bridge emits a `SessionResumedEvent` to the channel, and - the thread displays *"Session resumed: {name} ({count} messages - restored)"*. If the old session was pruned, the workstream starts - fresh with no error. + route (no MQ owner) and creates a new workstream with the old `ws_id` + as `resume_ws` on the `CreateWorkstreamMessage`. The server resumes + the workstream during creation (no separate command or reverse lookup + needed). The bridge emits a `WorkstreamResumedEvent` to the channel, and + the thread displays *"Resumed: {name} ({count} messages restored)"*. + If the old workstream was pruned, a fresh one starts with no error. 5. **Close** — `/close` command closes the workstream via MQ, deletes the route, unsubscribes from events, and archives the Discord thread. diff --git a/docs/diagrams/03-core-engine-classes.puml b/docs/diagrams/03-core-engine-classes.puml index 02ccc804..af2cbd33 100644 --- a/docs/diagrams/03-core-engine-classes.puml +++ b/docs/diagrams/03-core-engine-classes.puml @@ -118,7 +118,7 @@ class "ChatSession" as ChatSession { - ui: SessionUI - messages: list[dict] - _msg_tokens: list[int] - - _session_id: str + - _ws_id: str - _mcp_client: MCPClientManager | None - _registry: ModelRegistry | None + model_alias: str | None {property} @@ -130,7 +130,7 @@ class "ChatSession" as ChatSession { -- + send(user_input: str) + handle_command(command: str) - + resume_session(session_id: str) + + resume(ws_id: str) - _save_config() - _stream_response(stream) → dict - _create_stream_with_retry(msgs) → Stream (+ fallback) diff --git a/docs/diagrams/04-conversation-turn.puml b/docs/diagrams/04-conversation-turn.puml index 0aef6819..e4715015 100644 --- a/docs/diagrams/04-conversation-turn.puml +++ b/docs/diagrams/04-conversation-turn.puml @@ -18,7 +18,7 @@ User -> CS : send(user_input) activate CS CS -> CS : messages.append({role: "user", content: input}) -CS -> DB : save_message(session_id, "user", input) +CS -> DB : save_message(ws_id, "user", input) == LLM Call Loop == @@ -65,8 +65,8 @@ group loop [while tool_calls present] CS -> CS : _update_token_table()\ncalibrate chars_per_token ratio CS -> CS : messages.append(assistant_msg) - CS -> DB : save_message(session_id, "assistant", content) - CS -> DB : save_message(session_id, "tool_call", ...) ×N + CS -> DB : save_message(ws_id, "assistant", content) + CS -> DB : save_message(ws_id, "tool_call", ...) ×N == Tool Dispatch (if tool_calls) == @@ -136,7 +136,7 @@ group loop [while tool_calls present] loop for each result CS -> CS : messages.append({role: "tool", ...}) - CS -> DB : save_message(session_id, "tool_result", ...) + CS -> DB : save_message(ws_id, "tool_result", ...) end opt user_feedback from approval diff --git a/docs/diagrams/13-sdk-architecture.puml b/docs/diagrams/13-sdk-architecture.puml index d43b3855..eff1a3ff 100644 --- a/docs/diagrams/13-sdk-architecture.puml +++ b/docs/diagrams/13-sdk-architecture.puml @@ -35,7 +35,7 @@ package "turnstone/sdk/ (Python)" { + stream_events(ws_id) + stream_global_events() + send_and_wait() - + list_sessions() + + list_saved_workstreams() + login() / logout() + health() } diff --git a/docs/diagrams/14-storage-architecture.puml b/docs/diagrams/14-storage-architecture.puml index 242268ca..6a0a48af 100644 --- a/docs/diagrams/14-storage-architecture.puml +++ b/docs/diagrams/14-storage-architecture.puml @@ -13,23 +13,19 @@ skinparam class { ' -- Protocol -- interface "StorageBackend" as SB <> { - +register_session(session_id, title, node_id, ws_id) - +save_message(session_id, role, content, ...) - +load_session_messages(session_id) → list[dict] - +list_sessions(limit) → list - +delete_session(session_id) → bool - +prune_sessions(retention_days) → (int, int) - +resolve_session(alias_or_id) → str | None - +save_session_config(session_id, config) - +load_session_config(session_id) → dict - +set_session_alias(session_id, alias) → bool - +get_session_name(session_id) → str | None - +update_session_title(session_id, title) + +save_message(ws_id, role, content, ...) + +load_messages(ws_id) → list[dict] +register_workstream(ws_id, node_id, name, state) +update_workstream_state(ws_id, state) +update_workstream_name(ws_id, name) + +set_workstream_alias(ws_id, alias) → bool + +update_workstream_title(ws_id, title) + +resolve_workstream(alias_or_id) → str | None +delete_workstream(ws_id) → bool + +prune_workstreams(retention_days) → (int, int) +list_workstreams(node_id, limit) → list + +save_workstream_config(ws_id, config) + +load_workstream_config(ws_id) → dict +kv_get(key) → str | None +kv_set(key, value) → str | None +kv_delete(key) → bool @@ -68,9 +64,8 @@ class "_schema.py" as Schema <> { +metadata: MetaData +memories: Table +conversations: Table - +sessions: Table (node_id, ws_id, user_id) - +workstreams: Table (node_id, user_id, state) - +session_config: Table + +workstreams: Table (node_id, alias, title, state) + +workstream_config: Table +users: Table (username, password_hash) +api_tokens: Table (token_hash, scopes) +channel_users: Table (channel_type) @@ -106,14 +101,14 @@ class "_registry.py" as Registry { ' -- Facade -- class "memory.py" as Facade <> { - +register_session() +save_message() - +load_session_messages() + +load_messages() +register_workstream() +update_workstream_state() + +save_workstream_config() +save_memory() / delete_memory() +search_memories() - +... (all 22 functions) + +... (all delegated functions) -- Thin delegation to get_storage() diff --git a/docs/diagrams/16-channel-architecture.puml b/docs/diagrams/16-channel-architecture.puml index e8209ab4..f92439d8 100644 --- a/docs/diagrams/16-channel-architecture.puml +++ b/docs/diagrams/16-channel-architecture.puml @@ -195,13 +195,13 @@ note right of Bot 5. Broker.push_inbound(SendMessage) 6. Bridge pops from Redis, drives server - **Session Resume (evicted workstreams)** + **Workstream Resume (evicted workstreams)** 1. Stale route detected (no MQ owner) - 2. Old session looked up via get_session_id_by_ws() + 2. Existing ws_id reused directly from route 3. CreateWorkstreamMessage sent with - resume_session= + resume_ws= 4. Server resumes atomically during creation - 5. Bridge emits SessionResumedEvent → thread + 5. Bridge emits WorkstreamResumedEvent → thread end note note right of Broker diff --git a/docs/sdk.md b/docs/sdk.md index b0a24876..608dea90 100644 --- a/docs/sdk.md +++ b/docs/sdk.md @@ -78,7 +78,7 @@ Both `TurnstoneServer` (sync) and `AsyncTurnstoneServer` (async) expose: | **Streaming** | `stream_events(ws_id)` | `Iterator[ServerEvent]` | | | `stream_global_events()` | `Iterator[ServerEvent]` | | **High-level** | `send_and_wait(message, ws_id, *, timeout, on_event)` | `TurnResult` | -| **Sessions** | `list_sessions()` | `ListSessionsResponse` | +| **Saved** | `list_saved_workstreams()` | `ListSavedWorkstreamsResponse` | | **Auth** | `login(username=..., password=...)` | `AuthLoginResponse` | | | `login(token="ts_xxx")` | `AuthLoginResponse` | | | `logout()` | `StatusResponse` | diff --git a/docs/security.md b/docs/security.md index a3430daf..16cf9626 100644 --- a/docs/security.md +++ b/docs/security.md @@ -73,7 +73,7 @@ Scopes are hierarchical — higher scopes imply all lower ones. | Scope | Grants | Implies | |-------|--------|---------| -| `read` | View workstreams, sessions, history | — | +| `read` | View workstreams, saved workstreams, history | — | | `write` | Send messages, create/close workstreams | `read` | | `approve` | Approve tool calls, admin endpoints | `read`, `write` | diff --git a/docs/tools.md b/docs/tools.md index 5a223f97..e8cd0ce2 100644 --- a/docs/tools.md +++ b/docs/tools.md @@ -337,7 +337,7 @@ Plan before implementing -- an autonomous agent explores the codebase and writes |-----------|--------|----------|-------------| | `prompt` | string | yes | What to plan -- the goal, constraints, and scope. | -- **What it does**: Spawns a planning sub-agent with `AGENT_TOOLS` (read-only tools: `read_file`, `search`, `math`, `man`, `web_fetch`, `web_search`). The agent explores the codebase and writes a structured plan to `.plan-.md` (unique per session, so concurrent workstreams never collide). If the `plan` tool has been called before in the same session, the prior plan is passed to the agent as context so it refines rather than restarts. After completion, the user is prompted to review and can accept, reject, or annotate the plan. +- **What it does**: Spawns a planning sub-agent with `AGENT_TOOLS` (read-only tools: `read_file`, `search`, `math`, `man`, `web_fetch`, `web_search`). The agent explores the codebase and writes a structured plan to `.plan-.md` (unique per workstream, so concurrent workstreams never collide). If the `plan` tool has been called before in the same session, the prior plan is passed to the agent as context so it refines rather than restarts. After completion, the user is prompted to review and can accept, reject, or annotate the plan. - **Auto-approve**: No -- requires user confirmation, plus post-execution review gate. - **Agent availability**: Not available to sub-agents (top-level only). diff --git a/sdk/typescript/openapi-server.json b/sdk/typescript/openapi-server.json index f2ae4f91..01d96e28 100644 --- a/sdk/typescript/openapi-server.json +++ b/sdk/typescript/openapi-server.json @@ -2,7 +2,7 @@ "openapi": "3.1.0", "info": { "title": "turnstone Server API", - "version": "0.3.0", + "version": "0.4.2", "description": "Single-node workstream management, chat interaction, and real-time streaming." }, "paths": { @@ -365,12 +365,12 @@ } } }, - "/v1/api/sessions": { + "/v1/api/workstreams/saved": { "get": { - "summary": "List saved sessions", - "operationId": "v1_api_sessions_get", + "summary": "List saved workstreams", + "operationId": "v1_api_workstreams_saved_get", "tags": [ - "Sessions" + "Workstreams" ], "responses": { "200": { @@ -378,7 +378,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ListSessionsResponse" + "$ref": "#/components/schemas/ListSavedWorkstreamsResponse" } } } @@ -427,6 +427,88 @@ } } }, + "/v1/api/auth/setup": { + "post": { + "summary": "Create first admin user", + "operationId": "v1_api_auth_setup_post", + "tags": [ + "Auth" + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AuthSetupRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AuthSetupResponse" + } + } + } + }, + "400": { + "description": "Error 400", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "409": { + "description": "Error 409", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "503": { + "description": "Error 503", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + } + }, + "/v1/api/auth/status": { + "get": { + "summary": "Return auth state", + "operationId": "v1_api_auth_status_get", + "tags": [ + "Auth" + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AuthStatusResponse" + } + } + } + } + } + } + }, "/v1/api/auth/logout": { "post": { "summary": "Clear auth cookie", @@ -503,17 +585,27 @@ "type": "object" }, "AuthLoginRequest": { - "description": "POST /v1/api/auth/login request body.", + "description": "POST /v1/api/auth/login request body.\n\nEither username+password or token must be provided.", "properties": { + "username": { + "default": "", + "description": "Login username", + "title": "Username", + "type": "string" + }, + "password": { + "default": "", + "description": "Login password", + "title": "Password", + "type": "string" + }, "token": { - "description": "Bearer token to authenticate", + "default": "", + "description": "Legacy: bearer token to authenticate", "title": "Token", "type": "string" } }, - "required": [ - "token" - ], "title": "AuthLoginRequest", "type": "object" }, @@ -525,14 +617,35 @@ "title": "Status", "type": "string" }, + "user_id": { + "default": "", + "description": "Authenticated user ID", + "title": "User Id", + "type": "string" + }, "role": { - "description": "Assigned role", + "description": "Legacy role", "examples": [ "full", "read" ], "title": "Role", "type": "string" + }, + "scopes": { + "default": "", + "description": "Comma-separated scopes", + "examples": [ + "read,write,approve" + ], + "title": "Scopes", + "type": "string" + }, + "jwt": { + "default": "", + "description": "JWT session token (if JWT auth is configured)", + "title": "Jwt", + "type": "string" } }, "required": [ @@ -541,6 +654,97 @@ "title": "AuthLoginResponse", "type": "object" }, + "AuthSetupRequest": { + "description": "POST /v1/api/auth/setup request body.", + "properties": { + "username": { + "description": "Login username (1-64 ASCII characters)", + "title": "Username", + "type": "string" + }, + "display_name": { + "description": "Display name", + "title": "Display Name", + "type": "string" + }, + "password": { + "description": "Password (minimum 8 characters)", + "title": "Password", + "type": "string" + } + }, + "required": [ + "username", + "display_name", + "password" + ], + "title": "AuthSetupRequest", + "type": "object" + }, + "AuthSetupResponse": { + "description": "POST /v1/api/auth/setup success response.", + "properties": { + "status": { + "default": "ok", + "title": "Status", + "type": "string" + }, + "user_id": { + "title": "User Id", + "type": "string" + }, + "username": { + "title": "Username", + "type": "string" + }, + "role": { + "default": "full", + "title": "Role", + "type": "string" + }, + "scopes": { + "default": "approve,read,write", + "title": "Scopes", + "type": "string" + }, + "jwt": { + "default": "", + "description": "JWT session token", + "title": "Jwt", + "type": "string" + } + }, + "required": [ + "user_id", + "username" + ], + "title": "AuthSetupResponse", + "type": "object" + }, + "AuthStatusResponse": { + "description": "GET /v1/api/auth/status response.", + "properties": { + "auth_enabled": { + "title": "Auth Enabled", + "type": "boolean" + }, + "has_users": { + "title": "Has Users", + "type": "boolean" + }, + "setup_required": { + "title": "Setup Required", + "type": "boolean" + } + }, + "required": [ + "auth_enabled", + "has_users", + "setup_required" + ], + "title": "AuthStatusResponse", + "type": "object" + }, "SendRequest": { "properties": { "message": { @@ -677,6 +881,12 @@ "description": "Auto-approve all tool calls", "title": "Auto Approve", "type": "boolean" + }, + "resume_ws": { + "default": "", + "description": "Workstream ID to resume atomically during creation (empty = fresh start)", + "title": "Resume Ws", + "type": "string" } }, "title": "CreateWorkstreamRequest", @@ -693,6 +903,18 @@ "description": "Assigned workstream name", "title": "Name", "type": "string" + }, + "resumed": { + "default": false, + "description": "Whether a previous workstream was resumed", + "title": "Resumed", + "type": "boolean" + }, + "message_count": { + "default": 0, + "description": "Number of messages in the resumed workstream", + "title": "Message Count", + "type": "integer" } }, "required": [ @@ -745,18 +967,6 @@ "state": { "title": "State", "type": "string" - }, - "session_id": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "title": "Session Id" } }, "required": [ @@ -837,18 +1047,6 @@ "title": "State", "type": "string" }, - "session_id": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "title": "Session Id" - }, "title": { "default": "", "title": "Title", @@ -903,26 +1101,26 @@ "title": "DashboardWorkstream", "type": "object" }, - "ListSessionsResponse": { + "ListSavedWorkstreamsResponse": { "properties": { - "sessions": { + "workstreams": { "items": { - "$ref": "#/components/schemas/SessionInfo" + "$ref": "#/components/schemas/SavedWorkstreamInfo" }, - "title": "Sessions", + "title": "Workstreams", "type": "array" } }, "required": [ - "sessions" + "workstreams" ], - "title": "ListSessionsResponse", + "title": "ListSavedWorkstreamsResponse", "type": "object" }, - "SessionInfo": { + "SavedWorkstreamInfo": { "properties": { - "session_id": { - "title": "Session Id", + "ws_id": { + "title": "Ws Id", "type": "string" }, "alias": { @@ -963,12 +1161,12 @@ } }, "required": [ - "session_id", + "ws_id", "created", "updated", "message_count" ], - "title": "SessionInfo", + "title": "SavedWorkstreamInfo", "type": "object" }, "HealthResponse": { diff --git a/sdk/typescript/src/index.ts b/sdk/typescript/src/index.ts index 6c50a90a..3fdc8455 100644 --- a/sdk/typescript/src/index.ts +++ b/sdk/typescript/src/index.ts @@ -83,8 +83,8 @@ export type { DashboardWorkstream, DashboardAggregate, DashboardResponse, - SessionInfo, - ListSessionsResponse, + SavedWorkstreamInfo, + ListSavedWorkstreamsResponse, BackendStatus, WorkstreamCounts, HealthResponse, diff --git a/sdk/typescript/src/server.ts b/sdk/typescript/src/server.ts index 9c2ce33f..18acf78a 100644 --- a/sdk/typescript/src/server.ts +++ b/sdk/typescript/src/server.ts @@ -8,7 +8,7 @@ import type { CreateWorkstreamResponse, DashboardResponse, HealthResponse, - ListSessionsResponse, + ListSavedWorkstreamsResponse, ListWorkstreamsResponse, SendAndWaitOptions, SendResponse, @@ -178,10 +178,10 @@ export class TurnstoneServer extends BaseClient { return result; } - // -- Sessions ------------------------------------------------------------- + // -- Saved workstreams ---------------------------------------------------- - async listSessions(): Promise { - return this.request("GET", "/v1/api/sessions"); + async listSavedWorkstreams(): Promise { + return this.request("GET", "/v1/api/workstreams/saved"); } // -- Auth ----------------------------------------------------------------- diff --git a/sdk/typescript/src/types.ts b/sdk/typescript/src/types.ts index 527464da..6a070800 100644 --- a/sdk/typescript/src/types.ts +++ b/sdk/typescript/src/types.ts @@ -71,14 +71,13 @@ export interface CreateWorkstreamRequest { name?: string; model?: string; auto_approve?: boolean; - resume_session?: string; + resume_ws?: string; } export interface CreateWorkstreamResponse { ws_id: string; name: string; resumed?: boolean; - session_id?: string; message_count?: number; } @@ -90,7 +89,6 @@ export interface WorkstreamInfo { id: string; name: string; state: string; - session_id?: string | null; } export interface ListWorkstreamsResponse { @@ -101,7 +99,6 @@ export interface DashboardWorkstream { id: string; name: string; state: string; - session_id?: string | null; title?: string; tokens?: number; context_ratio?: number; @@ -128,11 +125,11 @@ export interface DashboardResponse { } // --------------------------------------------------------------------------- -// Server API — Sessions +// Server API — Saved workstreams // --------------------------------------------------------------------------- -export interface SessionInfo { - session_id: string; +export interface SavedWorkstreamInfo { + ws_id: string; alias?: string | null; title?: string | null; created: string; @@ -140,8 +137,8 @@ export interface SessionInfo { message_count: number; } -export interface ListSessionsResponse { - sessions: SessionInfo[]; +export interface ListSavedWorkstreamsResponse { + workstreams: SavedWorkstreamInfo[]; } // --------------------------------------------------------------------------- diff --git a/tests/test_auth.py b/tests/test_auth.py index 307b1e70..f8bd7533 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -719,7 +719,7 @@ class TestServerAuth: srv_mod._metrics.model = "test-model" mock_session = MagicMock() - mock_session.session_id = "test-session-id" + mock_session.ws_id = "test-session-id" mock_ws = MagicMock() mock_ws.id = "test-ws" @@ -937,7 +937,7 @@ class TestServerLogin: srv_mod._metrics.model = "test-model" mock_session = MagicMock() - mock_session.session_id = "test-session-id" + mock_session.ws_id = "test-session-id" mock_ws = MagicMock() mock_ws.id = "test-ws" diff --git a/tests/test_model_registry.py b/tests/test_model_registry.py index 96fa10c8..4c3b5806 100644 --- a/tests/test_model_registry.py +++ b/tests/test_model_registry.py @@ -534,7 +534,7 @@ class TestWorkstreamModelParam: nonlocal captured_alias captured_alias = model_alias mock_session = MagicMock() - mock_session.session_id = "test123" + mock_session.ws_id = "test123" return mock_session mgr = WorkstreamManager(factory) @@ -548,7 +548,7 @@ class TestWorkstreamModelParam: nonlocal captured_alias captured_alias = model_alias mock_session = MagicMock() - mock_session.session_id = "test123" + mock_session.ws_id = "test123" return mock_session from turnstone.core.workstream import WorkstreamManager diff --git a/tests/test_notify_tool.py b/tests/test_notify_tool.py index 56d28196..a39e9ebd 100644 --- a/tests/test_notify_tool.py +++ b/tests/test_notify_tool.py @@ -14,7 +14,7 @@ def _make_session() -> ChatSession: from unittest.mock import patch with ( - patch("turnstone.core.session.register_session"), + patch("turnstone.core.memory.register_workstream"), patch("turnstone.core.session.save_message"), ): from turnstone.core.session import ChatSession diff --git a/tests/test_openapi.py b/tests/test_openapi.py index 1a9da66e..4c30a23e 100644 --- a/tests/test_openapi.py +++ b/tests/test_openapi.py @@ -27,7 +27,7 @@ class TestServerSpec: expected = { "/v1/api/workstreams", "/v1/api/dashboard", - "/v1/api/sessions", + "/v1/api/workstreams/saved", "/v1/api/send", "/v1/api/approve", "/v1/api/plan", diff --git a/tests/test_resume_flow.py b/tests/test_resume_flow.py index 2255dcaf..bf22c5cc 100644 --- a/tests/test_resume_flow.py +++ b/tests/test_resume_flow.py @@ -1,6 +1,6 @@ """Tests for the atomic workstream resumption flow. -Covers CreateWorkstreamMessage resume_session field, SessionResumedEvent, +Covers CreateWorkstreamMessage resume_ws field, WorkstreamResumedEvent, WorkstreamCreatedEvent resumed fields, and server endpoint handling. """ @@ -10,8 +10,8 @@ import json from turnstone.mq.protocol import ( CreateWorkstreamMessage, - SessionResumedEvent, WorkstreamCreatedEvent, + WorkstreamResumedEvent, ) # --------------------------------------------------------------------------- @@ -20,93 +20,78 @@ from turnstone.mq.protocol import ( class TestCreateWorkstreamMessageResumeField: - def test_resume_session_defaults_empty(self) -> None: + def test_resume_ws_defaults_empty(self) -> None: msg = CreateWorkstreamMessage(name="test") - assert msg.resume_session == "" + assert msg.resume_ws == "" - def test_resume_session_set(self) -> None: - msg = CreateWorkstreamMessage(name="test", resume_session="sess-abc") - assert msg.resume_session == "sess-abc" + def test_resume_ws_set(self) -> None: + msg = CreateWorkstreamMessage(name="test", resume_ws="ws-abc") + assert msg.resume_ws == "ws-abc" - def test_resume_session_serializes(self) -> None: - msg = CreateWorkstreamMessage(resume_session="sess-xyz") + def test_resume_ws_serializes(self) -> None: + msg = CreateWorkstreamMessage(resume_ws="ws-xyz") data = json.loads(msg.to_json()) - assert data["resume_session"] == "sess-xyz" + assert data["resume_ws"] == "ws-xyz" - def test_resume_session_deserializes(self) -> None: - msg = CreateWorkstreamMessage(resume_session="sess-123") + def test_resume_ws_deserializes(self) -> None: + msg = CreateWorkstreamMessage(resume_ws="ws-123") raw = msg.to_json() from turnstone.mq.protocol import InboundMessage restored = InboundMessage.from_json(raw) - assert getattr(restored, "resume_session", "") == "sess-123" + assert getattr(restored, "resume_ws", "") == "ws-123" class TestWorkstreamCreatedEventResumeFields: def test_default_not_resumed(self) -> None: event = WorkstreamCreatedEvent(ws_id="ws-1", name="test") assert event.resumed is False - assert event.session_id == "" assert event.message_count == 0 def test_resumed_fields(self) -> None: - event = WorkstreamCreatedEvent( - ws_id="ws-1", name="test", resumed=True, session_id="s-1", message_count=42 - ) + event = WorkstreamCreatedEvent(ws_id="ws-1", name="test", resumed=True, message_count=42) assert event.resumed is True - assert event.session_id == "s-1" assert event.message_count == 42 def test_serializes_resumed_fields(self) -> None: - event = WorkstreamCreatedEvent( - ws_id="ws-1", resumed=True, session_id="s-1", message_count=10 - ) + event = WorkstreamCreatedEvent(ws_id="ws-1", resumed=True, message_count=10) data = json.loads(event.to_json()) assert data["resumed"] is True - assert data["session_id"] == "s-1" assert data["message_count"] == 10 def test_deserializes_resumed_fields(self) -> None: - event = WorkstreamCreatedEvent( - ws_id="ws-1", resumed=True, session_id="s-1", message_count=5 - ) + event = WorkstreamCreatedEvent(ws_id="ws-1", resumed=True, message_count=5) from turnstone.mq.protocol import OutboundEvent restored = OutboundEvent.from_json(event.to_json()) assert isinstance(restored, WorkstreamCreatedEvent) assert restored.resumed is True - assert restored.session_id == "s-1" assert restored.message_count == 5 -class TestSessionResumedEvent: +class TestWorkstreamResumedEvent: def test_defaults(self) -> None: - event = SessionResumedEvent(ws_id="ws-1") - assert event.type == "session_resumed" - assert event.session_id == "" + event = WorkstreamResumedEvent(ws_id="ws-1") + assert event.type == "ws_resumed" assert event.message_count == 0 assert event.name == "" def test_with_values(self) -> None: - event = SessionResumedEvent( - ws_id="ws-1", session_id="s-abc", message_count=25, name="My Chat" - ) - assert event.session_id == "s-abc" + event = WorkstreamResumedEvent(ws_id="ws-1", message_count=25, name="My Chat") assert event.message_count == 25 assert event.name == "My Chat" def test_round_trip(self) -> None: - event = SessionResumedEvent(ws_id="ws-1", session_id="s-abc", message_count=10, name="Chat") + event = WorkstreamResumedEvent(ws_id="ws-1", message_count=10, name="Chat") from turnstone.mq.protocol import OutboundEvent restored = OutboundEvent.from_json(event.to_json()) - assert isinstance(restored, SessionResumedEvent) - assert restored.session_id == "s-abc" + assert isinstance(restored, WorkstreamResumedEvent) assert restored.message_count == 10 assert restored.name == "Chat" def test_registered_in_outbound_registry(self) -> None: from turnstone.mq.protocol import _OUTBOUND_REGISTRY - assert "session_resumed" in _OUTBOUND_REGISTRY - assert _OUTBOUND_REGISTRY["session_resumed"] is SessionResumedEvent + assert "ws_resumed" in _OUTBOUND_REGISTRY + assert _OUTBOUND_REGISTRY["ws_resumed"] is WorkstreamResumedEvent diff --git a/tests/test_sdk_server.py b/tests/test_sdk_server.py index 4f903513..699571d0 100644 --- a/tests/test_sdk_server.py +++ b/tests/test_sdk_server.py @@ -148,19 +148,19 @@ async def test_command(): # --------------------------------------------------------------------------- -# Sessions +# History # --------------------------------------------------------------------------- @pytest.mark.anyio -async def test_list_sessions(): +async def test_list_saved_workstreams(): transport = _mock_transport( { - "GET /v1/api/sessions": _json_response( + "GET /v1/api/workstreams/saved": _json_response( { - "sessions": [ + "workstreams": [ { - "session_id": "s1", + "ws_id": "s1", "title": "test", "created": "2024-01-01", "updated": "2024-01-02", @@ -173,8 +173,8 @@ async def test_list_sessions(): ) async with httpx.AsyncClient(transport=transport, base_url="http://test") as hc: client = AsyncTurnstoneServer(httpx_client=hc) - resp = await client.list_sessions() - assert len(resp.sessions) == 1 + resp = await client.list_saved_workstreams() + assert len(resp.workstreams) == 1 # --------------------------------------------------------------------------- diff --git a/tests/test_server_live.py b/tests/test_server_live.py index 2a45b316..7fd97aef 100644 --- a/tests/test_server_live.py +++ b/tests/test_server_live.py @@ -609,7 +609,7 @@ class TestServerHealthMetrics: mock_ui._ws_context_ratio = 0.0 mock_session = MagicMock() - mock_session.session_id = "test-session-id" + mock_session.ws_id = "test-session-id" mock_ws = MagicMock() mock_ws.id = "test-ws" @@ -785,7 +785,7 @@ class TestServerRateLimiting: mock_ui._ws_context_ratio = 0.0 mock_session = MagicMock() - mock_session.session_id = "test-session-id" + mock_session.ws_id = "test-session-id" mock_ws = MagicMock() mock_ws.id = "test-ws" diff --git a/tests/test_session.py b/tests/test_session.py index 3094d58a..d24fe653 100644 --- a/tests/test_session.py +++ b/tests/test_session.py @@ -161,12 +161,12 @@ class TestPlanExec: return call_id, content, captured.get("messages", []) - def test_plan_file_uses_session_id(self, tmp_db, tmp_path, monkeypatch): - """Plan file is named .plan-.md, not .plan.md.""" + def test_plan_file_uses_ws_id(self, tmp_db, tmp_path, monkeypatch): + """Plan file is named .plan-.md, not .plan.md.""" monkeypatch.chdir(tmp_path) session = _make_session() self._run_plan(session, "add feature") - expected = tmp_path / f".plan-{session._session_id}.md" + expected = tmp_path / f".plan-{session._ws_id}.md" assert expected.exists(), f"Expected {expected} to be created" assert not (tmp_path / ".plan.md").exists() @@ -176,7 +176,7 @@ class TestPlanExec: session = _make_session() plan_content = "## Goal\n\nAdd a new endpoint." self._run_plan(session, "add endpoint", agent_return=plan_content) - plan_file = tmp_path / f".plan-{session._session_id}.md" + plan_file = tmp_path / f".plan-{session._ws_id}.md" assert plan_file.read_text() == plan_content def test_two_sessions_produce_different_files(self, tmp_db, tmp_path, monkeypatch): @@ -184,7 +184,7 @@ class TestPlanExec: monkeypatch.chdir(tmp_path) s1 = _make_session() s2 = _make_session() - assert s1._session_id != s2._session_id + assert s1._ws_id != s2._ws_id self._run_plan(s1, "feature A") self._run_plan(s2, "feature B") files = list(tmp_path.glob(".plan-*.md")) diff --git a/tests/test_sessions.py b/tests/test_sessions.py index ca2d0da7..8cc33bb1 100644 --- a/tests/test_sessions.py +++ b/tests/test_sessions.py @@ -1,155 +1,155 @@ -"""Tests for session persistence and resume functionality.""" +"""Tests for workstream persistence and resume functionality.""" from unittest.mock import MagicMock import sqlalchemy as sa from turnstone.core.memory import ( - delete_session, - list_sessions, - load_session_config, - load_session_messages, - prune_sessions, - register_session, - resolve_session, + delete_workstream, + list_workstreams_with_history, + load_messages, + load_workstream_config, + prune_workstreams, + register_workstream, + resolve_workstream, save_message, - save_session_config, - set_session_alias, - update_session_title, + save_workstream_config, + set_workstream_alias, + update_workstream_title, ) from turnstone.core.session import ChatSession from turnstone.core.storage import get_storage -# ── Session registration ────────────────────────────────────────────── +# ── Workstream registration ─────────────────────────────────────────── -class TestRegisterSession: +class TestRegisterWorkstream: def test_register_creates_row(self, tmp_db): - register_session("abc123") - # Session exists in DB (resolve works) even without messages - assert resolve_session("abc123") == "abc123" + register_workstream("abc123") + # Workstream exists in DB (resolve works) even without messages + assert resolve_workstream("abc123") == "abc123" def test_register_with_title(self, tmp_db): - register_session("abc123", title="My Session") + register_workstream("abc123", name="My Workstream") save_message("abc123", "user", "hello") - rows = list_sessions() - assert rows[0][2] == "My Session" # title + rows = list_workstreams_with_history() + assert rows[0][2] is None # title column (name is separate) def test_register_idempotent(self, tmp_db): - register_session("abc123", title="First") - register_session("abc123", title="Second") # should be ignored + register_workstream("abc123") + update_workstream_title("abc123", "First") + register_workstream("abc123") # should be ignored + update_workstream_title("abc123", "First") # title is set via update save_message("abc123", "user", "hello") - rows = list_sessions() + rows = list_workstreams_with_history() assert len(rows) == 1 - assert rows[0][2] == "First" # original title preserved + assert rows[0][2] == "First" # title preserved def test_update_title(self, tmp_db): - register_session("abc123") - update_session_title("abc123", "New Title") + register_workstream("abc123") + update_workstream_title("abc123", "New Title") save_message("abc123", "user", "hello") - rows = list_sessions() + rows = list_workstreams_with_history() assert rows[0][2] == "New Title" -# ── Session alias ───────────────────────────────────────────────────── +# ── Workstream alias ────────────────────────────────────────────────── -class TestSessionAlias: +class TestWorkstreamAlias: def test_set_alias(self, tmp_db): - register_session("abc123") - assert set_session_alias("abc123", "my-session") is True + register_workstream("abc123") + assert set_workstream_alias("abc123", "my-session") is True save_message("abc123", "user", "hello") - rows = list_sessions() + rows = list_workstreams_with_history() assert rows[0][1] == "my-session" # alias def test_alias_conflict(self, tmp_db): - register_session("abc123") - register_session("def456") - set_session_alias("abc123", "taken") - assert set_session_alias("def456", "taken") is False + register_workstream("abc123") + register_workstream("def456") + set_workstream_alias("abc123", "taken") + assert set_workstream_alias("def456", "taken") is False - def test_alias_same_session_ok(self, tmp_db): - register_session("abc123") - set_session_alias("abc123", "mine") - assert set_session_alias("abc123", "mine") is True # no-op, same session + def test_alias_same_workstream_ok(self, tmp_db): + register_workstream("abc123") + set_workstream_alias("abc123", "mine") + assert set_workstream_alias("abc123", "mine") is True # no-op, same workstream -# ── Session resolution ──────────────────────────────────────────────── +# ── Workstream resolution ───────────────────────────────────────────── -class TestResolveSession: +class TestResolveWorkstream: def test_resolve_by_alias(self, tmp_db): - register_session("abc123") - set_session_alias("abc123", "my-alias") - assert resolve_session("my-alias") == "abc123" + register_workstream("abc123") + set_workstream_alias("abc123", "my-alias") + assert resolve_workstream("my-alias") == "abc123" def test_resolve_by_exact_id(self, tmp_db): - register_session("abc123def456") - assert resolve_session("abc123def456") == "abc123def456" + register_workstream("abc123def456") + assert resolve_workstream("abc123def456") == "abc123def456" def test_resolve_by_prefix(self, tmp_db): - register_session("abc123def456") - assert resolve_session("abc123") == "abc123def456" + register_workstream("abc123def456") + assert resolve_workstream("abc123") == "abc123def456" def test_resolve_prefix_ambiguous(self, tmp_db): - register_session("abc123aaaaaa") - register_session("abc123bbbbbb") + register_workstream("abc123aaaaaa") + register_workstream("abc123bbbbbb") # Ambiguous prefix should return None - assert resolve_session("abc123") is None + assert resolve_workstream("abc123") is None def test_resolve_not_found(self, tmp_db): - assert resolve_session("nonexistent") is None - - def test_resolve_legacy_session(self, tmp_db): - """Sessions that exist only in conversations (pre-migration) should auto-register.""" - save_message("legacy123456", "user", "old message") - result = resolve_session("legacy123456") - assert result == "legacy123456" - # Should now appear in sessions list - rows = list_sessions() - assert any(r[0] == "legacy123456" for r in rows) + assert resolve_workstream("nonexistent") is None -# ── List sessions ───────────────────────────────────────────────────── +# ── List workstreams with history ────────────────────────────────────── -class TestListSessions: +class TestListWorkstreamsWithHistory: def test_empty(self, tmp_db): - assert list_sessions() == [] + assert list_workstreams_with_history() == [] def test_ordered_by_updated(self, tmp_db): - register_session("first") + register_workstream("first") save_message("first", "user", "hello") - register_session("second") + # Force an older timestamp so ordering is deterministic + engine = get_storage()._engine # noqa: SLF001 + with engine.connect() as conn: + conn.execute( + sa.text("UPDATE workstreams SET updated = '2020-01-01' WHERE ws_id = 'first'") + ) + conn.commit() + register_workstream("second") save_message("second", "user", "hello") # second is more recent - rows = list_sessions() + rows = list_workstreams_with_history() assert rows[0][0] == "second" assert rows[1][0] == "first" def test_includes_message_count(self, tmp_db): - register_session("sess1") + register_workstream("sess1") save_message("sess1", "user", "hello") save_message("sess1", "assistant", "hi") - rows = list_sessions() + rows = list_workstreams_with_history() assert rows[0][5] == 2 # msg_count def test_respects_limit(self, tmp_db): for i in range(5): - register_session(f"sess{i}") + register_workstream(f"sess{i}") save_message(f"sess{i}", "user", "hello") - rows = list_sessions(limit=3) + rows = list_workstreams_with_history(limit=3) assert len(rows) == 3 -# ── Load session messages ───────────────────────────────────────────── +# ── Load messages ───────────────────────────────────────────────────── -class TestLoadSessionMessages: +class TestLoadMessages: def test_simple_user_assistant(self, tmp_db): save_message("s1", "user", "hello") save_message("s1", "assistant", "hi there") - msgs = load_session_messages("s1") + msgs = load_messages("s1") assert len(msgs) == 2 assert msgs[0] == {"role": "user", "content": "hello"} assert msgs[1] == {"role": "assistant", "content": "hi there"} @@ -159,7 +159,7 @@ class TestLoadSessionMessages: save_message("s1", "assistant", "Let me check.") save_message("s1", "tool_call", None, "bash", '{"command":"ls"}', tool_call_id="call_abc") save_message("s1", "tool_result", "file1.txt\nfile2.txt", "bash", tool_call_id="call_abc") - msgs = load_session_messages("s1") + msgs = load_messages("s1") assert len(msgs) == 3 # user, assistant+tool_calls, tool # Assistant should have content merged with tool_calls assert msgs[1]["role"] == "assistant" @@ -177,7 +177,7 @@ class TestLoadSessionMessages: save_message("s1", "user", "do stuff") save_message("s1", "tool_call", None, "bash", '{"command":"ls"}') save_message("s1", "tool_result", "output", "bash") - msgs = load_session_messages("s1") + msgs = load_messages("s1") assert len(msgs) == 3 # Synthetic IDs should match tc_id = msgs[1]["tool_calls"][0]["id"] @@ -189,36 +189,36 @@ class TestLoadSessionMessages: save_message("s1", "tool_call", None, "search", '{"query":"b"}', tool_call_id="call_2") save_message("s1", "tool_result", "result a", "search", tool_call_id="call_1") save_message("s1", "tool_result", "result b", "search", tool_call_id="call_2") - msgs = load_session_messages("s1") + msgs = load_messages("s1") assert len(msgs) == 4 # user, assistant+2 tool_calls, 2 tool results assert len(msgs[1]["tool_calls"]) == 2 assert msgs[2]["tool_call_id"] == "call_1" assert msgs[3]["tool_call_id"] == "call_2" - def test_empty_session(self, tmp_db): - assert load_session_messages("nonexistent") == [] + def test_empty_workstream(self, tmp_db): + assert load_messages("nonexistent") == [] def test_orphaned_tool_result_skipped(self, tmp_db): save_message("s1", "user", "hello") save_message("s1", "tool_result", "orphan", "bash") - msgs = load_session_messages("s1") + msgs = load_messages("s1") assert len(msgs) == 1 # only the user message -# ── Delete session ──────────────────────────────────────────────────── +# ── Delete workstream ───────────────────────────────────────────────── -class TestDeleteSession: - def test_delete_removes_session_and_messages(self, tmp_db): - register_session("abc123") +class TestDeleteWorkstream: + def test_delete_removes_workstream_and_messages(self, tmp_db): + register_workstream("abc123") save_message("abc123", "user", "hello") save_message("abc123", "assistant", "hi") - assert delete_session("abc123") is True - assert list_sessions() == [] - assert load_session_messages("abc123") == [] + assert delete_workstream("abc123") is True + assert list_workstreams_with_history() == [] + assert load_messages("abc123") == [] def test_delete_nonexistent(self, tmp_db): - assert delete_session("nonexistent") is True # no-op, still returns True + assert delete_workstream("nonexistent") is False # ── save_message with tool_call_id ──────────────────────────────────── @@ -230,7 +230,7 @@ class TestSaveMessageToolCallId: engine = get_storage()._engine # noqa: SLF001 with engine.connect() as conn: row = conn.execute( - sa.text("SELECT tool_call_id FROM conversations WHERE session_id = 's1'") + sa.text("SELECT tool_call_id FROM conversations WHERE ws_id = 's1'") ).fetchone() assert row[0] == "call_xyz" @@ -239,20 +239,20 @@ class TestSaveMessageToolCallId: engine = get_storage()._engine # noqa: SLF001 with engine.connect() as conn: row = conn.execute( - sa.text("SELECT tool_call_id FROM conversations WHERE session_id = 's1'") + sa.text("SELECT tool_call_id FROM conversations WHERE ws_id = 's1'") ).fetchone() assert row[0] is None -# ── Sessions table creation ─────────────────────────────────────────── +# ── Workstreams table creation ──────────────────────────────────────── -class TestSessionsTable: - def test_sessions_table_exists(self, tmp_db): +class TestWorkstreamsTable: + def test_workstreams_table_exists(self, tmp_db): engine = get_storage()._engine # noqa: SLF001 with engine.connect() as conn: rows = conn.execute( - sa.text("SELECT name FROM sqlite_master WHERE type='table' AND name='sessions'") + sa.text("SELECT name FROM sqlite_master WHERE type='table' AND name='workstreams'") ).fetchall() assert len(rows) == 1 @@ -263,15 +263,15 @@ class TestSessionsTable: conn.execute(sa.text("SELECT tool_call_id FROM conversations LIMIT 0")) -# ── ChatSession.resume_session ──────────────────────────────────────── +# ── ChatSession.resume ──────────────────────────────────────────────── -class TestResumeSession: +class TestResumeWorkstream: def test_resume_loads_messages(self, tmp_db, mock_openai_client): - # Set up a session with messages in DB - register_session("old_sess_123") - save_message("old_sess_123", "user", "hello world") - save_message("old_sess_123", "assistant", "hi there") + # Set up a workstream with messages in DB + register_workstream("old_ws_123") + save_message("old_ws_123", "user", "hello world") + save_message("old_ws_123", "assistant", "hi there") # Create a new session and resume session = ChatSession( @@ -283,12 +283,12 @@ class TestResumeSession: max_tokens=1000, tool_timeout=10, ) - original_id = session._session_id - assert original_id != "old_sess_123" + original_id = session._ws_id + assert original_id != "old_ws_123" - result = session.resume_session("old_sess_123") + result = session.resume("old_ws_123") assert result is True - assert session._session_id == "old_sess_123" + assert session._ws_id == "old_ws_123" assert len(session.messages) == 2 assert session.messages[0]["content"] == "hello world" assert session._title_generated is True @@ -303,9 +303,9 @@ class TestResumeSession: max_tokens=1000, tool_timeout=10, ) - assert session.resume_session("nonexistent") is False + assert session.resume("nonexistent") is False - def test_session_registered_on_init(self, tmp_db, mock_openai_client): + def test_workstream_not_registered_until_message(self, tmp_db, mock_openai_client): session = ChatSession( client=mock_openai_client, model="test-model", @@ -315,20 +315,19 @@ class TestResumeSession: max_tokens=1000, tool_timeout=10, ) - # Session is registered in DB (resolvable) even before any messages - assert resolve_session(session._session_id) == session._session_id - # But does not appear in list_sessions until a message is saved - assert not any(r[0] == session._session_id for r in list_sessions()) + # Workstream is not auto-registered on init — only on /new or server creation + assert resolve_workstream(session._ws_id) is None + assert not any(r[0] == session._ws_id for r in list_workstreams_with_history()) -# ── save_message updates sessions.updated ───────────────────────────── +# ── save_message updates workstreams.updated ────────────────────────── -class TestSaveMessageUpdatesSession: +class TestSaveMessageUpdatesWorkstream: def test_updated_timestamp_bumped(self, tmp_db): - register_session("s1") + register_workstream("s1") save_message("s1", "user", "first") - rows = list_sessions() + rows = list_workstreams_with_history() _original_updated = rows[0][4] import time @@ -336,18 +335,18 @@ class TestSaveMessageUpdatesSession: time.sleep(0.01) # ensure different timestamp save_message("s1", "user", "hello") - rows = list_sessions() + rows = list_workstreams_with_history() new_updated = rows[0][4] # updated should be same or later (sqlite datetime resolution is seconds, # so they may be equal in fast tests — just verify no error) assert new_updated is not None -# ── Interrupted session repair ─────────────────────────────────────── +# ── Interrupted workstream repair ───────────────────────────────────── -class TestInterruptedSessionRepair: - """load_session_messages() should strip trailing incomplete tool call turns.""" +class TestInterruptedWorkstreamRepair: + """load_messages() should strip trailing incomplete tool call turns.""" def test_complete_tool_turn_preserved(self, tmp_db): """2 tool_calls + 2 tool_results = complete, no stripping.""" @@ -356,7 +355,7 @@ class TestInterruptedSessionRepair: save_message("s1", "tool_call", None, "bash", '{"command":"pwd"}', "call_2") save_message("s1", "tool_result", "file.txt", tool_call_id="call_1") save_message("s1", "tool_result", "/home", tool_call_id="call_2") - msgs = load_session_messages("s1") + msgs = load_messages("s1") assert len(msgs) == 4 # user + assistant(2 calls) + 2 tool results def test_partial_tool_results_stripped(self, tmp_db): @@ -365,7 +364,7 @@ class TestInterruptedSessionRepair: save_message("s1", "tool_call", None, "bash", '{"command":"ls"}', "call_1") save_message("s1", "tool_call", None, "bash", '{"command":"pwd"}', "call_2") save_message("s1", "tool_result", "file.txt", tool_call_id="call_1") - msgs = load_session_messages("s1") + msgs = load_messages("s1") assert len(msgs) == 1 # only user message remains assert msgs[0]["role"] == "user" @@ -375,7 +374,7 @@ class TestInterruptedSessionRepair: save_message("s1", "assistant", "Let me check") save_message("s1", "tool_call", None, "bash", '{"command":"ls"}', "call_1") save_message("s1", "tool_call", None, "bash", '{"command":"pwd"}', "call_2") - msgs = load_session_messages("s1") + msgs = load_messages("s1") # assistant with content was merged into tool_call assistant, so stripped assert len(msgs) == 1 assert msgs[0]["role"] == "user" @@ -386,42 +385,42 @@ class TestInterruptedSessionRepair: save_message("s1", "assistant", "response") save_message("s1", "user", "second") save_message("s1", "tool_call", None, "bash", '{"command":"ls"}', "call_1") - msgs = load_session_messages("s1") + msgs = load_messages("s1") assert len(msgs) == 3 # user + assistant + user (incomplete turn stripped) assert msgs[0]["role"] == "user" assert msgs[1]["role"] == "assistant" assert msgs[2]["role"] == "user" -# ── Session config persistence ─────────────────────────────────────── +# ── Workstream config persistence ───────────────────────────────────── -class TestSessionConfig: +class TestWorkstreamConfig: def test_save_load_roundtrip(self, tmp_db): config = {"temperature": "0.3", "reasoning_effort": "high", "creative_mode": "False"} - save_session_config("s1", config) - loaded = load_session_config("s1") + save_workstream_config("s1", config) + loaded = load_workstream_config("s1") assert loaded == config def test_update_existing_key(self, tmp_db): - save_session_config("s1", {"temperature": "0.3"}) - save_session_config("s1", {"temperature": "0.7"}) - loaded = load_session_config("s1") + save_workstream_config("s1", {"temperature": "0.3"}) + save_workstream_config("s1", {"temperature": "0.7"}) + loaded = load_workstream_config("s1") assert loaded["temperature"] == "0.7" - def test_missing_session_returns_empty(self, tmp_db): - loaded = load_session_config("nonexistent") + def test_missing_workstream_returns_empty(self, tmp_db): + loaded = load_workstream_config("nonexistent") assert loaded == {} - def test_delete_session_removes_config(self, tmp_db): - register_session("s1") + def test_delete_workstream_removes_config(self, tmp_db): + register_workstream("s1") save_message("s1", "user", "hi") - save_session_config("s1", {"temperature": "0.5"}) - delete_session("s1") - assert load_session_config("s1") == {} + save_workstream_config("s1", {"temperature": "0.5"}) + delete_workstream("s1") + assert load_workstream_config("s1") == {} def test_resume_restores_config(self, tmp_db): - """ChatSession.resume_session() should restore persisted config.""" + """ChatSession.resume() should restore persisted config.""" client = MagicMock() client.models.list.return_value.data = [MagicMock(id="test-model")] ui = MagicMock() @@ -430,11 +429,11 @@ class TestSessionConfig: ui.on_state_change = MagicMock() ui.on_rename = MagicMock() - # Create a session with specific config - register_session("orig") + # Create a workstream with specific config + register_workstream("orig") save_message("orig", "user", "hello") save_message("orig", "assistant", "hi there") - save_session_config( + save_workstream_config( "orig", { "temperature": "0.3", @@ -456,7 +455,7 @@ class TestSessionConfig: tool_timeout=30, ) assert session.temperature == 0.7 # default - result = session.resume_session("orig") + result = session.resume("orig") assert result is True assert session.temperature == 0.3 assert session.reasoning_effort == "high" @@ -465,84 +464,84 @@ class TestSessionConfig: assert session.creative_mode is True -# ── Prune sessions ─────────────────────────────────────────────────── +# ── Prune workstreams ───────────────────────────────────────────────── -class TestPruneSessions: +class TestPruneWorkstreams: def test_orphan_removed(self, tmp_db): - """Session registered with no messages should be pruned.""" - register_session("orphan") - orphans, stale = prune_sessions() + """Workstream registered with no messages should be pruned.""" + register_workstream("orphan") + orphans, stale = prune_workstreams() assert orphans == 1 - assert list_sessions() == [] + assert list_workstreams_with_history() == [] - def test_session_with_messages_kept(self, tmp_db): - """Session with messages should not be pruned.""" - register_session("active") + def test_workstream_with_messages_kept(self, tmp_db): + """Workstream with messages should not be pruned.""" + register_workstream("active") save_message("active", "user", "hello") - orphans, _stale = prune_sessions() + orphans, _stale = prune_workstreams() assert orphans == 0 - assert len(list_sessions()) == 1 + assert len(list_workstreams_with_history()) == 1 def test_stale_unnamed_removed(self, tmp_db): - """Old unnamed session should be pruned by retention policy.""" - register_session("old1") + """Old unnamed workstream should be pruned by retention policy.""" + register_workstream("old1") save_message("old1", "user", "ancient message") # Force the updated timestamp to the past so it looks stale engine = get_storage()._engine # noqa: SLF001 with engine.connect() as conn: conn.execute( - sa.text("UPDATE sessions SET updated = '2020-01-01' WHERE session_id = 'old1'") + sa.text("UPDATE workstreams SET updated = '2020-01-01' WHERE ws_id = 'old1'") ) conn.commit() - _orphans, stale = prune_sessions(retention_days=30) + _orphans, stale = prune_workstreams(retention_days=30) assert stale == 1 - def test_named_session_preserved(self, tmp_db): - """Session with alias should be kept regardless of age.""" - register_session("old2") - set_session_alias("old2", "important") + def test_named_workstream_preserved(self, tmp_db): + """Workstream with alias should be kept regardless of age.""" + register_workstream("old2") + set_workstream_alias("old2", "important") save_message("old2", "user", "old but named") # Force old timestamp engine = get_storage()._engine # noqa: SLF001 with engine.connect() as conn: conn.execute( - sa.text("UPDATE sessions SET updated = '2020-01-01' WHERE session_id = 'old2'") + sa.text("UPDATE workstreams SET updated = '2020-01-01' WHERE ws_id = 'old2'") ) conn.commit() - _orphans, stale = prune_sessions(retention_days=30) + _orphans, stale = prune_workstreams(retention_days=30) assert stale == 0 - assert len(list_sessions()) == 1 + assert len(list_workstreams_with_history()) == 1 def test_fresh_unnamed_preserved(self, tmp_db): - """Recent unnamed session should not be pruned.""" - register_session("fresh") + """Recent unnamed workstream should not be pruned.""" + register_workstream("fresh") save_message("fresh", "user", "just now") - _orphans, stale = prune_sessions(retention_days=30) + _orphans, stale = prune_workstreams(retention_days=30) assert stale == 0 - assert len(list_sessions()) == 1 + assert len(list_workstreams_with_history()) == 1 - def test_prune_removes_session_config(self, tmp_db): - """Pruning orphan/stale sessions should also remove their config rows.""" - register_session("orphan_cfg") - save_session_config("orphan_cfg", {"temperature": "0.5"}) + def test_prune_removes_workstream_config(self, tmp_db): + """Pruning orphan/stale workstreams should also remove their config rows.""" + register_workstream("orphan_cfg") + save_workstream_config("orphan_cfg", {"temperature": "0.5"}) - register_session("stale_cfg") + register_workstream("stale_cfg") save_message("stale_cfg", "user", "old") - save_session_config("stale_cfg", {"temperature": "0.9"}) + save_workstream_config("stale_cfg", {"temperature": "0.9"}) engine = get_storage()._engine # noqa: SLF001 with engine.connect() as conn: conn.execute( - sa.text("UPDATE sessions SET updated = '2020-01-01' WHERE session_id = 'stale_cfg'") + sa.text("UPDATE workstreams SET updated = '2020-01-01' WHERE ws_id = 'stale_cfg'") ) conn.commit() # Both should have config before prune - assert load_session_config("orphan_cfg") == {"temperature": "0.5"} - assert load_session_config("stale_cfg") == {"temperature": "0.9"} + assert load_workstream_config("orphan_cfg") == {"temperature": "0.5"} + assert load_workstream_config("stale_cfg") == {"temperature": "0.9"} - prune_sessions(retention_days=30) + prune_workstreams(retention_days=30) # Config rows should be cleaned up - assert load_session_config("orphan_cfg") == {} - assert load_session_config("stale_cfg") == {} + assert load_workstream_config("orphan_cfg") == {} + assert load_workstream_config("stale_cfg") == {} diff --git a/tests/test_storage_sqlite.py b/tests/test_storage_sqlite.py index 740e6f81..3156f9dd 100644 --- a/tests/test_storage_sqlite.py +++ b/tests/test_storage_sqlite.py @@ -14,28 +14,28 @@ def backend(tmp_path): reset_storage() -# -- Session operations -------------------------------------------------------- +# -- Workstream registration --------------------------------------------------- -class TestRegisterSession: - def test_register_creates_session(self, backend): - backend.register_session("s1", title="Test") - name = backend.get_session_name("s1") +class TestRegisterWorkstream: + def test_register_creates_workstream(self, backend): + backend.register_workstream("s1", title="Test") + name = backend.get_workstream_display_name("s1") assert name == "Test" def test_register_idempotent(self, backend): - backend.register_session("s1", title="First") - backend.register_session("s1", title="Second") - name = backend.get_session_name("s1") + backend.register_workstream("s1", title="First") + backend.register_workstream("s1", title="Second") + name = backend.get_workstream_display_name("s1") assert name == "First" # INSERT OR IGNORE preserves first class TestSaveAndLoadMessages: def test_roundtrip(self, backend): - backend.register_session("s1") + backend.register_workstream("s1") backend.save_message("s1", "user", "hello") backend.save_message("s1", "assistant", "world") - msgs = backend.load_session_messages("s1") + msgs = backend.load_messages("s1") assert len(msgs) == 2 assert msgs[0]["role"] == "user" assert msgs[0]["content"] == "hello" @@ -43,12 +43,12 @@ class TestSaveAndLoadMessages: assert msgs[1]["content"] == "world" def test_tool_call_grouping(self, backend): - backend.register_session("s1") + backend.register_workstream("s1") backend.save_message("s1", "user", "do something") backend.save_message("s1", "tool_call", None, "bash", '{"cmd":"ls"}', tool_call_id="c1") backend.save_message("s1", "tool_result", "file.txt", tool_call_id="c1") backend.save_message("s1", "assistant", "done") - msgs = backend.load_session_messages("s1") + msgs = backend.load_messages("s1") assert len(msgs) == 4 assert msgs[1]["role"] == "assistant" assert len(msgs[1]["tool_calls"]) == 1 @@ -57,136 +57,136 @@ class TestSaveAndLoadMessages: assert msgs[2]["content"] == "file.txt" def test_incomplete_turn_repair(self, backend): - backend.register_session("s1") + backend.register_workstream("s1") backend.save_message("s1", "user", "do something") backend.save_message("s1", "tool_call", None, "bash", '{"cmd":"ls"}', tool_call_id="c1") backend.save_message("s1", "tool_call", None, "read", '{"path":"a"}', tool_call_id="c2") # Only 1 result for 2 calls — incomplete turn backend.save_message("s1", "tool_result", "ok", tool_call_id="c1") - msgs = backend.load_session_messages("s1") + msgs = backend.load_messages("s1") # Incomplete turn should be stripped assert len(msgs) == 1 # only the user message remains def test_provider_data_preserved(self, backend): import json - backend.register_session("s1") + backend.register_workstream("s1") pd = json.dumps({"encrypted": True}) backend.save_message("s1", "assistant", "hi", provider_data=pd) - msgs = backend.load_session_messages("s1") + msgs = backend.load_messages("s1") assert msgs[0].get("_provider_content") == {"encrypted": True} - def test_empty_session_returns_empty(self, backend): - assert backend.load_session_messages("nonexistent") == [] + def test_empty_workstream_returns_empty(self, backend): + assert backend.load_messages("nonexistent") == [] -class TestListSessions: - def test_lists_sessions_with_messages(self, backend): - backend.register_session("s1") +class TestListWorkstreamsWithHistory: + def test_lists_workstreams_with_messages(self, backend): + backend.register_workstream("s1") backend.save_message("s1", "user", "hi") - backend.register_session("s2") # no messages - rows = backend.list_sessions() + backend.register_workstream("s2") # no messages + rows = backend.list_workstreams_with_history() assert len(rows) == 1 assert rows[0][0] == "s1" def test_respects_limit(self, backend): for i in range(5): sid = f"s{i}" - backend.register_session(sid) + backend.register_workstream(sid) backend.save_message(sid, "user", f"msg {i}") - rows = backend.list_sessions(limit=3) + rows = backend.list_workstreams_with_history(limit=3) assert len(rows) == 3 -class TestDeleteSession: +class TestDeleteWorkstream: def test_deletes_all_data(self, backend): - backend.register_session("s1") + backend.register_workstream("s1") backend.save_message("s1", "user", "hi") - backend.save_session_config("s1", {"temp": "0.5"}) - assert backend.delete_session("s1") - assert backend.load_session_messages("s1") == [] - assert backend.load_session_config("s1") == {} - assert backend.get_session_name("s1") is None + backend.save_workstream_config("s1", {"temp": "0.5"}) + assert backend.delete_workstream("s1") + assert backend.load_messages("s1") == [] + assert backend.load_workstream_config("s1") == {} + assert backend.get_workstream_display_name("s1") is None -class TestPruneSessions: +class TestPruneWorkstreams: def test_orphan_removed(self, backend): - backend.register_session("orphan") - orphans, stale = backend.prune_sessions() + backend.register_workstream("orphan") + orphans, stale = backend.prune_workstreams() assert orphans == 1 def test_stale_removed(self, backend): import sqlalchemy as sa - backend.register_session("old") + backend.register_workstream("old") backend.save_message("old", "user", "hi") # Force old timestamp with backend._engine.connect() as conn: conn.execute( - sa.text("UPDATE sessions SET updated = '2020-01-01' WHERE session_id = 'old'") + sa.text("UPDATE workstreams SET updated = '2020-01-01' WHERE ws_id = 'old'") ) conn.commit() - _, stale = backend.prune_sessions(retention_days=30) + _, stale = backend.prune_workstreams(retention_days=30) assert stale == 1 -class TestResolveSession: +class TestResolveWorkstream: def test_exact_alias(self, backend): - backend.register_session("s1") - backend.set_session_alias("s1", "myalias") - assert backend.resolve_session("myalias") == "s1" + backend.register_workstream("s1") + backend.set_workstream_alias("s1", "myalias") + assert backend.resolve_workstream("myalias") == "s1" def test_exact_id(self, backend): - backend.register_session("abc-123-def") - assert backend.resolve_session("abc-123-def") == "abc-123-def" + backend.register_workstream("abc-123-def") + assert backend.resolve_workstream("abc-123-def") == "abc-123-def" def test_prefix_match(self, backend): - backend.register_session("abc-123-def") - assert backend.resolve_session("abc") == "abc-123-def" + backend.register_workstream("abc-123-def") + assert backend.resolve_workstream("abc") == "abc-123-def" def test_not_found(self, backend): - assert backend.resolve_session("nonexistent") is None + assert backend.resolve_workstream("nonexistent") is None -# -- Session config ------------------------------------------------------------ +# -- Workstream config --------------------------------------------------------- -class TestSessionConfig: +class TestWorkstreamConfig: def test_roundtrip(self, backend): - backend.register_session("s1") - backend.save_session_config("s1", {"temperature": "0.7", "effort": "high"}) - cfg = backend.load_session_config("s1") + backend.register_workstream("s1") + backend.save_workstream_config("s1", {"temperature": "0.7", "effort": "high"}) + cfg = backend.load_workstream_config("s1") assert cfg == {"temperature": "0.7", "effort": "high"} def test_empty_config(self, backend): - assert backend.load_session_config("nonexistent") == {} + assert backend.load_workstream_config("nonexistent") == {} -# -- Session metadata ---------------------------------------------------------- +# -- Workstream metadata ------------------------------------------------------ -class TestSessionMetadata: +class TestWorkstreamMetadata: def test_alias(self, backend): - backend.register_session("s1") - assert backend.set_session_alias("s1", "my-session") - assert backend.get_session_name("s1") == "my-session" + backend.register_workstream("s1") + assert backend.set_workstream_alias("s1", "my-session") + assert backend.get_workstream_display_name("s1") == "my-session" def test_alias_conflict(self, backend): - backend.register_session("s1") - backend.register_session("s2") - backend.set_session_alias("s1", "taken") - assert not backend.set_session_alias("s2", "taken") + backend.register_workstream("s1") + backend.register_workstream("s2") + backend.set_workstream_alias("s1", "taken") + assert not backend.set_workstream_alias("s2", "taken") def test_title(self, backend): - backend.register_session("s1") - backend.update_session_title("s1", "My Title") - assert backend.get_session_name("s1") == "My Title" + backend.register_workstream("s1") + backend.update_workstream_title("s1", "My Title") + assert backend.get_workstream_display_name("s1") == "My Title" def test_alias_preferred_over_title(self, backend): - backend.register_session("s1") - backend.update_session_title("s1", "Title") - backend.set_session_alias("s1", "Alias") - assert backend.get_session_name("s1") == "Alias" + backend.register_workstream("s1") + backend.update_workstream_title("s1", "Title") + backend.set_workstream_alias("s1", "Alias") + assert backend.get_workstream_display_name("s1") == "Alias" # -- Key-value store ----------------------------------------------------------- @@ -234,7 +234,7 @@ class TestKVStore: class TestSearch: def test_search_history(self, backend): - backend.register_session("s1") + backend.register_workstream("s1") backend.save_message("s1", "user", "hello world") backend.save_message("s1", "user", "goodbye world") results = backend.search_history("hello") @@ -242,7 +242,7 @@ class TestSearch: assert any("hello" in str(r[3]) for r in results) def test_search_recent(self, backend): - backend.register_session("s1") + backend.register_workstream("s1") backend.save_message("s1", "user", "msg1") backend.save_message("s1", "user", "msg2") results = backend.search_history_recent(limit=1) @@ -293,15 +293,14 @@ class TestWorkstreams: assert len(rows) == 1 assert rows[0][0] == "ws1" - def test_session_with_ws_id(self, backend): + def test_workstream_with_messages_in_history(self, backend): backend.register_workstream("ws1", node_id="node-a") - backend.register_session("s1", node_id="node-a", ws_id="ws1") - backend.save_message("s1", "user", "hello") - rows = backend.list_sessions() + backend.save_message("ws1", "user", "hello") + rows = backend.list_workstreams_with_history() assert len(rows) == 1 - # Columns: sid, alias, title, created, updated, count, node_id, ws_id + # Columns: ws_id, alias, title, created, updated, count, node_id + assert rows[0][0] == "ws1" assert rows[0][6] == "node-a" - assert rows[0][7] == "ws1" # -- Lifecycle ----------------------------------------------------------------- diff --git a/tests/test_user_storage.py b/tests/test_user_storage.py index c7f0fb46..7ef3dec4 100644 --- a/tests/test_user_storage.py +++ b/tests/test_user_storage.py @@ -127,21 +127,7 @@ class TestApiTokenCRUD: assert "expires" not in tok -class TestSessionWorkstreamUserId: - def test_register_session_with_user_id(self, db): - db.register_session("s1", user_id="u1") - # Verify via raw SQL that user_id is stored - import sqlalchemy as sa - - from turnstone.core.storage._schema import sessions - - with db._engine.connect() as conn: - row = conn.execute( - sa.select(sessions.c.user_id).where(sessions.c.session_id == "s1") - ).fetchone() - assert row is not None - assert row[0] == "u1" - +class TestWorkstreamUserId: def test_register_workstream_with_user_id(self, db): db.register_workstream("ws1", user_id="u1") import sqlalchemy as sa @@ -155,15 +141,15 @@ class TestSessionWorkstreamUserId: assert row is not None assert row[0] == "u1" - def test_register_session_without_user_id(self, db): - db.register_session("s1") + def test_register_workstream_without_user_id(self, db): + db.register_workstream("ws1") import sqlalchemy as sa - from turnstone.core.storage._schema import sessions + from turnstone.core.storage._schema import workstreams with db._engine.connect() as conn: row = conn.execute( - sa.select(sessions.c.user_id).where(sessions.c.session_id == "s1") + sa.select(workstreams.c.user_id).where(workstreams.c.ws_id == "ws1") ).fetchone() assert row is not None assert row[0] is None diff --git a/turnstone/api/server_schemas.py b/turnstone/api/server_schemas.py index b18be769..f66ec8df 100644 --- a/turnstone/api/server_schemas.py +++ b/turnstone/api/server_schemas.py @@ -39,18 +39,19 @@ class CreateWorkstreamRequest(BaseModel): name: str = Field(default="", description="Workstream display name (auto-generated if empty)") model: str = Field(default="", description="Model alias from registry") auto_approve: bool = Field(default=False, description="Auto-approve all tool calls") - resume_session: str = Field( + resume_ws: str = Field( default="", - description="Session ID to resume atomically during creation (empty = fresh start)", + description="Workstream ID to resume atomically during creation (empty = fresh start)", ) class CreateWorkstreamResponse(BaseModel): ws_id: str = Field(description="Unique ID of the new workstream") name: str = Field(description="Assigned workstream name") - resumed: bool = Field(default=False, description="Whether a previous session was resumed") - session_id: str = Field(default="", description="Resolved session ID (set when resumed)") - message_count: int = Field(default=0, description="Number of messages in the resumed session") + resumed: bool = Field(default=False, description="Whether a previous workstream was resumed") + message_count: int = Field( + default=0, description="Number of messages in the resumed workstream" + ) class CloseWorkstreamRequest(BaseModel): @@ -66,7 +67,6 @@ class WorkstreamInfo(BaseModel): id: str name: str state: str - session_id: str | None = None class ListWorkstreamsResponse(BaseModel): @@ -77,7 +77,6 @@ class DashboardWorkstream(BaseModel): id: str name: str state: str - session_id: str | None = None title: str = "" tokens: int = 0 context_ratio: float = 0.0 @@ -104,12 +103,12 @@ class DashboardResponse(BaseModel): # --------------------------------------------------------------------------- -# Sessions +# Saved workstreams # --------------------------------------------------------------------------- -class SessionInfo(BaseModel): - session_id: str +class SavedWorkstreamInfo(BaseModel): + ws_id: str alias: str | None = None title: str | None = None created: str @@ -117,8 +116,8 @@ class SessionInfo(BaseModel): message_count: int -class ListSessionsResponse(BaseModel): - sessions: list[SessionInfo] +class ListSavedWorkstreamsResponse(BaseModel): + workstreams: list[SavedWorkstreamInfo] # --------------------------------------------------------------------------- diff --git a/turnstone/api/server_spec.py b/turnstone/api/server_spec.py index e0416383..7bf9bef7 100644 --- a/turnstone/api/server_spec.py +++ b/turnstone/api/server_spec.py @@ -25,7 +25,7 @@ from turnstone.api.server_schemas import ( CreateWorkstreamResponse, DashboardResponse, HealthResponse, - ListSessionsResponse, + ListSavedWorkstreamsResponse, ListWorkstreamsResponse, PlanFeedbackRequest, SendRequest, @@ -122,13 +122,13 @@ SERVER_ENDPOINTS: list[EndpointSpec] = [ "across all workstreams. Returns text/event-stream.", tags=["Streaming"], ), - # --- Sessions --- + # --- Saved workstreams --- EndpointSpec( - "/v1/api/sessions", + "/v1/api/workstreams/saved", "GET", - "List saved sessions", - response_model=ListSessionsResponse, - tags=["Sessions"], + "List saved workstreams", + response_model=ListSavedWorkstreamsResponse, + tags=["Workstreams"], ), # --- Auth --- EndpointSpec( @@ -191,7 +191,7 @@ _ALL_MODELS: list[type[BaseModel]] = [ CloseWorkstreamRequest, ListWorkstreamsResponse, DashboardResponse, - ListSessionsResponse, + ListSavedWorkstreamsResponse, HealthResponse, ] diff --git a/turnstone/channels/_routing.py b/turnstone/channels/_routing.py index 4ef16247..97a9ec07 100644 --- a/turnstone/channels/_routing.py +++ b/turnstone/channels/_routing.py @@ -150,7 +150,7 @@ class ChannelRouter: owner = await self._broker.get_ws_owner(route["ws_id"]) if owner: return route["ws_id"], False - # Workstream was evicted/closed — capture old ws_id for session + # Workstream was evicted/closed — capture old ws_id for # resume, then remove the stale route. old_ws_id = route["ws_id"] await asyncio.to_thread( @@ -163,20 +163,13 @@ class ChannelRouter: channel_id=channel_id, ) - # 2. Look up old session for atomic resume (if stale route). - resume_session = "" - if old_ws_id: - old_sid: str | None = await asyncio.to_thread( - self._storage.get_session_id_by_ws, old_ws_id - ) - resume_session = old_sid or "" - - # 3. Create via MQ with atomic resume. + # 2. Create via MQ with atomic resume (reuse old ws_id directly). + resume_ws = old_ws_id or "" msg = CreateWorkstreamMessage( name=name, model=model, - initial_message="" if resume_session else initial_message, - resume_session=resume_session, + initial_message="" if resume_ws else initial_message, + resume_ws=resume_ws, auto_approve=self._auto_approve, auto_approve_tools=list(self._auto_approve_tools), ) @@ -190,7 +183,7 @@ class ChannelRouter: correlation_id=cid, channel_type=channel_type, channel_id=channel_id, - resume_session=resume_session or None, + resume_ws=resume_ws or None, ) try: diff --git a/turnstone/channels/discord/bot.py b/turnstone/channels/discord/bot.py index 5f5b5f4e..3476fa60 100644 --- a/turnstone/channels/discord/bot.py +++ b/turnstone/channels/discord/bot.py @@ -21,8 +21,8 @@ from turnstone.mq.protocol import ( ErrorEvent, OutboundEvent, PlanReviewEvent, - SessionResumedEvent, TurnCompleteEvent, + WorkstreamResumedEvent, ) if TYPE_CHECKING: @@ -307,10 +307,10 @@ class TurnstoneBot: if sm is not None: await sm.finalize() - elif isinstance(event, SessionResumedEvent): - name = event.name or "previous session" + elif isinstance(event, WorkstreamResumedEvent): + name = event.name or "previous workstream" count = event.message_count - await thread.send(f"*Session resumed: {name} ({count} messages restored)*") + await thread.send(f"*Resumed: {name} ({count} messages restored)*") elif isinstance(event, ErrorEvent): safe_msg = event.message[:500] if event.message else "An error occurred" diff --git a/turnstone/cli.py b/turnstone/cli.py index 5b08c0ec..c48248b2 100644 --- a/turnstone/cli.py +++ b/turnstone/cli.py @@ -41,7 +41,7 @@ SLASH_COMMANDS = [ "/instructions", "/clear", "/new", - "/sessions", + "/workstreams", "/resume", "/name", "/delete", @@ -787,8 +787,8 @@ def main() -> None: parser.add_argument( "--resume", default=None, - metavar="SESSION", - help="Resume a previous session by alias or session_id", + metavar="WS", + help="Resume a previous workstream by alias or ws_id", ) parser.add_argument( "--skip-permissions", @@ -801,11 +801,11 @@ def main() -> None: help="API key (default: $OPENAI_API_KEY, or 'dummy' for local servers)", ) parser.add_argument( - "--session-retention-days", + "--retention-days", type=int, default=90, metavar="DAYS", - help="Delete unnamed sessions older than DAYS days on startup, 0 to disable (default: 90)", + help="Delete unnamed workstreams older than DAYS days on startup, 0 to disable (default: 90)", ) parser.add_argument( "--console-url", @@ -845,10 +845,10 @@ def main() -> None: ) init_storage(db_backend, path=db_path, url=db_url, pool_size=db_pool_size) - # Prune stale / empty sessions on startup - from turnstone.core.memory import prune_sessions + # Prune stale / empty workstreams on startup + from turnstone.core.memory import prune_workstreams - prune_sessions(retention_days=args.session_retention_days, log_fn=print) + prune_workstreams(retention_days=args.retention_days, log_fn=print) # Set up readline setup_readline() @@ -894,7 +894,7 @@ def main() -> None: mcp_client = create_mcp_client(getattr(args, "mcp_config", None)) - # Session factory — captures shared config for creating workstream sessions + # ChatSession factory — captures shared config for creating workstreams def session_factory( ui: SessionUI | None, model_alias: str | None = None, ws_id: str | None = None ) -> ChatSession: @@ -929,19 +929,19 @@ def main() -> None: # Handle --resume if args.resume: - from turnstone.core.memory import resolve_session + from turnstone.core.memory import resolve_workstream - target_id = resolve_session(args.resume) + target_id = resolve_workstream(args.resume) if not target_id: - print(red(f"Session not found: {args.resume}")) + print(red(f"Workstream not found: {args.resume}")) sys.exit(1) if ws.session is None: print(red("No session available.")) sys.exit(1) - if not ws.session.resume_session(target_id): - print(red(f"Session '{args.resume}' has no messages.")) + if not ws.session.resume(target_id): + print(red(f"Workstream '{args.resume}' has no messages.")) sys.exit(1) - print(f"Resumed session {bold(target_id)} ({len(ws.session.messages)} messages)") + print(f"Resumed workstream {bold(target_id)} ({len(ws.session.messages)} messages)") # Background attention notification — write to stderr while user types def _bg_attention_notify(ws_id: str, state: WorkstreamState) -> None: diff --git a/turnstone/core/config.py b/turnstone/core/config.py index 6a761e05..ccc7dd52 100644 --- a/turnstone/core/config.py +++ b/turnstone/core/config.py @@ -61,7 +61,7 @@ _CONFIG_MAP: dict[str, dict[str, str]] = { }, "session": { "instructions": "instructions", - "retention_days": "session_retention_days", + "retention_days": "retention_days", "compact_max_tokens": "compact_max_tokens", "auto_compact_pct": "auto_compact_pct", }, diff --git a/turnstone/core/memory.py b/turnstone/core/memory.py index b9d11b88..4df7af6e 100644 --- a/turnstone/core/memory.py +++ b/turnstone/core/memory.py @@ -21,22 +21,11 @@ def normalize_key(key: str) -> str: return key.lower().replace("-", "_").replace(" ", "_") -# -- Core session operations --------------------------------------------------- - - -def register_session( - session_id: str, - title: str | None = None, - node_id: str | None = None, - ws_id: str | None = None, -) -> None: - """Create a sessions row for a new session (no-op if already exists).""" - with contextlib.suppress(Exception): - get_storage().register_session(session_id, title, node_id=node_id, ws_id=ws_id) +# -- Core conversation operations --------------------------------------------- def save_message( - session_id: str, + ws_id: str, role: str, content: str | None, tool_name: str | None = None, @@ -47,111 +36,19 @@ def save_message( """Log a message to the conversations table.""" with contextlib.suppress(Exception): get_storage().save_message( - session_id, role, content, tool_name, tool_args, tool_call_id, provider_data + ws_id, role, content, tool_name, tool_args, tool_call_id, provider_data ) -def load_session_messages(session_id: str) -> list[dict[str, Any]]: - """Load messages for a session and reconstruct OpenAI message format.""" +def load_messages(ws_id: str) -> list[dict[str, Any]]: + """Load messages for a workstream and reconstruct OpenAI message format.""" try: - return get_storage().load_session_messages(session_id) + return get_storage().load_messages(ws_id) except Exception: return [] -# -- Session management -------------------------------------------------------- - - -def list_sessions(limit: int = 20) -> list[Any]: - """List recent sessions with message counts.""" - try: - return get_storage().list_sessions(limit) - except Exception: - return [] - - -def delete_session(session_id: str) -> bool: - """Delete a session and all its messages.""" - try: - return get_storage().delete_session(session_id) - except Exception: - return False - - -def prune_sessions( - retention_days: int = 90, - log_fn: Callable[[str], None] | None = None, -) -> tuple[int, int]: - """Prune orphaned and stale sessions.""" - try: - orphans, stale = get_storage().prune_sessions(retention_days) - except Exception: - return (0, 0) - - if log_fn and (orphans or stale): - parts = [] - if orphans: - parts.append(f"{orphans} empty session{'s' if orphans != 1 else ''}") - if stale: - parts.append( - f"{stale} session{'s' if stale != 1 else ''} older than {retention_days} days" - ) - log_fn(f"[turnstone] Session cleanup: removed {', '.join(parts)}.") - - return (orphans, stale) - - -def resolve_session(alias_or_id: str) -> str | None: - """Resolve an alias or session_id (or prefix) to a full session_id.""" - try: - return get_storage().resolve_session(alias_or_id) - except Exception: - return None - - -# -- Session config ------------------------------------------------------------ - - -def save_session_config(session_id: str, config: dict[str, str]) -> None: - """Persist session configuration key/value pairs.""" - with contextlib.suppress(Exception): - get_storage().save_session_config(session_id, config) - - -def load_session_config(session_id: str) -> dict[str, str]: - """Load session configuration.""" - try: - return get_storage().load_session_config(session_id) - except Exception: - return {} - - -# -- Session metadata ---------------------------------------------------------- - - -def set_session_alias(session_id: str, alias: str) -> bool: - """Set a human-friendly alias. Returns False if alias is taken.""" - try: - return get_storage().set_session_alias(session_id, alias) - except Exception: - return False - - -def get_session_name(session_id: str) -> str | None: - """Return the alias (or title) for a session, or None if unset.""" - try: - return get_storage().get_session_name(session_id) - except Exception: - return None - - -def update_session_title(session_id: str, title: str) -> None: - """Set or update the auto-generated title for a session.""" - with contextlib.suppress(Exception): - get_storage().update_session_title(session_id, title) - - -# -- Workstream operations ----------------------------------------------------- +# -- Workstream management ---------------------------------------------------- def register_workstream( @@ -182,6 +79,95 @@ def list_workstreams(node_id: str | None = None, limit: int = 100) -> list[Any]: return [] +def list_workstreams_with_history(limit: int = 20) -> list[Any]: + """List workstreams that have conversation messages.""" + try: + return get_storage().list_workstreams_with_history(limit) + except Exception: + return [] + + +def delete_workstream(ws_id: str) -> bool: + """Delete a workstream and all its conversations + config.""" + try: + return get_storage().delete_workstream(ws_id) + except Exception: + return False + + +def prune_workstreams( + retention_days: int = 90, + log_fn: Callable[[str], None] | None = None, +) -> tuple[int, int]: + """Prune orphaned and stale workstreams.""" + try: + orphans, stale = get_storage().prune_workstreams(retention_days) + except Exception: + return (0, 0) + + if log_fn and (orphans or stale): + parts = [] + if orphans: + parts.append(f"{orphans} empty workstream{'s' if orphans != 1 else ''}") + if stale: + parts.append( + f"{stale} workstream{'s' if stale != 1 else ''} older than {retention_days} days" + ) + log_fn(f"[turnstone] Cleanup: removed {', '.join(parts)}.") + + return (orphans, stale) + + +def resolve_workstream(alias_or_id: str) -> str | None: + """Resolve an alias or ws_id (or prefix) to a full ws_id.""" + try: + return get_storage().resolve_workstream(alias_or_id) + except Exception: + return None + + +# -- Workstream config -------------------------------------------------------- + + +def save_workstream_config(ws_id: str, config: dict[str, str]) -> None: + """Persist workstream configuration key/value pairs.""" + with contextlib.suppress(Exception): + get_storage().save_workstream_config(ws_id, config) + + +def load_workstream_config(ws_id: str) -> dict[str, str]: + """Load workstream configuration.""" + try: + return get_storage().load_workstream_config(ws_id) + except Exception: + return {} + + +# -- Workstream metadata ------------------------------------------------------ + + +def set_workstream_alias(ws_id: str, alias: str) -> bool: + """Set a human-friendly alias. Returns False if alias is taken.""" + try: + return get_storage().set_workstream_alias(ws_id, alias) + except Exception: + return False + + +def get_workstream_display_name(ws_id: str) -> str | None: + """Return the alias (or title) for a workstream, or None if unset.""" + try: + return get_storage().get_workstream_display_name(ws_id) + except Exception: + return None + + +def update_workstream_title(ws_id: str, title: str) -> None: + """Set or update the auto-generated title for a workstream.""" + with contextlib.suppress(Exception): + get_storage().update_workstream_title(ws_id, title) + + # -- Key-value store (memories) ------------------------------------------------ diff --git a/turnstone/core/metrics.py b/turnstone/core/metrics.py index ac76a8a5..788d65aa 100644 --- a/turnstone/core/metrics.py +++ b/turnstone/core/metrics.py @@ -259,19 +259,13 @@ class MetricsCollector: # Per-workstream metrics (only when data is provided) if workstream_metrics: - # turnstone_workstream_info — exposes session_id as a label for joining, - # without propagating that high-cardinality label to counters. - lines.append( - "# HELP turnstone_workstream_info Workstream metadata" - " (join on session_id for per-session queries)" - ) + lines.append("# HELP turnstone_workstream_info Workstream metadata") lines.append("# TYPE turnstone_workstream_info gauge") for wm in workstream_metrics: lstr = _fmt_labels( { "ws_id": wm["ws_id"], "name": wm["name"], - "session_id": wm["session_id"], } ) lines.append(f"turnstone_workstream_info{lstr} 1") diff --git a/turnstone/core/session.py b/turnstone/core/session.py index 2080ea9e..39f98d70 100644 --- a/turnstone/core/session.py +++ b/turnstone/core/session.py @@ -29,23 +29,22 @@ from turnstone.core.edit import find_occurrences, pick_nearest from turnstone.core.log import get_logger from turnstone.core.memory import ( delete_memory, - delete_session, - get_session_name, - list_sessions, + delete_workstream, + get_workstream_display_name, + list_workstreams_with_history, load_memories, - load_session_config, - load_session_messages, + load_messages, + load_workstream_config, normalize_key, - register_session, - resolve_session, + resolve_workstream, save_memory, save_message, - save_session_config, + save_workstream_config, search_history, search_history_recent, search_memories, - set_session_alias, - update_session_title, + set_workstream_alias, + update_workstream_title, ) from turnstone.core.providers import create_provider from turnstone.core.safety import is_command_blocked, sanitize_command @@ -192,10 +191,8 @@ class ChatSession: self.debug = False self.auto_approve = False self._node_id = node_id - self._ws_id = ws_id - self._session_id = uuid.uuid4().hex + self._ws_id = ws_id or uuid.uuid4().hex self._title_generated = False - register_session(self._session_id, node_id=self._node_id, ws_id=self._ws_id) self._read_files: set[str] = set() self.messages: list[dict[str, Any]] = [] self._last_usage: dict[str, int] | None = None @@ -219,17 +216,17 @@ class ChatSession: self._save_config() @property - def session_id(self) -> str: - return self._session_id + def ws_id(self) -> str: + return self._ws_id @property def model_alias(self) -> str | None: return self._model_alias def _save_config(self) -> None: - """Persist LLM-affecting config so resumed sessions behave identically.""" - save_session_config( - self._session_id, + """Persist LLM-affecting config so resumed workstreams behave identically.""" + save_workstream_config( + self._ws_id, { "temperature": str(self.temperature), "reasoning_effort": self.reasoning_effort, @@ -298,32 +295,32 @@ class ChatSession: # Take first line, strip quotes title = raw.split("\n")[0].strip().strip('"').strip("'") if title: - update_session_title(self._session_id, title[:80]) + update_workstream_title(self._ws_id, title[:80]) except Exception: pass # Title generation is non-critical - def resume_session(self, session_id: str) -> bool: - """Load messages from a previous session and resume it. + def resume(self, ws_id: str) -> bool: + """Load messages from a previous workstream and resume it. Replaces the current conversation with the loaded messages, - adopting the old session_id so new messages continue in the same - session. Restores persisted config (temperature, reasoning_effort, - etc.) so the resumed session behaves identically to the original. + adopting the old ws_id so new messages continue in the same + workstream. Restores persisted config (temperature, reasoning_effort, + etc.) so the resumed workstream behaves identically to the original. Returns True on success. """ - messages = load_session_messages(session_id) + messages = load_messages(ws_id) if not messages: return False - self._session_id = session_id + self._ws_id = ws_id self.messages = messages self._read_files.clear() self._last_usage = None - self._title_generated = True # don't re-title resumed sessions + self._title_generated = True # don't re-title resumed workstreams self._msg_tokens = [ max(1, int(self._msg_char_count(m) / self._chars_per_token)) for m in self.messages ] # Restore persisted config - config = load_session_config(session_id) + config = load_workstream_config(ws_id) if config: if "temperature" in config: self.temperature = float(config["temperature"]) @@ -513,7 +510,7 @@ class ChatSession: self._notify_count = 0 self.messages.append({"role": "user", "content": user_input}) self._msg_tokens.append(max(1, int(len(user_input) / self._chars_per_token))) - save_message(self._session_id, "user", user_input) + save_message(self._ws_id, "user", user_input) try: while True: @@ -549,9 +546,7 @@ class ChatSession: provider_data = _json.dumps(assistant_msg["_provider_content"]) if content or provider_data is not None: - save_message( - self._session_id, "assistant", content, provider_data=provider_data - ) + save_message(self._ws_id, "assistant", content, provider_data=provider_data) if tc: for call in tc: fn = call.get("function", {}) @@ -562,7 +557,7 @@ class ChatSession: "recall", ): save_message( - self._session_id, + self._ws_id, "tool_call", None, name, @@ -612,7 +607,7 @@ class ChatSession: "recall", ): save_message( - self._session_id, + self._ws_id, "tool_result", output[:2000], _tname, @@ -2327,9 +2322,9 @@ class ChatSession: ) def _exec_plan(self, item: dict[str, Any]) -> tuple[str, str]: - """Run a planning agent and write the result to .plan-.md.""" + """Run a planning agent and write the result to .plan-.md.""" call_id, prompt = item["call_id"], item["prompt"] - plan_path = f".plan-{self._session_id}.md" + plan_path = f".plan-{self._ws_id}.md" # If plan was called before in this session, the previous assistant # tool_call + tool result are already in self.messages — pass them @@ -2908,29 +2903,31 @@ class ChatSession: self._read_files.clear() self._last_usage = None self._msg_tokens = [] - self.ui.on_info("Context cleared (session preserved in database).") + self.ui.on_info("Context cleared (messages preserved in database).") elif cmd == "/new": + from turnstone.core.memory import register_workstream + self.messages.clear() self._read_files.clear() self._last_usage = None self._msg_tokens = [] - self._session_id = uuid.uuid4().hex + self._ws_id = uuid.uuid4().hex self._title_generated = False - register_session(self._session_id, node_id=self._node_id, ws_id=self._ws_id) + register_workstream(self._ws_id, node_id=self._node_id) self._save_config() - self.ui.on_info("New session started.") + self.ui.on_info("New workstream started.") - elif cmd == "/sessions": - rows = list_sessions(limit=20) + elif cmd == "/workstreams": + rows = list_workstreams_with_history(limit=20) if not rows: - self.ui.on_info("No saved sessions.") + self.ui.on_info("No saved workstreams.") else: - lines = ["Sessions:\n"] - for sid, alias, title, _created, updated, count, *_extra in rows: - display_name = alias or sid + lines = ["Workstreams:\n"] + for wid, alias, title, _created, updated, count, *_extra in rows: + display_name = alias or wid display_title = f" {title}" if title else "" - marker = " *" if sid == self._session_id else " " + marker = " *" if wid == self._ws_id else " " lines.append( f" {marker} {bold(display_name)}{display_title} " f"{dim(f'{count} msgs, {updated}')}" @@ -2940,30 +2937,29 @@ class ChatSession: elif cmd == "/resume": if not arg: self.ui.on_info( - "Usage: /resume \n" - "Use /sessions to list available sessions." + "Usage: /resume \nUse /workstreams to list available workstreams." ) else: - target_id = resolve_session(arg.strip()) + target_id = resolve_workstream(arg.strip()) if not target_id: - self.ui.on_info(f"Session not found: {arg.strip()}") - elif target_id == self._session_id: - self.ui.on_info("Already in that session.") - elif self.resume_session(target_id): + self.ui.on_info(f"Workstream not found: {arg.strip()}") + elif target_id == self._ws_id: + self.ui.on_info("Already in that workstream.") + elif self.resume(target_id): self.ui.on_info( - f"Resumed session {bold(target_id)} ({len(self.messages)} messages loaded)" + f"Resumed {bold(target_id)} ({len(self.messages)} messages loaded)" ) - name = get_session_name(target_id) + name = get_workstream_display_name(target_id) if name: self.ui.on_rename(name) else: - self.ui.on_info(f"Session {arg.strip()} has no messages.") + self.ui.on_info(f"Workstream {arg.strip()} has no messages.") elif cmd == "/name": if not arg: - self.ui.on_info(f"Current session: {self._session_id}") - elif set_session_alias(self._session_id, arg.strip()): - self.ui.on_info(f"Session named: {bold(arg.strip())}") + self.ui.on_info(f"Current workstream: {self._ws_id}") + elif set_workstream_alias(self._ws_id, arg.strip()): + self.ui.on_info(f"Workstream named: {bold(arg.strip())}") self.ui.on_rename(arg.strip()) else: self.ui.on_info(f"Alias '{arg.strip()}' is already in use.") @@ -2971,18 +2967,18 @@ class ChatSession: elif cmd == "/delete": if not arg: self.ui.on_info( - "Usage: /delete \nUse /sessions to list sessions." + "Usage: /delete \nUse /workstreams to list workstreams." ) else: - target_id = resolve_session(arg.strip()) + target_id = resolve_workstream(arg.strip()) if not target_id: - self.ui.on_info(f"Session not found: {arg.strip()}") - elif target_id == self._session_id: - self.ui.on_info("Cannot delete the active session.") - elif delete_session(target_id): - self.ui.on_info(f"Deleted session {arg.strip()}") + self.ui.on_info(f"Workstream not found: {arg.strip()}") + elif target_id == self._ws_id: + self.ui.on_info("Cannot delete the active workstream.") + elif delete_workstream(target_id): + self.ui.on_info(f"Deleted workstream {arg.strip()}") else: - self.ui.on_info(f"Failed to delete session {arg.strip()}") + self.ui.on_info(f"Failed to delete workstream {arg.strip()}") elif cmd == "/history": query = arg.strip() if arg else None @@ -3110,13 +3106,13 @@ class ChatSession: [ "── Slash Commands ─────────────────────────────────────", " /instructions Set developer instructions", - " /clear Clear context (session preserved in database)", - " /new Start a new session (old session stays resumable)", + " /clear Clear context (workstream preserved in database)", + " /new Start a new workstream (old one stays resumable)", "", - " /sessions List saved sessions", - " /resume Resume a previous session", - " /name Name the current session", - " /delete Delete a saved session", + " /workstreams List saved workstreams", + " /resume Resume a previous workstream", + " /name Name the current workstream", + " /delete Delete a saved workstream", "", " /history [query] Search conversation history (or show recent)", " /compact Compact conversation (summarize old messages)", diff --git a/turnstone/core/storage/_postgresql.py b/turnstone/core/storage/_postgresql.py index 98dbc248..01a54bf5 100644 --- a/turnstone/core/storage/_postgresql.py +++ b/turnstone/core/storage/_postgresql.py @@ -13,9 +13,8 @@ from turnstone.core.storage._schema import ( conversations, memories, metadata, - session_config, - sessions, users, + workstream_config, workstreams, ) from turnstone.core.storage._sqlite import _reconstruct_messages @@ -38,40 +37,11 @@ class PostgreSQLBackend: if create_tables: metadata.create_all(self._engine) - # -- Core session operations ----------------------------------------------- - - def register_session( - self, - session_id: str, - title: str | None = None, - node_id: str | None = None, - ws_id: str | None = None, - user_id: str | None = None, - ) -> None: - now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S") - with self._engine.connect() as conn: - # Use dialect-neutral upsert pattern - existing = conn.execute( - sa.select(sessions.c.session_id).where(sessions.c.session_id == session_id) - ).fetchone() - if not existing: - conn.execute( - sa.insert(sessions), - { - "session_id": session_id, - "title": title, - "node_id": node_id, - "ws_id": ws_id, - "user_id": user_id, - "created": now, - "updated": now, - }, - ) - conn.commit() + # -- Core conversation operations ------------------------------------------ def save_message( self, - session_id: str, + ws_id: str, role: str, content: str | None, tool_name: str | None = None, @@ -84,7 +54,7 @@ class PostgreSQLBackend: conn.execute( sa.insert(conversations), { - "session_id": session_id, + "ws_id": ws_id, "timestamp": now, "role": role, "content": content, @@ -95,11 +65,11 @@ class PostgreSQLBackend: }, ) conn.execute( - sa.update(sessions).where(sessions.c.session_id == session_id).values(updated=now) + sa.update(workstreams).where(workstreams.c.ws_id == ws_id).values(updated=now) ) conn.commit() - def load_session_messages(self, session_id: str) -> list[dict[str, Any]]: + def load_messages(self, ws_id: str) -> list[dict[str, Any]]: with self._engine.connect() as conn: rows = conn.execute( sa.select( @@ -110,174 +80,149 @@ class PostgreSQLBackend: conversations.c.tool_call_id, conversations.c.provider_data, ) - .where(conversations.c.session_id == session_id) + .where(conversations.c.ws_id == ws_id) .order_by(conversations.c.id) ).fetchall() - return _reconstruct_messages(list(rows), session_id) + return _reconstruct_messages(list(rows), ws_id) - # -- Session management ---------------------------------------------------- + # -- Workstream management ------------------------------------------------- - def list_sessions(self, limit: int = 20) -> list[Any]: + def list_workstreams_with_history(self, limit: int = 20) -> list[Any]: with self._engine.connect() as conn: return list( conn.execute( sa.text( - "SELECT s.session_id, s.alias, s.title, s.created, s.updated, " + "SELECT w.ws_id, w.alias, w.title, w.created, w.updated, " "(SELECT COUNT(*) FROM conversations c " - " WHERE c.session_id = s.session_id), " - "s.node_id, s.ws_id " - "FROM sessions s " + " WHERE c.ws_id = w.ws_id), " + "w.node_id " + "FROM workstreams w " "WHERE EXISTS " - " (SELECT 1 FROM conversations c WHERE c.session_id = s.session_id) " - "ORDER BY s.updated DESC LIMIT :limit" + " (SELECT 1 FROM conversations c WHERE c.ws_id = w.ws_id) " + "ORDER BY w.updated DESC LIMIT :limit" ), {"limit": limit}, ).fetchall() ) - def delete_session(self, session_id: str) -> bool: - with self._engine.connect() as conn: - conn.execute(sa.delete(conversations).where(conversations.c.session_id == session_id)) - conn.execute(sa.delete(session_config).where(session_config.c.session_id == session_id)) - conn.execute(sa.delete(sessions).where(sessions.c.session_id == session_id)) - conn.commit() - return True - - def prune_sessions(self, retention_days: int = 90) -> tuple[int, int]: + def prune_workstreams(self, retention_days: int = 90) -> tuple[int, int]: orphans = stale = 0 with self._engine.connect() as conn: - # 1. Remove sessions with no messages + # 1. Remove workstreams with no messages orphan_rows = conn.execute( sa.text( - "SELECT session_id FROM sessions " + "SELECT ws_id FROM workstreams " "WHERE NOT EXISTS " " (SELECT 1 FROM conversations c " - " WHERE c.session_id = sessions.session_id)" + " WHERE c.ws_id = workstreams.ws_id)" ) ).fetchall() orphan_ids = [r[0] for r in orphan_rows] if orphan_ids: conn.execute( - sa.delete(session_config).where(session_config.c.session_id.in_(orphan_ids)) + sa.delete(workstream_config).where(workstream_config.c.ws_id.in_(orphan_ids)) ) result = conn.execute( - sa.delete(sessions).where(sessions.c.session_id.in_(orphan_ids)) + sa.delete(workstreams).where(workstreams.c.ws_id.in_(orphan_ids)) ) orphans = result.rowcount - # 2. Remove old unnamed sessions + # 2. Remove old unnamed workstreams if retention_days > 0: cutoff = (datetime.now(UTC) - timedelta(days=retention_days)).strftime( "%Y-%m-%dT%H:%M:%S" ) stale_rows = conn.execute( - sa.select(sessions.c.session_id).where( - sessions.c.alias.is_(None), - sessions.c.updated < cutoff, + sa.select(workstreams.c.ws_id).where( + workstreams.c.alias.is_(None), + workstreams.c.updated < cutoff, ) ).fetchall() stale_ids = [r[0] for r in stale_rows] if stale_ids: conn.execute( - sa.delete(session_config).where(session_config.c.session_id.in_(stale_ids)) + sa.delete(conversations).where(conversations.c.ws_id.in_(stale_ids)) + ) + conn.execute( + sa.delete(workstream_config).where(workstream_config.c.ws_id.in_(stale_ids)) ) result = conn.execute( - sa.delete(sessions).where(sessions.c.session_id.in_(stale_ids)) + sa.delete(workstreams).where(workstreams.c.ws_id.in_(stale_ids)) ) stale = result.rowcount conn.commit() return (orphans, stale) - def resolve_session(self, alias_or_id: str) -> str | None: + def resolve_workstream(self, alias_or_id: str) -> str | None: with self._engine.connect() as conn: # 1. Exact alias row = conn.execute( - sa.select(sessions.c.session_id).where(sessions.c.alias == alias_or_id) + sa.select(workstreams.c.ws_id).where(workstreams.c.alias == alias_or_id) ).fetchone() if row: return str(row[0]) - # 2. Exact session_id + # 2. Exact ws_id row = conn.execute( - sa.select(sessions.c.session_id).where(sessions.c.session_id == alias_or_id) + sa.select(workstreams.c.ws_id).where(workstreams.c.ws_id == alias_or_id) ).fetchone() if row: return str(row[0]) # 3. Prefix match rows = conn.execute( - sa.select(sessions.c.session_id).where( - sessions.c.session_id.like(alias_or_id + "%") - ) + sa.select(workstreams.c.ws_id).where(workstreams.c.ws_id.like(alias_or_id + "%")) ).fetchall() if len(rows) == 1: return str(rows[0][0]) - # 4. Legacy: check conversations - row = conn.execute( - sa.select(sa.distinct(conversations.c.session_id)) - .where(conversations.c.session_id == alias_or_id) - .limit(1) - ).fetchone() - if row: - now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S") - existing = conn.execute( - sa.select(sessions.c.session_id).where(sessions.c.session_id == row[0]) - ).fetchone() - if not existing: - conn.execute( - sa.insert(sessions), - {"session_id": row[0], "created": now, "updated": now}, - ) - conn.commit() - return str(row[0]) return None - # -- Session config -------------------------------------------------------- + # -- Workstream config ----------------------------------------------------- - def save_session_config(self, session_id: str, config: dict[str, str]) -> None: + def save_workstream_config(self, ws_id: str, config: dict[str, str]) -> None: with self._engine.connect() as conn: for key, value in config.items(): # Upsert: delete + insert conn.execute( - sa.delete(session_config).where( - session_config.c.session_id == session_id, - session_config.c.key == key, + sa.delete(workstream_config).where( + workstream_config.c.ws_id == ws_id, + workstream_config.c.key == key, ) ) conn.execute( - sa.insert(session_config), - {"session_id": session_id, "key": key, "value": value}, + sa.insert(workstream_config), + {"ws_id": ws_id, "key": key, "value": value}, ) conn.commit() - def load_session_config(self, session_id: str) -> dict[str, str]: + def load_workstream_config(self, ws_id: str) -> dict[str, str]: with self._engine.connect() as conn: rows = conn.execute( - sa.select(session_config.c.key, session_config.c.value).where( - session_config.c.session_id == session_id + sa.select(workstream_config.c.key, workstream_config.c.value).where( + workstream_config.c.ws_id == ws_id ) ).fetchall() return {row[0]: row[1] for row in rows} - # -- Session metadata ------------------------------------------------------ + # -- Workstream metadata --------------------------------------------------- - def set_session_alias(self, session_id: str, alias: str) -> bool: + def set_workstream_alias(self, ws_id: str, alias: str) -> bool: with self._engine.connect() as conn: existing = conn.execute( - sa.select(sessions.c.session_id).where(sessions.c.alias == alias) + sa.select(workstreams.c.ws_id).where(workstreams.c.alias == alias) ).fetchone() - if existing and existing[0] != session_id: + if existing and existing[0] != ws_id: return False conn.execute( - sa.update(sessions).where(sessions.c.session_id == session_id).values(alias=alias) + sa.update(workstreams).where(workstreams.c.ws_id == ws_id).values(alias=alias) ) conn.commit() return True - def get_session_name(self, session_id: str) -> str | None: + def get_workstream_display_name(self, ws_id: str) -> str | None: with self._engine.connect() as conn: row = conn.execute( - sa.select(sessions.c.alias, sessions.c.title).where( - sessions.c.session_id == session_id + sa.select(workstreams.c.alias, workstreams.c.title).where( + workstreams.c.ws_id == ws_id ) ).fetchone() if row: @@ -285,10 +230,10 @@ class PostgreSQLBackend: return str(value) if value is not None else None return None - def update_session_title(self, session_id: str, title: str) -> None: + def update_workstream_title(self, ws_id: str, title: str) -> None: with self._engine.connect() as conn: conn.execute( - sa.update(sessions).where(sessions.c.session_id == session_id).values(title=title) + sa.update(workstreams).where(workstreams.c.ws_id == ws_id).values(title=title) ) conn.commit() @@ -359,6 +304,8 @@ class PostgreSQLBackend: name: str = "", state: str = "idle", user_id: str | None = None, + alias: str | None = None, + title: str | None = None, ) -> None: now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S") with self._engine.connect() as conn: @@ -374,6 +321,8 @@ class PostgreSQLBackend: "user_id": user_id, "name": name, "state": state, + "alias": alias, + "title": title, "created": now, "updated": now, }, @@ -402,6 +351,8 @@ class PostgreSQLBackend: def delete_workstream(self, ws_id: str) -> bool: with self._engine.connect() as conn: + conn.execute(sa.delete(conversations).where(conversations.c.ws_id == ws_id)) + conn.execute(sa.delete(workstream_config).where(workstream_config.c.ws_id == ws_id)) result = conn.execute(sa.delete(workstreams).where(workstreams.c.ws_id == ws_id)) conn.commit() return result.rowcount > 0 @@ -436,7 +387,7 @@ class PostgreSQLBackend: return list( conn.execute( sa.text( - "SELECT c.timestamp, c.session_id, c.role, c.content, c.tool_name " + "SELECT c.timestamp, c.ws_id, c.role, c.content, c.tool_name " "FROM conversations c " "WHERE to_tsvector('english', COALESCE(c.content, '')) " " @@ plainto_tsquery('english', :query) " @@ -452,7 +403,7 @@ class PostgreSQLBackend: return list( conn.execute( sa.text( - "SELECT timestamp, session_id, role, content, tool_name " + "SELECT timestamp, ws_id, role, content, tool_name " "FROM conversations WHERE content ILIKE :pattern " "ORDER BY timestamp DESC LIMIT :limit" ), @@ -466,22 +417,13 @@ class PostgreSQLBackend: return list( conn.execute( sa.text( - "SELECT timestamp, session_id, role, content, tool_name " + "SELECT timestamp, ws_id, role, content, tool_name " "FROM conversations ORDER BY timestamp DESC LIMIT :limit" ), {"limit": capped}, ).fetchall() ) - # -- Session lookup by workstream ------------------------------------------ - - def get_session_id_by_ws(self, ws_id: str) -> str | None: - with self._engine.connect() as conn: - row = conn.execute( - sa.select(sessions.c.session_id).where(sessions.c.ws_id == ws_id) - ).fetchone() - return str(row[0]) if row else None - # -- User identity operations ----------------------------------------------- def create_user( diff --git a/turnstone/core/storage/_protocol.py b/turnstone/core/storage/_protocol.py index 8932851b..2dbf6d46 100644 --- a/turnstone/core/storage/_protocol.py +++ b/turnstone/core/storage/_protocol.py @@ -9,26 +9,15 @@ from typing import Any, Protocol, runtime_checkable class StorageBackend(Protocol): """Protocol that every storage backend adapter must implement. - Provides session management, conversation persistence, key-value storage + Provides workstream management, conversation persistence, key-value storage (for memories), and full-text search. """ - # -- Core session operations ----------------------------------------------- - - def register_session( - self, - session_id: str, - title: str | None = None, - node_id: str | None = None, - ws_id: str | None = None, - user_id: str | None = None, - ) -> None: - """Create a sessions row for a new session (no-op if already exists).""" - ... + # -- Core conversation operations ------------------------------------------ def save_message( self, - session_id: str, + ws_id: str, role: str, content: str | None, tool_name: str | None = None, @@ -39,50 +28,46 @@ class StorageBackend(Protocol): """Log a message to the conversations table.""" ... - def load_session_messages(self, session_id: str) -> list[dict[str, Any]]: - """Load messages for a session and reconstruct OpenAI message format.""" + def load_messages(self, ws_id: str) -> list[dict[str, Any]]: + """Load messages for a workstream and reconstruct OpenAI message format.""" ... - # -- Session management ---------------------------------------------------- + # -- Workstream management ------------------------------------------------- - def list_sessions(self, limit: int = 20) -> list[Any]: - """List recent sessions with message counts, ordered by updated DESC.""" + def list_workstreams_with_history(self, limit: int = 20) -> list[Any]: + """List workstreams that have messages, ordered by updated DESC.""" ... - def delete_session(self, session_id: str) -> bool: - """Delete a session and all its messages. Returns True on success.""" + def prune_workstreams(self, retention_days: int = 90) -> tuple[int, int]: + """Remove orphaned + stale unnamed workstreams. Returns (orphans, stale).""" ... - def prune_sessions(self, retention_days: int = 90) -> tuple[int, int]: - """Remove orphaned + stale unnamed sessions. Returns (orphans, stale).""" + def resolve_workstream(self, alias_or_id: str) -> str | None: + """Resolve an alias or ws_id (or prefix) to a full ws_id.""" ... - def resolve_session(self, alias_or_id: str) -> str | None: - """Resolve an alias or session_id (or prefix) to a full session_id.""" + # -- Workstream config ----------------------------------------------------- + + def save_workstream_config(self, ws_id: str, config: dict[str, str]) -> None: + """Persist workstream configuration key/value pairs.""" ... - # -- Session config -------------------------------------------------------- - - def save_session_config(self, session_id: str, config: dict[str, str]) -> None: - """Persist session configuration key/value pairs.""" + def load_workstream_config(self, ws_id: str) -> dict[str, str]: + """Load workstream configuration. Returns empty dict if none stored.""" ... - def load_session_config(self, session_id: str) -> dict[str, str]: - """Load session configuration. Returns empty dict if none stored.""" - ... + # -- Workstream metadata --------------------------------------------------- - # -- Session metadata ------------------------------------------------------ - - def set_session_alias(self, session_id: str, alias: str) -> bool: + def set_workstream_alias(self, ws_id: str, alias: str) -> bool: """Set a human-friendly alias. Returns False if alias is taken.""" ... - def get_session_name(self, session_id: str) -> str | None: - """Return the alias (or title) for a session, or None if unset.""" + def get_workstream_display_name(self, ws_id: str) -> str | None: + """Return the alias (or title) for a workstream, or None if unset.""" ... - def update_session_title(self, session_id: str, title: str) -> None: - """Set or update the auto-generated title for a session.""" + def update_workstream_title(self, ws_id: str, title: str) -> None: + """Set or update the auto-generated title for a workstream.""" ... # -- Generic key-value store (backs memories table) ------------------------ @@ -116,6 +101,8 @@ class StorageBackend(Protocol): name: str = "", state: str = "idle", user_id: str | None = None, + alias: str | None = None, + title: str | None = None, ) -> None: """Create a workstreams row (no-op if already exists).""" ... @@ -129,7 +116,7 @@ class StorageBackend(Protocol): ... def delete_workstream(self, ws_id: str) -> bool: - """Delete a workstream. Returns True on success.""" + """Delete a workstream and all its conversations + config.""" ... def list_workstreams(self, node_id: str | None = None, limit: int = 100) -> list[Any]: @@ -139,7 +126,7 @@ class StorageBackend(Protocol): # -- Conversation search --------------------------------------------------- def search_history(self, query: str, limit: int = 20) -> list[Any]: - """Search conversation history. Returns (timestamp, session_id, role, content, tool_name).""" + """Search conversation history. Returns (timestamp, ws_id, role, content, tool_name).""" ... def search_history_recent(self, limit: int = 20) -> list[Any]: @@ -219,12 +206,6 @@ class StorageBackend(Protocol): """Remove a channel user mapping. Returns True if existed.""" ... - # -- Session lookup by workstream ------------------------------------------ - - def get_session_id_by_ws(self, ws_id: str) -> str | None: - """Find the session_id associated with a workstream. Returns None if not found.""" - ... - # -- Channel routing ------------------------------------------------------- def create_channel_route( diff --git a/turnstone/core/storage/_schema.py b/turnstone/core/storage/_schema.py index 19a835c0..e3702776 100644 --- a/turnstone/core/storage/_schema.py +++ b/turnstone/core/storage/_schema.py @@ -22,7 +22,7 @@ conversations = sa.Table( "conversations", metadata, sa.Column("id", sa.Integer, primary_key=True, autoincrement=True), - sa.Column("session_id", sa.Text, nullable=False, index=True), + sa.Column("ws_id", sa.Text, nullable=False, index=True), sa.Column("timestamp", sa.Text, nullable=False), sa.Column("role", sa.Text, nullable=False), sa.Column("content", sa.Text), @@ -32,32 +32,14 @@ conversations = sa.Table( sa.Column("provider_data", sa.Text), ) -sessions = sa.Table( - "sessions", - metadata, - sa.Column("session_id", sa.Text, primary_key=True), - sa.Column("alias", sa.Text, unique=True), - sa.Column("title", sa.Text), - sa.Column("node_id", sa.Text), - sa.Column("ws_id", sa.Text), - sa.Column("user_id", sa.Text), - sa.Column("created", sa.Text, nullable=False), - sa.Column("updated", sa.Text, nullable=False), -) - -# Additional indexes on sessions (name-based to avoid duplication with SA's auto-index) -sa.Index("idx_sessions_alias", sessions.c.alias) -sa.Index("idx_sessions_updated", sessions.c.updated) -sa.Index("idx_sessions_node_id", sessions.c.node_id) -sa.Index("idx_sessions_ws_id", sessions.c.ws_id) -sa.Index("idx_sessions_user_id", sessions.c.user_id) - workstreams = sa.Table( "workstreams", metadata, sa.Column("ws_id", sa.Text, primary_key=True), sa.Column("node_id", sa.Text), sa.Column("user_id", sa.Text), + sa.Column("alias", sa.Text, unique=True), + sa.Column("title", sa.Text), sa.Column("name", sa.Text, nullable=False, server_default=""), sa.Column("state", sa.Text, nullable=False, server_default="idle"), sa.Column("created", sa.Text, nullable=False), @@ -67,14 +49,15 @@ workstreams = sa.Table( sa.Index("idx_workstreams_node_id", workstreams.c.node_id) sa.Index("idx_workstreams_state", workstreams.c.state) sa.Index("idx_workstreams_user_id", workstreams.c.user_id) +sa.Index("idx_workstreams_alias", workstreams.c.alias) -session_config = sa.Table( - "session_config", +workstream_config = sa.Table( + "workstream_config", metadata, - sa.Column("session_id", sa.Text, nullable=False), + sa.Column("ws_id", sa.Text, nullable=False), sa.Column("key", sa.Text, nullable=False), sa.Column("value", sa.Text), - sa.PrimaryKeyConstraint("session_id", "key"), + sa.PrimaryKeyConstraint("ws_id", "key"), ) # --------------------------------------------------------------------------- diff --git a/turnstone/core/storage/_sqlite.py b/turnstone/core/storage/_sqlite.py index 75028754..10a7fdf5 100644 --- a/turnstone/core/storage/_sqlite.py +++ b/turnstone/core/storage/_sqlite.py @@ -15,9 +15,8 @@ from turnstone.core.storage._schema import ( conversations, memories, metadata, - session_config, - sessions, users, + workstream_config, workstreams, ) @@ -82,35 +81,11 @@ class SQLiteBackend: except Exception: self._fts5_available = False - # -- Core session operations ----------------------------------------------- - - def register_session( - self, - session_id: str, - title: str | None = None, - node_id: str | None = None, - ws_id: str | None = None, - user_id: str | None = None, - ) -> None: - now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S") - with self._engine.connect() as conn: - conn.execute( - sa.insert(sessions).prefix_with("OR IGNORE"), - { - "session_id": session_id, - "title": title, - "node_id": node_id, - "ws_id": ws_id, - "user_id": user_id, - "created": now, - "updated": now, - }, - ) - conn.commit() + # -- Core conversation operations ------------------------------------------ def save_message( self, - session_id: str, + ws_id: str, role: str, content: str | None, tool_name: str | None = None, @@ -123,7 +98,7 @@ class SQLiteBackend: result = conn.execute( sa.insert(conversations), { - "session_id": session_id, + "ws_id": ws_id, "timestamp": now, "role": role, "content": content, @@ -145,13 +120,13 @@ class SQLiteBackend: ) except Exception: self._fts5_available = False - # Bump session updated timestamp + # Bump workstream updated timestamp conn.execute( - sa.update(sessions).where(sessions.c.session_id == session_id).values(updated=now) + sa.update(workstreams).where(workstreams.c.ws_id == ws_id).values(updated=now) ) conn.commit() - def load_session_messages(self, session_id: str) -> list[dict[str, Any]]: + def load_messages(self, ws_id: str) -> list[dict[str, Any]]: with self._engine.connect() as conn: rows = conn.execute( sa.select( @@ -162,52 +137,44 @@ class SQLiteBackend: conversations.c.tool_call_id, conversations.c.provider_data, ) - .where(conversations.c.session_id == session_id) + .where(conversations.c.ws_id == ws_id) .order_by(conversations.c.id) ).fetchall() - return _reconstruct_messages(list(rows), session_id) + return _reconstruct_messages(list(rows), ws_id) - # -- Session management ---------------------------------------------------- + # -- Workstream management ------------------------------------------------- - def list_sessions(self, limit: int = 20) -> list[Any]: + def list_workstreams_with_history(self, limit: int = 20) -> list[Any]: with self._engine.connect() as conn: return list( conn.execute( sa.text( - "SELECT s.session_id, s.alias, s.title, s.created, s.updated, " + "SELECT w.ws_id, w.alias, w.title, w.created, w.updated, " "(SELECT COUNT(*) FROM conversations c " - " WHERE c.session_id = s.session_id), " - "s.node_id, s.ws_id " - "FROM sessions s " + " WHERE c.ws_id = w.ws_id), " + "w.node_id " + "FROM workstreams w " "WHERE EXISTS " - " (SELECT 1 FROM conversations c WHERE c.session_id = s.session_id) " - "ORDER BY s.updated DESC LIMIT :limit" + " (SELECT 1 FROM conversations c WHERE c.ws_id = w.ws_id) " + "ORDER BY w.updated DESC LIMIT :limit" ), {"limit": limit}, ).fetchall() ) - def delete_session(self, session_id: str) -> bool: - with self._engine.connect() as conn: - conn.execute(sa.delete(conversations).where(conversations.c.session_id == session_id)) - conn.execute(sa.delete(session_config).where(session_config.c.session_id == session_id)) - conn.execute(sa.delete(sessions).where(sessions.c.session_id == session_id)) - conn.commit() - return True - - def prune_sessions(self, retention_days: int = 90) -> tuple[int, int]: + def prune_workstreams(self, retention_days: int = 90) -> tuple[int, int]: orphans = stale = 0 with self._engine.connect() as conn: - # 1. Remove sessions with no messages + # 1. Remove workstreams with no messages orphan_ids = [ row[0] for row in conn.execute( sa.text( - "SELECT session_id FROM sessions " + "SELECT ws_id FROM workstreams " "WHERE NOT EXISTS " " (SELECT 1 FROM conversations c " - " WHERE c.session_id = sessions.session_id)" + " WHERE c.ws_id = workstreams.ws_id)" ) ).fetchall() ] @@ -215,16 +182,16 @@ class SQLiteBackend: placeholders = ",".join([":p" + str(i) for i in range(len(orphan_ids))]) params = {f"p{i}": oid for i, oid in enumerate(orphan_ids)} conn.execute( - sa.text(f"DELETE FROM session_config WHERE session_id IN ({placeholders})"), + sa.text(f"DELETE FROM workstream_config WHERE ws_id IN ({placeholders})"), params, ) result = conn.execute( - sa.text(f"DELETE FROM sessions WHERE session_id IN ({placeholders})"), + sa.text(f"DELETE FROM workstreams WHERE ws_id IN ({placeholders})"), params, ) orphans = result.rowcount - # 2. Remove old unnamed sessions + # 2. Remove old unnamed workstreams if retention_days > 0: cutoff = (datetime.now(UTC) - timedelta(days=retention_days)).strftime( "%Y-%m-%dT%H:%M:%S" @@ -233,7 +200,7 @@ class SQLiteBackend: row[0] for row in conn.execute( sa.text( - "SELECT session_id FROM sessions " + "SELECT ws_id FROM workstreams " "WHERE alias IS NULL AND updated < :cutoff" ), {"cutoff": cutoff}, @@ -243,11 +210,15 @@ class SQLiteBackend: placeholders = ",".join([":p" + str(i) for i in range(len(stale_ids))]) params = {f"p{i}": sid for i, sid in enumerate(stale_ids)} conn.execute( - sa.text(f"DELETE FROM session_config WHERE session_id IN ({placeholders})"), + sa.text(f"DELETE FROM workstream_config WHERE ws_id IN ({placeholders})"), + params, + ) + conn.execute( + sa.text(f"DELETE FROM conversations WHERE ws_id IN ({placeholders})"), params, ) result = conn.execute( - sa.text(f"DELETE FROM sessions WHERE session_id IN ({placeholders})"), + sa.text(f"DELETE FROM workstreams WHERE ws_id IN ({placeholders})"), params, ) stale = result.rowcount @@ -255,94 +226,71 @@ class SQLiteBackend: conn.commit() return (orphans, stale) - def resolve_session(self, alias_or_id: str) -> str | None: + def resolve_workstream(self, alias_or_id: str) -> str | None: with self._engine.connect() as conn: # 1. Exact alias match row = conn.execute( - sa.select(sessions.c.session_id).where(sessions.c.alias == alias_or_id) + sa.select(workstreams.c.ws_id).where(workstreams.c.alias == alias_or_id) ).fetchone() if row: return str(row[0]) - # 2. Exact session_id match + # 2. Exact ws_id match row = conn.execute( - sa.select(sessions.c.session_id).where(sessions.c.session_id == alias_or_id) + sa.select(workstreams.c.ws_id).where(workstreams.c.ws_id == alias_or_id) ).fetchone() if row: return str(row[0]) - # 3. Session_id prefix match + # 3. ws_id prefix match rows = conn.execute( - sa.select(sessions.c.session_id).where( - sessions.c.session_id.like(alias_or_id + "%") - ) + sa.select(workstreams.c.ws_id).where(workstreams.c.ws_id.like(alias_or_id + "%")) ).fetchall() if len(rows) == 1: return str(rows[0][0]) - # 4. Legacy: check conversations table - row = conn.execute( - sa.text( - "SELECT DISTINCT session_id FROM conversations WHERE session_id = :sid LIMIT 1" - ), - {"sid": alias_or_id}, - ).fetchone() - if row: - # Auto-register legacy session - conn.execute( - sa.text( - "INSERT OR IGNORE INTO sessions " - "(session_id, created, updated) VALUES (" - ":sid, " - "(SELECT MIN(timestamp) FROM conversations WHERE session_id = :sid), " - "(SELECT MAX(timestamp) FROM conversations WHERE session_id = :sid))" - ), - {"sid": row[0]}, - ) - conn.commit() - return str(row[0]) return None - # -- Session config -------------------------------------------------------- + # -- Workstream config ----------------------------------------------------- - def save_session_config(self, session_id: str, config: dict[str, str]) -> None: + def save_workstream_config(self, ws_id: str, config: dict[str, str]) -> None: with self._engine.connect() as conn: for key, value in config.items(): conn.execute( sa.text( - "INSERT OR REPLACE INTO session_config " - "(session_id, key, value) VALUES (:sid, :key, :value)" + "INSERT OR REPLACE INTO workstream_config " + "(ws_id, key, value) VALUES (:wid, :key, :value)" ), - {"sid": session_id, "key": key, "value": value}, + {"wid": ws_id, "key": key, "value": value}, ) conn.commit() - def load_session_config(self, session_id: str) -> dict[str, str]: + def load_workstream_config(self, ws_id: str) -> dict[str, str]: with self._engine.connect() as conn: rows = conn.execute( - sa.select(session_config.c.key, session_config.c.value).where( - session_config.c.session_id == session_id + sa.select(workstream_config.c.key, workstream_config.c.value).where( + workstream_config.c.ws_id == ws_id ) ).fetchall() return {row[0]: row[1] for row in rows} - # -- Session metadata ------------------------------------------------------ + # -- Workstream metadata --------------------------------------------------- - def set_session_alias(self, session_id: str, alias: str) -> bool: + def set_workstream_alias(self, ws_id: str, alias: str) -> bool: with self._engine.connect() as conn: existing = conn.execute( - sa.select(sessions.c.session_id).where(sessions.c.alias == alias) + sa.select(workstreams.c.ws_id).where(workstreams.c.alias == alias) ).fetchone() - if existing and existing[0] != session_id: + if existing and existing[0] != ws_id: return False conn.execute( - sa.update(sessions).where(sessions.c.session_id == session_id).values(alias=alias) + sa.update(workstreams).where(workstreams.c.ws_id == ws_id).values(alias=alias) ) conn.commit() return True - def get_session_name(self, session_id: str) -> str | None: + def get_workstream_display_name(self, ws_id: str) -> str | None: with self._engine.connect() as conn: row = conn.execute( - sa.select(sessions.c.alias, sessions.c.title).where( - sessions.c.session_id == session_id + sa.select(workstreams.c.alias, workstreams.c.title).where( + workstreams.c.ws_id == ws_id ) ).fetchone() if row: @@ -350,10 +298,10 @@ class SQLiteBackend: return str(value) if value is not None else None return None - def update_session_title(self, session_id: str, title: str) -> None: + def update_workstream_title(self, ws_id: str, title: str) -> None: with self._engine.connect() as conn: conn.execute( - sa.update(sessions).where(sessions.c.session_id == session_id).values(title=title) + sa.update(workstreams).where(workstreams.c.ws_id == ws_id).values(title=title) ) conn.commit() @@ -428,6 +376,8 @@ class SQLiteBackend: name: str = "", state: str = "idle", user_id: str | None = None, + alias: str | None = None, + title: str | None = None, ) -> None: now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S") with self._engine.connect() as conn: @@ -437,6 +387,8 @@ class SQLiteBackend: "ws_id": ws_id, "node_id": node_id, "user_id": user_id, + "alias": alias, + "title": title, "name": name, "state": state, "created": now, @@ -467,6 +419,8 @@ class SQLiteBackend: def delete_workstream(self, ws_id: str) -> bool: with self._engine.connect() as conn: + conn.execute(sa.delete(conversations).where(conversations.c.ws_id == ws_id)) + conn.execute(sa.delete(workstream_config).where(workstream_config.c.ws_id == ws_id)) result = conn.execute(sa.delete(workstreams).where(workstreams.c.ws_id == ws_id)) conn.commit() return result.rowcount > 0 @@ -500,7 +454,7 @@ class SQLiteBackend: return list( conn.execute( sa.text( - "SELECT c.timestamp, c.session_id, c.role, c.content, c.tool_name " + "SELECT c.timestamp, c.ws_id, c.role, c.content, c.tool_name " "FROM conversations_fts f " "JOIN conversations c ON c.id = f.rowid " "WHERE conversations_fts MATCH :query " @@ -512,7 +466,7 @@ class SQLiteBackend: return list( conn.execute( sa.text( - "SELECT timestamp, session_id, role, content, tool_name " + "SELECT timestamp, ws_id, role, content, tool_name " "FROM conversations WHERE content LIKE :pattern ESCAPE '\\' " "ORDER BY timestamp DESC LIMIT :limit" ), @@ -526,7 +480,7 @@ class SQLiteBackend: return list( conn.execute( sa.text( - "SELECT timestamp, session_id, role, content, tool_name " + "SELECT timestamp, ws_id, role, content, tool_name " "FROM conversations ORDER BY timestamp DESC LIMIT :limit" ), {"limit": capped}, @@ -732,15 +686,6 @@ class SQLiteBackend: conn.commit() return result.rowcount > 0 - # -- Session lookup by workstream ------------------------------------------ - - def get_session_id_by_ws(self, ws_id: str) -> str | None: - with self._engine.connect() as conn: - row = conn.execute( - sa.select(sessions.c.session_id).where(sessions.c.ws_id == ws_id) - ).fetchone() - return str(row[0]) if row else None - # -- Channel user mapping --------------------------------------------------- def create_channel_user(self, channel_type: str, channel_user_id: str, user_id: str) -> None: @@ -1195,7 +1140,7 @@ class SQLiteBackend: self._engine.dispose() -def _reconstruct_messages(rows: list[Any], session_id: str) -> list[dict[str, Any]]: +def _reconstruct_messages(rows: list[Any], ws_id: str) -> list[dict[str, Any]]: """Reconstruct OpenAI message format from stored conversation rows. Handles tool_call / tool_result grouping and incomplete turn repair. @@ -1233,7 +1178,7 @@ def _reconstruct_messages(rows: list[Any], session_id: str) -> list[dict[str, An while i < len(rows) and rows[i][0] == "tool_call": _, _, tn, ta, stored_tc_id, _ = rows[i] - call_id = stored_tc_id or f"call_{session_id}_{i}" + call_id = stored_tc_id or f"call_{ws_id}_{i}" assistant_msg["tool_calls"].append( { "id": call_id, diff --git a/turnstone/core/storage/migrations/versions/006_normalize_ws_id.py b/turnstone/core/storage/migrations/versions/006_normalize_ws_id.py new file mode 100644 index 00000000..15a77091 --- /dev/null +++ b/turnstone/core/storage/migrations/versions/006_normalize_ws_id.py @@ -0,0 +1,131 @@ +"""Normalize session_id into ws_id — merge sessions table into workstreams. + +Conversations and config are now keyed by ws_id (workstream identity) instead +of a separate session_id. The sessions table is dropped; its alias/title +columns move to workstreams. + +Revision ID: 006 +Revises: 005 +Create Date: 2026-03-07 +""" + +import sqlalchemy as sa +from alembic import op + +revision = "006" +down_revision = "005" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # 1. Add alias and title columns to workstreams. + op.add_column("workstreams", sa.Column("alias", sa.Text)) + op.add_column("workstreams", sa.Column("title", sa.Text)) + op.create_index("idx_workstreams_alias", "workstreams", ["alias"], unique=True) + + conn = op.get_bind() + + # 2. Copy alias/title from sessions → workstreams (for rows that have a ws_id). + conn.execute( + sa.text( + "UPDATE workstreams SET " + " alias = (SELECT s.alias FROM sessions s WHERE s.ws_id = workstreams.ws_id), " + " title = (SELECT s.title FROM sessions s WHERE s.ws_id = workstreams.ws_id) " + "WHERE EXISTS (SELECT 1 FROM sessions s WHERE s.ws_id = workstreams.ws_id)" + ) + ) + + # 3. Create workstream rows for sessions that have a ws_id but no + # corresponding workstream row yet. + # The NOT EXISTS guard makes this safe on both SQLite and PostgreSQL. + conn.execute( + sa.text( + "INSERT INTO workstreams " + "(ws_id, node_id, alias, title, state, created, updated) " + "SELECT s.ws_id, s.node_id, s.alias, s.title, 'closed', s.created, s.updated " + "FROM sessions s " + "WHERE s.ws_id IS NOT NULL AND s.ws_id != '' " + " AND NOT EXISTS (SELECT 1 FROM workstreams w WHERE w.ws_id = s.ws_id)" + ) + ) + + # 4. Create workstream rows for sessions WITHOUT a ws_id + # (use session_id as ws_id). + conn.execute( + sa.text( + "INSERT INTO workstreams " + "(ws_id, node_id, alias, title, state, created, updated) " + "SELECT s.session_id, s.node_id, s.alias, s.title, 'closed', s.created, s.updated " + "FROM sessions s " + "WHERE (s.ws_id IS NULL OR s.ws_id = '') " + " AND NOT EXISTS (SELECT 1 FROM workstreams w WHERE w.ws_id = s.session_id)" + ) + ) + + # 5. Rename conversations.session_id → conversations.ws_id and remap values. + # For sessions with ws_id: map session_id → ws_id. + # For sessions without ws_id: session_id stays (used as ws_id). + op.alter_column("conversations", "session_id", new_column_name="ws_id") + + conn.execute( + sa.text( + "UPDATE conversations SET ws_id = (" + " SELECT COALESCE(NULLIF(s.ws_id, ''), s.session_id) " + " FROM sessions s WHERE s.session_id = conversations.ws_id" + ") " + "WHERE EXISTS (" + " SELECT 1 FROM sessions s WHERE s.session_id = conversations.ws_id" + ")" + ) + ) + + # 6. Rename session_config → workstream_config with ws_id column. + op.rename_table("session_config", "workstream_config") + op.alter_column("workstream_config", "session_id", new_column_name="ws_id") + + conn.execute( + sa.text( + "UPDATE workstream_config SET ws_id = (" + " SELECT COALESCE(NULLIF(s.ws_id, ''), s.session_id) " + " FROM sessions s WHERE s.session_id = workstream_config.ws_id" + ") " + "WHERE EXISTS (" + " SELECT 1 FROM sessions s WHERE s.session_id = workstream_config.ws_id" + ")" + ) + ) + + # 7. Drop the sessions table. + op.drop_table("sessions") + + +def downgrade() -> None: + # Recreate the sessions table. + op.create_table( + "sessions", + sa.Column("session_id", sa.Text, primary_key=True), + sa.Column("alias", sa.Text, unique=True), + sa.Column("title", sa.Text), + sa.Column("node_id", sa.Text), + sa.Column("ws_id", sa.Text), + sa.Column("user_id", sa.Text), + sa.Column("created", sa.Text, nullable=False), + sa.Column("updated", sa.Text, nullable=False), + ) + op.create_index("idx_sessions_alias", "sessions", ["alias"]) + op.create_index("idx_sessions_updated", "sessions", ["updated"]) + op.create_index("idx_sessions_node_id", "sessions", ["node_id"]) + op.create_index("idx_sessions_ws_id", "sessions", ["ws_id"]) + + # Reverse config table rename. + op.alter_column("workstream_config", "ws_id", new_column_name="session_id") + op.rename_table("workstream_config", "session_config") + + # Reverse conversations column rename. + op.alter_column("conversations", "ws_id", new_column_name="session_id") + + # Drop alias/title from workstreams. + op.drop_index("idx_workstreams_alias", "workstreams") + op.drop_column("workstreams", "title") + op.drop_column("workstreams", "alias") diff --git a/turnstone/core/workstream.py b/turnstone/core/workstream.py index b369a5cd..60cb9275 100644 --- a/turnstone/core/workstream.py +++ b/turnstone/core/workstream.py @@ -1,4 +1,4 @@ -"""Workstream manager — concurrent independent chat sessions. +"""Workstream manager — concurrent independent conversations. A workstream is an independent conversation with its own ChatSession and UI adapter. The WorkstreamManager coordinates multiple workstreams, tracks their @@ -74,9 +74,10 @@ class WorkstreamManager: Args: session_factory: callable(ui, model_alias, ws_id) -> ChatSession. Captures shared config (registry, temperature, …) so the - manager can create sessions without knowing those details. - *model_alias* selects a model from the registry (None = default). - *ws_id* links the session to its workstream in storage. + manager can create ChatSession instances without knowing + those details. *model_alias* selects a model from the + registry (None = default). *ws_id* is the persistent + identity used for all storage operations. max_workstreams: Maximum number of concurrent workstreams. When at capacity, ``create()`` will auto-evict the oldest IDLE workstream before raising. @@ -125,7 +126,7 @@ class WorkstreamManager: model: Optional model alias from the registry. ``None`` uses the default model. """ - # Fast-fail capacity check (avoids expensive session creation when full). + # Fast-fail capacity check (avoids expensive ChatSession creation when full). first_evicted: Workstream | None = None with self._lock: if len(self._workstreams) >= self._max_workstreams: @@ -141,7 +142,7 @@ class WorkstreamManager: _m1.record_eviction() - # Create workstream and session outside the lock (session creation is + # Create workstream and ChatSession outside the lock (construction is # expensive — involves LLM client setup and DB writes). ws = Workstream(name=name) if ui_factory: diff --git a/turnstone/mq/bridge.py b/turnstone/mq/bridge.py index 1d308873..847ac634 100644 --- a/turnstone/mq/bridge.py +++ b/turnstone/mq/bridge.py @@ -34,7 +34,6 @@ from turnstone.mq.protocol import ( OutboundEvent, PlanReviewEvent, ReasoningEvent, - SessionResumedEvent, StateChangeEvent, StatusEvent, StreamEndEvent, @@ -46,6 +45,7 @@ from turnstone.mq.protocol import ( WorkstreamCreatedEvent, WorkstreamListEvent, WorkstreamRenameEvent, + WorkstreamResumedEvent, ) if TYPE_CHECKING: @@ -361,7 +361,7 @@ class Bridge: auto_approve_tools = getattr(msg, "auto_approve_tools", []) model = getattr(msg, "model", "") initial_message = getattr(msg, "initial_message", "") - resume_session = getattr(msg, "resume_session", "") + resume_ws = getattr(msg, "resume_ws", "") user_id = getattr(msg, "user_id", "") if user_id: log.info("bridge.create_ws user_id=%s name=%s model=%s", user_id, name, model) @@ -371,11 +371,11 @@ class Bridge: auto_approve_tools=auto_approve_tools, correlation_id=msg.correlation_id, model=model, - resume_session=resume_session, + resume_ws=resume_ws, ) - # Send initial_message only when no session was actually resumed. + # Send initial_message only when no workstream was actually resumed. # Use the server's `resumed` response (not just the intent) so that - # a pruned/missing session falls back to sending the initial message. + # a pruned/missing workstream falls back to sending the initial message. if ws_id and initial_message and not resumed: # Track the send so the global SSE handler emits TurnCompleteEvent # when the workstream returns to idle. @@ -438,15 +438,15 @@ class Bridge: auto_approve_tools: list[str], correlation_id: str, model: str = "", - resume_session: str = "", + resume_ws: str = "", ) -> tuple[str, bool]: """Create a workstream on the server. Returns (ws_id, resumed).""" try: payload: dict[str, Any] = {"name": name, "auto_approve": auto_approve} if model: payload["model"] = model - if resume_session: - payload["resume_session"] = resume_session + if resume_ws: + payload["resume_ws"] = resume_ws resp = self._http.post( "/v1/api/workstreams/new", json=payload, @@ -475,15 +475,12 @@ class Bridge: self._start_ws_sse(ws_id) - resolved_session_id = data.get("session_id", "") if resumed else "" - self._publish_global( WorkstreamCreatedEvent( ws_id=ws_id, name=ws_name, correlation_id=correlation_id, resumed=resumed, - session_id=resolved_session_id, message_count=data.get("message_count", 0), ) ) @@ -500,10 +497,9 @@ class Bridge: if resumed: self._publish_ws( ws_id, - SessionResumedEvent( + WorkstreamResumedEvent( ws_id=ws_id, correlation_id=correlation_id, - session_id=resolved_session_id, message_count=data.get("message_count", 0), name=ws_name, ), diff --git a/turnstone/mq/protocol.py b/turnstone/mq/protocol.py index 17c84916..de91baf0 100644 --- a/turnstone/mq/protocol.py +++ b/turnstone/mq/protocol.py @@ -95,7 +95,7 @@ class CreateWorkstreamMessage(InboundMessage): target_node: str = "" model: str = "" initial_message: str = "" - resume_session: str = "" + resume_ws: str = "" user_id: str = "" @@ -277,7 +277,6 @@ class WorkstreamCreatedEvent(OutboundEvent): name: str = "" node_id: str = "" resumed: bool = False - session_id: str = "" message_count: int = 0 @@ -337,11 +336,10 @@ class NodeListEvent(OutboundEvent): @dataclass -class SessionResumedEvent(OutboundEvent): - """Confirmation that a session was resumed during workstream creation.""" +class WorkstreamResumedEvent(OutboundEvent): + """Confirmation that a workstream was resumed during creation.""" - type: str = "session_resumed" - session_id: str = "" + type: str = "ws_resumed" message_count: int = 0 name: str = "" @@ -411,7 +409,7 @@ _OUTBOUND_REGISTRY: dict[str, type[OutboundEvent]] = { ErrorEvent, InfoEvent, NodeListEvent, - SessionResumedEvent, + WorkstreamResumedEvent, ClusterStateEvent, ] } diff --git a/turnstone/sdk/server.py b/turnstone/sdk/server.py index 4340a4af..5af436c8 100644 --- a/turnstone/sdk/server.py +++ b/turnstone/sdk/server.py @@ -26,7 +26,7 @@ from turnstone.api.server_schemas import ( CreateWorkstreamResponse, DashboardResponse, HealthResponse, - ListSessionsResponse, + ListSavedWorkstreamsResponse, ListWorkstreamsResponse, SendResponse, ) @@ -76,7 +76,7 @@ class AsyncTurnstoneServer(_BaseClient): name: str = "", model: str = "", auto_approve: bool = False, - resume_session: str = "", + resume_ws: str = "", ) -> CreateWorkstreamResponse: body: dict[str, Any] = {} if name: @@ -85,8 +85,8 @@ class AsyncTurnstoneServer(_BaseClient): body["model"] = model if auto_approve: body["auto_approve"] = True - if resume_session: - body["resume_session"] = resume_session + if resume_ws: + body["resume_ws"] = resume_ws return await self._request( "POST", "/v1/api/workstreams/new", @@ -214,10 +214,12 @@ class AsyncTurnstoneServer(_BaseClient): await consume_task return result - # -- sessions ------------------------------------------------------------ + # -- saved workstreams ---------------------------------------------------- - async def list_sessions(self) -> ListSessionsResponse: - return await self._request("GET", "/v1/api/sessions", response_model=ListSessionsResponse) + async def list_saved_workstreams(self) -> ListSavedWorkstreamsResponse: + return await self._request( + "GET", "/v1/api/workstreams/saved", response_model=ListSavedWorkstreamsResponse + ) # -- auth ---------------------------------------------------------------- @@ -307,11 +309,11 @@ class TurnstoneServer: name: str = "", model: str = "", auto_approve: bool = False, - resume_session: str = "", + resume_ws: str = "", ) -> CreateWorkstreamResponse: return self._runner.run( self._async.create_workstream( - name=name, model=model, auto_approve=auto_approve, resume_session=resume_session + name=name, model=model, auto_approve=auto_approve, resume_ws=resume_ws ) ) @@ -363,10 +365,10 @@ class TurnstoneServer: self._async.send_and_wait(message, ws_id, timeout=timeout, on_event=on_event) ) - # -- sessions ------------------------------------------------------------ + # -- saved workstreams ---------------------------------------------------- - def list_sessions(self) -> ListSessionsResponse: - return self._runner.run(self._async.list_sessions()) + def list_saved_workstreams(self) -> ListSavedWorkstreamsResponse: + return self._runner.run(self._async.list_saved_workstreams()) # -- auth ---------------------------------------------------------------- diff --git a/turnstone/server.py b/turnstone/server.py index d480b8e2..1b1f0fac 100644 --- a/turnstone/server.py +++ b/turnstone/server.py @@ -302,7 +302,7 @@ class WebUI: def _build_history( session: ChatSession, has_pending_approval: bool = False ) -> list[dict[str, Any]]: - """Build a history replay list from session messages. + """Build a history replay list from ChatSession messages. When ``has_pending_approval`` is True, the last assistant entry's tool_calls are marked ``"pending": True`` so the client renders them @@ -566,7 +566,6 @@ async def list_workstreams(request: Request) -> JSONResponse: "id": ws.id, "name": ws.name, "state": ws.state.value, - "session_id": ws.session.session_id if ws.session else None, } ) return JSONResponse({"workstreams": result}) @@ -574,7 +573,7 @@ async def list_workstreams(request: Request) -> JSONResponse: async def dashboard(request: Request) -> JSONResponse: """GET /v1/api/dashboard — enriched workstream data + aggregate stats.""" - from turnstone.core.memory import get_session_name + from turnstone.core.memory import get_workstream_display_name mgr: WorkstreamManager = request.app.state.workstreams wss = mgr.list_all() @@ -596,13 +595,12 @@ async def dashboard(request: Request) -> JSONResponse: active_count += 1 title = "" if ws.session: - title = get_session_name(ws.session.session_id) or "" + title = get_workstream_display_name(ws.session.ws_id) or "" ws_list.append( { "id": ws.id, "name": ws.name, "state": ws.state.value, - "session_id": ws.session.session_id if ws.session else None, "title": title, "tokens": tok, "context_ratio": round(ctx, 3), @@ -630,25 +628,23 @@ async def dashboard(request: Request) -> JSONResponse: ) -async def list_sessions_endpoint(request: Request) -> JSONResponse: - """GET /v1/api/sessions — list saved sessions.""" - from turnstone.core.memory import list_sessions +async def list_saved_workstreams(request: Request) -> JSONResponse: + """GET /v1/api/workstreams/saved — list saved workstreams with conversation history.""" + from turnstone.core.memory import list_workstreams_with_history - rows = list_sessions(limit=50) - sessions = [ + rows = list_workstreams_with_history(limit=50) + result = [ { - "session_id": sid, + "ws_id": wid, "alias": alias, "title": title, "created": created, "updated": updated, "message_count": count, - "node_id": node_id, - "ws_id": ws_id, } - for sid, alias, title, created, updated, count, node_id, ws_id in rows + for wid, alias, title, created, updated, count, *_extra in rows ] - return JSONResponse({"sessions": sessions}) + return JSONResponse({"workstreams": result}) def _count_ws_states(wss: list[Workstream]) -> dict[str, int]: @@ -694,7 +690,6 @@ async def metrics_endpoint(request: Request) -> Response: { "ws_id": ws.id, "name": ws.name, - "session_id": ws.session.session_id if ws.session else "", "prompt_tokens": ui._ws_prompt_tokens, "completion_tokens": ui._ws_completion_tokens, "messages": ui._ws_messages, @@ -814,7 +809,7 @@ async def command(request: Request) -> JSONResponse: should_exit = ws.session.handle_command(cmd) if should_exit: ui.on_info("Session ended. You can close this tab.") - # Handle UI updates for session-changing commands + # Handle UI updates for workstream-changing commands cmd_word = cmd.strip().split(None, 1)[0].lower() if cmd_word in ("/clear", "/new"): ui._enqueue({"type": "clear_ui"}) @@ -826,9 +821,9 @@ async def command(request: Request) -> JSONResponse: # Sync in-memory workstream name after any command that can change it. # This ensures /api/workstreams and future page loads see the right name. if cmd_word in ("/name", "/resume"): - from turnstone.core.memory import get_session_name + from turnstone.core.memory import get_workstream_display_name - updated_name = get_session_name(ws.session.session_id) + updated_name = get_workstream_display_name(ws.session.ws_id) if ws.session else None if updated_name: ws.name = updated_name except Exception as e: @@ -868,20 +863,18 @@ async def create_workstream(request: Request) -> JSONResponse: "reason": "evicted", } ) - # Atomic session resume during creation. + # Atomic workstream resume during creation. resumed = False message_count = 0 - session_id = "" - resume_session_id = body.get("resume_session", "") - if resume_session_id and ws.session is not None: - from turnstone.core.memory import get_session_name, resolve_session + resume_ws_id = body.get("resume_ws", "") + if resume_ws_id and ws.session is not None: + from turnstone.core.memory import get_workstream_display_name, resolve_workstream - target_id = resolve_session(resume_session_id) - if target_id and ws.session.resume_session(target_id): + target_id = resolve_workstream(resume_ws_id) + if target_id and ws.session.resume(target_id): resumed = True - session_id = target_id message_count = len(ws.session.messages) - ws.name = get_session_name(target_id) or ws.name + ws.name = get_workstream_display_name(target_id) or ws.name ui = ws.ui if isinstance(ui, WebUI): ui._enqueue({"type": "clear_ui"}) @@ -894,7 +887,6 @@ async def create_workstream(request: Request) -> JSONResponse: "ws_id": ws.id, "name": ws.name, "resumed": resumed, - "session_id": session_id, "message_count": message_count, } ) @@ -1084,7 +1076,7 @@ def create_app( Route("/api/events/global", global_events_sse), Route("/api/workstreams", list_workstreams), Route("/api/dashboard", dashboard), - Route("/api/sessions", list_sessions_endpoint), + Route("/api/workstreams/saved", list_saved_workstreams), Route("/api/send", send_message, methods=["POST"]), Route("/api/approve", approve, methods=["POST"]), Route("/api/plan", plan_feedback, methods=["POST"]), @@ -1223,8 +1215,8 @@ def main() -> None: parser.add_argument( "--resume", default=None, - metavar="SESSION", - help="Resume a previous session by alias or session_id", + metavar="WS", + help="Resume a previous workstream by alias or ws_id", ) parser.add_argument( "--skip-permissions", @@ -1248,11 +1240,11 @@ def main() -> None: help="Port to listen on (default: 8080)", ) parser.add_argument( - "--session-retention-days", + "--retention-days", type=int, default=90, metavar="DAYS", - help="Delete unnamed sessions older than DAYS days on startup, 0 to disable (default: 90)", + help="Delete unnamed workstreams older than DAYS days on startup, 0 to disable (default: 90)", ) parser.add_argument( "--workstream-idle-timeout", @@ -1348,10 +1340,10 @@ def main() -> None: ) init_storage(db_backend, path=db_path, url=db_url, pool_size=db_pool_size) - # Prune stale / empty sessions on startup - from turnstone.core.memory import prune_sessions + # Prune stale / empty workstreams on startup + from turnstone.core.memory import prune_workstreams - prune_sessions(retention_days=args.session_retention_days, log_fn=print) + prune_workstreams(retention_days=args.retention_days, log_fn=print) # Create client and detect model provider_name = args.provider @@ -1488,16 +1480,16 @@ def main() -> None: # Handle --resume assert ws.session is not None if args.resume: - from turnstone.core.memory import resolve_session + from turnstone.core.memory import resolve_workstream - target_id = resolve_session(args.resume) + target_id = resolve_workstream(args.resume) if not target_id: - log.error("Session not found: %s", args.resume) + log.error("Workstream not found: %s", args.resume) sys.exit(1) - if not ws.session.resume_session(target_id): - log.error("Session '%s' has no messages.", args.resume) + if not ws.session.resume(target_id): + log.error("Workstream '%s' has no messages.", args.resume) sys.exit(1) - log.info("Resumed session %s (%d messages)", target_id, len(ws.session.messages)) + log.info("Resumed workstream %s (%d messages)", target_id, len(ws.session.messages)) # Record detected model in metrics _metrics.model = model diff --git a/turnstone/tools/plan.json b/turnstone/tools/plan.json index 832ffcb9..ea330762 100644 --- a/turnstone/tools/plan.json +++ b/turnstone/tools/plan.json @@ -1,6 +1,6 @@ { "name": "plan", - "description": "Plan before implementing. An autonomous agent explores the codebase and writes a structured plan to .plan-.md (unique per session to avoid workstream collisions). If a plan for this session already exists it is re-read and refined rather than overwritten from scratch. Use plan BEFORE writing code — when the user asks to build, add, refactor, or change something that touches multiple files or has unclear scope. The plan identifies files to modify, existing patterns to reuse, and risks to consider.", + "description": "Plan before implementing. An autonomous agent explores the codebase and writes a structured plan to .plan-.md (unique per workstream to avoid collisions). If a plan for this workstream already exists it is re-read and refined rather than overwritten from scratch. Use plan BEFORE writing code — when the user asks to build, add, refactor, or change something that touches multiple files or has unclear scope. The plan identifies files to modify, existing patterns to reuse, and risks to consider.", "parameters": { "type": "object", "properties": { diff --git a/turnstone/ui/static/app.js b/turnstone/ui/static/app.js index ea838cdb..f7b3e7dd 100644 --- a/turnstone/ui/static/app.js +++ b/turnstone/ui/static/app.js @@ -617,12 +617,12 @@ function toggleDashboard() { function loadDashboard() { var tableEl = document.getElementById("dash-ws-table"); tableEl.innerHTML = '
Loading\u2026
'; - document.getElementById("dashboard-session-cards").innerHTML = + document.getElementById("dashboard-saved-cards").innerHTML = '
Loading\u2026
'; var dashP = authFetch("/v1/api/dashboard").then(function (r) { return r.json(); }); - var sessP = authFetch("/v1/api/sessions").then(function (r) { + var sessP = authFetch("/v1/api/workstreams/saved").then(function (r) { return r.json(); }); Promise.all([dashP, sessP]) @@ -631,19 +631,19 @@ function loadDashboard() { var wsList = dashData.workstreams || []; var agg = dashData.aggregate || {}; renderDashboardTable(wsList, agg); - // Collect active session IDs for dedup - var activeSessionIds = {}; + // Collect active ws IDs for dedup + var activeWsIds = {}; wsList.forEach(function (ws) { - if (ws.session_id) activeSessionIds[ws.session_id] = true; + activeWsIds[ws.id] = true; }); - var sessList = (res[1].sessions || []).filter(function (s) { - return !activeSessionIds[s.session_id]; + var savedList = (res[1].workstreams || []).filter(function (s) { + return !activeWsIds[s.ws_id]; }); - renderDashboardSessions(sessList); + renderSavedWorkstreams(savedList); }) .catch(function () { tableEl.innerHTML = '
Failed to load
'; - document.getElementById("dashboard-session-cards").innerHTML = + document.getElementById("dashboard-saved-cards").innerHTML = '
Failed to load
'; }); } @@ -800,30 +800,30 @@ function updateDashFooter(agg) { ")"; } } -function renderDashboardSessions(sessions) { - var c = document.getElementById("dashboard-session-cards"); +function renderSavedWorkstreams(items) { + var c = document.getElementById("dashboard-saved-cards"); c.innerHTML = ""; - if (!sessions.length) { - c.innerHTML = '
No saved sessions
'; + if (!items.length) { + c.innerHTML = '
No saved workstreams
'; return; } - sessions.forEach(function (sess) { + items.forEach(function (sess) { var card = document.createElement("div"); card.className = "dashboard-card"; card.setAttribute("role", "button"); card.setAttribute("tabindex", "0"); - var label = sess.alias || sess.title || sess.session_id; + var label = sess.alias || sess.title || sess.ws_id; card.setAttribute("aria-label", "Resume: " + label); card.onclick = function () { - dashboardResumeSession(sess.session_id); + dashboardResumeSession(sess.ws_id); }; card.onkeydown = function (e) { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); - dashboardResumeSession(sess.session_id); + dashboardResumeSession(sess.ws_id); } }; - var title = sess.alias || sess.title || sess.session_id.substring(0, 12); + var title = sess.alias || sess.title || sess.ws_id.substring(0, 12); var meta = sess.message_count + " msgs"; if (sess.updated) meta += " \u00b7 " + formatRelativeTime(sess.updated); card.innerHTML = @@ -861,11 +861,11 @@ function dashboardSwitchWorkstream(wsId) { switchTab(wsId); } else loadDashboard(); } -function dashboardResumeSession(sessionId) { +function dashboardResumeSession(wsId) { authFetch("/v1/api/workstreams/new", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ resume_session: sessionId }), + body: JSON.stringify({ resume_ws: wsId }), }) .then(function (r) { if (!r.ok) throw new Error("HTTP " + r.status); @@ -879,7 +879,7 @@ function dashboardResumeSession(sessionId) { // Resume handled atomically by server — history arrives via SSE. }) .catch(function (err) { - showToast("Failed to resume session", "error"); + showToast("Failed to resume workstream", "error"); }); } function dashboardNewChat() { diff --git a/turnstone/ui/static/index.html b/turnstone/ui/static/index.html index 2806f2df..4ebe35f5 100644 --- a/turnstone/ui/static/index.html +++ b/turnstone/ui/static/index.html @@ -62,9 +62,9 @@ -
-

Recent Sessions

-
+
+

Saved Workstreams

+