WebSocket merge

frdel committed Feb 1, 2026 at 16:07 UTC 4932e125466f77a83a3a3bcab799fd8cd78d0f25
124 files changed +12051 -736
README.md
+4 -3
@@ -6,7 +6,7 @@
6 <a href="https://trendshift.io/repositories/11745" target="_blank"><img src="https://trendshift.io/api/badge/repositories/11745" alt="frdel%2Fagent-zero | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
7 </p>
8
9 -[![Agent Zero Website](https://img.shields.io/badge/Website-agent--zero.ai-0A192F?style=for-the-badge&logo=vercel&logoColor=white)](https://agent-zero.ai) [![Thanks to Sponsors](https://img.shields.io/badge/GitHub%20Sponsors-Thanks%20to%20Sponsors-FF69B4?style=for-the-badge&logo=githubsponsors&logoColor=white)](https://github.com/sponsors/agent0ai) [![Follow on X](https://img.shields.io/badge/X-Follow-000000?style=for-the-badge&logo=x&logoColor=white)](https://x.com/Agent0ai) [![Join our Discord](https://img.shields.io/badge/Discord-Join%20our%20server-5865F2?style=for-the-badge&logo=discord&logoColor=white)](https://discord.gg/B8KZKNsPpj) [![Subscribe on YouTube](https://img.shields.io/badge/YouTube-Subscribe-red?style=for-the-badge&logo=youtube&logoColor=white)](https://www.youtube.com/@AgentZeroFW) [![Connect on LinkedIn](https://img.shields.io/badge/LinkedIn-Connect-blue?style=for-the-badge&logo=linkedin&logoColor=white)](https://www.linkedin.com/in/jan-tomasek/) [![Follow on Warpcast](https://img.shields.io/badge/Warpcast-Follow-5A32F3?style=for-the-badge)](https://warpcast.com/agent-zero)
9 +[![Agent Zero Website](https://img.shields.io/badge/Website-agent--zero.ai-0A192F?style=for-the-badge&logo=vercel&logoColor=white)](https://agent-zero.ai) [![Thanks to Sponsors](https://img.shields.io/badge/GitHub%20Sponsors-Thanks%20to%20Sponsors-FF69B4?style=for-the-badge&logo=githubsponsors&logoColor=white)](https://github.com/sponsors/agent0ai) [![Follow on X](https://img.shields.io/badge/X-Follow-000000?style=for-the-badge&logo=x&logoColor=white)](https://x.com/Agent0ai) [![Join our Discord](https://img.shields.io/badge/Discord-Join%20our%20server-5865F2?style=for-the-badge&logo=discord&logoColor=white)](https://discord.gg/B8KZKNsPpj) [![Subscribe on YouTube](https://img.shields.io/badge/YouTube-Subscribe-red?style=for-the-badge&logo=youtube&logoColor=white)](https://www.youtube.com/@AgentZeroFW) [![Connect on LinkedIn](https://img.shields.io/badge/LinkedIn-Connect-blue?style=for-the-badge&logo=linkedin&logoColor=white)](https://www.linkedin.com/in/jan-tomasek/) [![Follow on Warpcast](https://img.shields.io/badge/Warpcast-Follow-5A32F3?style=for-the-badge)](https://warpcast.com/agent-zero)
10
11
12 ## Documentation:
@@ -14,7 +14,7 @@
14 [Introduction](#a-personal-organic-agentic-framework-that-grows-and-learns-with-you) •
15 [Installation](./docs/installation.md) •
16 [Development](./docs/development.md) •
17 -[Extensibility](./docs/extensibility.md) •
17 +[WebSocket Infrastructure](./docs/websocket-infrastructure.md) •
18 [Connectivity](./docs/connectivity.md) •
19 [How to update](./docs/installation.md#how-to-update-agent-zero) •
20 [Documentation](./docs/README.md) •
@@ -158,6 +158,7 @@ docker run -p 50001:80 agent0ai/agent-zero
158 | [Installation](./docs/installation.md) | Installation, setup and configuration |
159 | [Usage](./docs/usage.md) | Basic and advanced usage |
160 | [Development](./docs/development.md) | Development and customization |
161 +| [WebSocket Infrastructure](./docs/websocket-infrastructure.md) | Real-time WebSocket handlers, client APIs, filtering semantics, envelopes |
162 | [Extensibility](./docs/extensibility.md) | Extending Agent Zero |
163 | [Connectivity](./docs/connectivity.md) | External API endpoints, MCP server connections, A2A protocol |
164 | [Architecture](./docs/architecture.md) | System design and components |
@@ -265,7 +266,7 @@ docker run -p 50001:80 agent0ai/agent-zero
266 - More space efficient on mobile
267 - Streamable HTTP MCP servers support
268 - LLM API URL added to models config for Azure, local and custom providers
268 -
269 +
270
271 ### v0.9.0 - Agent roles, backup/restore
272 [Release video](https://www.youtube.com/watch?v=rMIe-TC6H-k)
agent.py
+26 -17
@@ -1,4 +1,4 @@
1 -import asyncio, random, string
1 +import asyncio, random, string, threading
2 import nest_asyncio
3
4 nest_asyncio.apply()
@@ -45,6 +45,7 @@ class AgentContextType(Enum):
45 class AgentContext:
46
47 _contexts: dict[str, "AgentContext"] = {}
48 + _contexts_lock = threading.RLock()
49 _counter: int = 0
50 _notification_manager = None
51
@@ -66,10 +67,14 @@ class AgentContext:
67 ):
68 # initialize context
69 self.id = id or AgentContext.generate_id()
69 - existing = self._contexts.get(self.id, None)
70 - if existing:
71 - AgentContext.remove(self.id)
72 - self._contexts[self.id] = self
70 + existing = None
71 + with AgentContext._contexts_lock:
72 + existing = AgentContext._contexts.get(self.id, None)
73 + if existing:
74 + AgentContext._contexts.pop(self.id, None)
75 + AgentContext._contexts[self.id] = self
76 + if existing and existing.task:
77 + existing.task.kill()
78 if set_current:
79 AgentContext.set_current(self.id)
80
@@ -94,7 +99,8 @@ class AgentContext:
99
100 @staticmethod
101 def get(id: str):
97 - return AgentContext._contexts.get(id, None)
102 + with AgentContext._contexts_lock:
103 + return AgentContext._contexts.get(id, None)
104
105 @staticmethod
106 def use(id: str):
@@ -118,13 +124,15 @@ class AgentContext:
124
125 @staticmethod
126 def first():
121 - if not AgentContext._contexts:
122 - return None
123 - return list(AgentContext._contexts.values())[0]
127 + with AgentContext._contexts_lock:
128 + if not AgentContext._contexts:
129 + return None
130 + return list(AgentContext._contexts.values())[0]
131
132 @staticmethod
133 def all():
127 - return list(AgentContext._contexts.values())
134 + with AgentContext._contexts_lock:
135 + return list(AgentContext._contexts.values())
136
137 @staticmethod
138 def generate_id():
@@ -133,8 +141,9 @@ class AgentContext:
141
142 while True:
143 short_id = generate_short_id()
136 - if short_id not in AgentContext._contexts:
137 - return short_id
144 + with AgentContext._contexts_lock:
145 + if short_id not in AgentContext._contexts:
146 + return short_id
147
148 @classmethod
149 def get_notification_manager(cls):
@@ -146,7 +155,8 @@ class AgentContext:
155
156 @staticmethod
157 def remove(id: str):
149 - context = AgentContext._contexts.pop(id, None)
158 + with AgentContext._contexts_lock:
159 + context = AgentContext._contexts.pop(id, None)
160 if context and context.task:
161 context.task.kill()
162 return context
@@ -197,7 +207,6 @@ class AgentContext:
207 heading: str | None = None,
208 content: str | None = None,
209 kvps: dict | None = None,
200 - temp: bool | None = None,
210 update_progress: Log.ProgressUpdate | None = None,
211 id: str | None = None, # Add id parameter
212 **kwargs,
@@ -206,7 +215,7 @@ class AgentContext:
215 for context in AgentContext.all():
216 items.append(
217 context.log.log(
209 - type, heading, content, kvps, temp, update_progress, id, **kwargs
218 + type, heading, content, kvps, update_progress, id, **kwargs
219 )
220 )
221 return items
@@ -231,8 +240,8 @@ class AgentContext:
240 def get_agent(self):
241 return self.streaming_agent or self.agent0
242
234 - def is_running(self):
235 - return self.task and self.task.is_alive()
243 + def is_running(self) -> bool:
244 + return (self.task and self.task.is_alive()) or False
245
246 def communicate(self, msg: "UserMessage", broadcast_level: int = 1):
247 self.paused = False # unpause if paused
docs/README.md
+3 -1
@@ -6,6 +6,7 @@ To begin with Agent Zero, follow the links below for detailed guides on various
6 - **[Usage Guide](usage.md):** Explore GUI features and usage scenarios.
7 - **[Development](development.md):** Set up a development environment for Agent Zero.
8 - **[Extensibility](extensibility.md):** Learn how to create custom extensions for Agent Zero.
9 +- **[WebSocket Infrastructure](websocket-infrastructure.md):** Build real-time features with bidirectional handlers and client APIs.
10 - **[Connectivity](connectivity.md):** Learn how to connect to Agent Zero from other applications.
11 - **[Architecture Overview](architecture.md):** Understand the internal workings of the framework.
12 - **[Contributing](contribution.md):** Learn how to contribute to the Agent Zero project.
@@ -58,7 +59,8 @@ To begin with Agent Zero, follow the links below for detailed guides on various
59 - [Knowledge](architecture.md#5-knowledge)
60 - [Instruments](architecture.md#6-instruments)
61 - [Extensions](architecture.md#7-extensions)
61 - - [Contributing](contribution.md)
62 +- [WebSocket Infrastructure](websocket-infrastructure.md)
63 +- [Development](development.md)
64 - [Getting Started](contribution.md#getting-started)
65 - [Making Changes](contribution.md#making-changes)
66 - [Submitting a Pull Request](contribution.md#submitting-a-pull-request)
docs/contribution.md
+2 -1
@@ -6,6 +6,7 @@ Contributions to improve Agent Zero are very welcome! This guide outlines how t
6
7 - See [development](development.md) for instructions on how to set up a development environment.
8 - See [extensibility](extensibility.md) for instructions on how to create custom extensions.
9 +- See [websocket infrastructure](websocket-infrastructure.md) for guidance on building real-time handlers and client integrations.
10
11 1. **Fork the Repository:** Fork the Agent Zero repository on GitHub.
12 2. **Clone Your Fork:** Clone your forked repository to your local machine.
@@ -27,4 +28,4 @@ Contributions to improve Agent Zero are very welcome! This guide outlines how t
28
29 ## Documentation Stack
30
30 -- The documentation is built using Markdown. We appreciate your contributions even if you don't know Markdown, and look forward to improve Agent Zero for everyone's benefit.
\ No newline at end of file
31 +- The documentation is built using Markdown. We appreciate your contributions even if you don't know Markdown, and look forward to improve Agent Zero for everyone's benefit.
docs/development.md
+6 -3
@@ -68,7 +68,7 @@ Now when you select one of the python files in the project, you should see prope
68 ```bash
69 pip install -r requirements.txt
70 playwright install chromium
71 -```
71 +```
72 These will install all the python packages and browser binaries for playwright (browser agent).
73 Errors in the code editor caused by missing packages should now be gone. If not, try reloading the window.
74
@@ -81,7 +81,9 @@ It will not be able to do code execution and few other features requiring the Do
81
82 ![VS Code debugging](res/dev/devinst-6.png)
83
84 -The framework will run at the default port 5000. If you open `http://localhost:5000` in your browser and see `ERR_EMPTY_RESPONSE`, don't panic, you may need to select another port like I did for some reason. If you need to change the defaut port, you can add `"--port=5555"` to the args in the `.vscode/launch.json` file or you can create a `.env` file in the root directory and set the `WEB_UI_PORT` variable to the desired port.
84 +The framework will run at the default port 5000. If you open `http://localhost:5000` in your browser and see `ERR_EMPTY_RESPONSE`, don't panic, you may need to select another port like I did for some reason. If you need to change the default port, you can add `"--port=5555"` to the args in the `.vscode/launch.json` file or you can create a `.env` file in the root directory and set the `WEB_UI_PORT` variable to the desired port.
85 +
86 +You can also set the bind host via `"--host=0.0.0.0"` (or `WEB_UI_HOST=0.0.0.0`).
87
88 It may take a while the first time. You should see output like the screenshot below. The RFC error is ok for now as we did not yet connect our local development to another instance in docker.
89 ![First run](res/dev/devinst-7.png)
@@ -147,6 +149,7 @@ You're now ready to contribute to Agent Zero, create custom extensions, or modif
149
150 ## Next steps
151 - See [extensibility](extensibility.md) for instructions on how to create custom extensions.
152 +- See [websocket infrastructure](websocket-infrastructure.md) for real-time handler patterns, client APIs, and troubleshooting tips.
153 - See [contribution](contribution.md) for instructions on how to contribute to the framework.
154
155 ## Configuration via Environment Variables
@@ -167,4 +170,4 @@ These environment variables automatically override the hardcoded defaults in `ge
170 - You can use the `DockerfileLocal` to build your docker image.
171 - Navigate to your project root in the terminal and run `docker build -f DockerfileLocal -t agent-zero-local --build-arg CACHE_DATE=$(date +%Y-%m-%d:%H:%M:%S) .`
172 - The `CACHE_DATE` argument is optional, it is used to cache most of the build process and only rebuild the last steps when the files or dependencies change.
170 -- See `docker/run/build.txt` for more build command examples.
\ No newline at end of file
173 +- See `docker/run/build.txt` for more build command examples.
docs/installation.md
+15 -14
@@ -10,7 +10,7 @@ The following user guide provides instructions for installing and running Agent
10 ## Windows, macOS and Linux Setup Guide
11
12
13 -1. **Install Docker Desktop:**
13 +1. **Install Docker Desktop:**
14 - Docker Desktop provides the runtime environment for Agent Zero, ensuring consistent behavior and security across platforms
15 - The entire framework runs within a Docker container, providing isolation and easy deployment
16 - Available as a user-friendly GUI application for all major operating systems
@@ -23,8 +23,8 @@ The following user guide provides instructions for installing and running Agent
23 <br><br>
24
25 > [!NOTE]
26 -> **Linux Users:** You can install either Docker Desktop or docker-ce (Community Edition).
27 -> For Docker Desktop, follow the instructions for your specific Linux distribution [here](https://docs.docker.com/desktop/install/linux-install/).
26 +> **Linux Users:** You can install either Docker Desktop or docker-ce (Community Edition).
27 +> For Docker Desktop, follow the instructions for your specific Linux distribution [here](https://docs.docker.com/desktop/install/linux-install/).
28 > For docker-ce, follow the instructions [here](https://docs.docker.com/engine/install/).
29 >
30 > If you're using docker-ce, you'll need to add your user to the `docker` group:
@@ -44,14 +44,14 @@ The following user guide provides instructions for installing and running Agent
44 <img src="res/setup/image-12.png" alt="docker install" width="300"/>
45 <br><br>
46
47 -1.4. Once installed, launch Docker Desktop:
47 +1.4. Once installed, launch Docker Desktop:
48
49 <img src="res/setup/image-11.png" alt="docker installed" height="100"/>
50 <img src="res/setup/image-13.png" alt="docker installed" height="100"/>
51 <br><br>
52
53 > [!NOTE]
54 -> **MacOS Configuration:** In Docker Desktop's preferences (Docker menu) → Settings →
54 +> **MacOS Configuration:** In Docker Desktop's preferences (Docker menu) → Settings →
55 > Advanced, enable "Allow the default Docker socket to be used (requires password)."
56
57 ![docker socket macOS](res/setup/macsocket.png)
@@ -189,8 +189,8 @@ Optionally you can map local folders for file persistence:
189 > You can also access the Web UI by clicking the ports right under the container ID in Docker Desktop.
190
191 > [!NOTE]
192 -> After starting the container, you'll find all Agent Zero files in your chosen
193 -> directory. You can access and edit these files directly on your machine, and
192 +> After starting the container, you'll find all Agent Zero files in your chosen
193 +> directory. You can access and edit these files directly on your machine, and
194 > the changes will be immediately reflected in the running container.
195
196 3. Configure Agent Zero
@@ -306,7 +306,7 @@ ollama pull <model-name>
306 2. A CLI message should confirm the model download on your system
307
308 #### Selecting your model within Agent Zero
309 -1. Once you've downloaded your model(s), you must select it in the Settings page of the GUI.
309 +1. Once you've downloaded your model(s), you must select it in the Settings page of the GUI.
310
311 2. Within the Chat model, Utility model, or Embedding model section, choose Ollama as provider.
312
@@ -321,7 +321,7 @@ ollama pull <model-name>
321 #### Managing your downloaded models
322 Once you've downloaded some models, you might want to check which ones you have available or remove any you no longer need.
323
324 -- **Listing downloaded models:**
324 +- **Listing downloaded models:**
325 To see a list of all the models you've downloaded, use the command:
326 ```
327 ollama list
@@ -356,8 +356,10 @@ Agent Zero's Web UI is accessible from any device on your network through the Do
356 > - The port is automatically assigned by Docker unless you specify one
357
358 > [!NOTE]
359 -> If you're running Agent Zero directly on your system (legacy approach) instead of
360 -> using Docker, you'll need to configure the host manually in `run_ui.py` to run on all interfaces using `host="0.0.0.0"`.
359 +> If you're running Agent Zero directly on your system (legacy approach) instead of
360 +> using Docker, configure the bind address/ports via flags or environment variables:
361 +> - Use `--host 0.0.0.0` (or set `WEB_UI_HOST=0.0.0.0` in `.env`) to listen on all interfaces.
362 +> - Use `--port <PORT>` (or `WEB_UI_PORT`) to pick the HTTP port.
363
364 For developers or users who need to run Agent Zero directly on their system,see the [In-Depth Guide for Full Binaries Installation](#in-depth-guide-for-full-binaries-installation).
365
@@ -418,9 +420,8 @@ For developers or users who need to run Agent Zero directly on their system,see
420 > docker run -p $PORT:80 -v /path/to/your/data:/a0 agent0ai/agent-zero
421 > ```
422
421 -
423 +
424 ### Conclusion
423 -After following the instructions for your specific operating system, you should have Agent Zero successfully installed and running. You can now start exploring the framework's capabilities and experimenting with creating your own intelligent agents.
425 +After following the instructions for your specific operating system, you should have Agent Zero successfully installed and running. You can now start exploring the framework's capabilities and experimenting with creating your own intelligent agents.
426
427 If you encounter any issues during the installation process, please consult the [Troubleshooting section](troubleshooting.md) of this documentation or refer to the Agent Zero [Skool](https://www.skool.com/agent-zero) or [Discord](https://discord.gg/B8KZKNsPpj) community for assistance.
426 -
docs/quickstart.md
+10 -7
@@ -4,22 +4,25 @@ This guide provides a quick introduction to using Agent Zero. We'll cover launch
4 ## Launching the Web UI
5 1. Make sure you have Agent Zero installed and your environment set up correctly (refer to the [Installation guide](installation.md) if needed).
6 2. Open a terminal in the Agent Zero directory and activate your conda environment (if you're using one).
7 -3. Run the following command:
7 +3. Run one of the following commands:
8
9 ```bash
10 python run_ui.py
11 ```
12
13 -4. A message similar to this will appear in your terminal, indicating the Web UI is running:
13 +Notes:
14 +- HTTP binds to `--host/--port` (or `WEB_UI_HOST`/`WEB_UI_PORT`, default port 5000).
15 +
16 +4. A message similar to this will appear in your terminal, indicating the Web UI is running:
17
18 ![](res/flask_link.png)
19
17 -5. Open your web browser and navigate to the URL shown in the terminal (usually `http://127.0.0.1:50001`). You should see the Agent Zero Web UI.
20 +5. Open your web browser and navigate to the URL shown in the terminal (usually `http://127.0.0.1:5000`). You should see the Agent Zero Web UI.
21
22 ![New Chat](res/ui_newchat1.png)
23
24 > [!TIP]
22 -> As you can see, the Web UI has four distinct buttons for easy chat management:
25 +> As you can see, the Web UI has four distinct buttons for easy chat management:
26 > `New Chat`, `Reset Chat`, `Save Chat`, and `Load Chat`.
27 > Chats can be saved and loaded individually in `json` format and are stored in the
28 > `/tmp/chats` directory.
@@ -49,6 +52,6 @@ Now that you've run a simple task, you can experiment with more complex requests
52 * Create or modify files
53
54 > [!TIP]
52 -> The [Usage Guide](usage.md) provides more in-depth information on using Agent
53 -> Zero's various features, including prompt engineering, tool usage, and multi-agent
54 -> cooperation.
\ No newline at end of file
55 +> The [Usage Guide](usage.md) provides more in-depth information on using Agent
56 +> Zero's various features, including prompt engineering, tool usage, and multi-agent
57 +> cooperation.
docs/usage.md
+3
@@ -102,6 +102,9 @@ Agent Zero's power comes from its ability to use [tools](architecture.md#tools).
102
103 - **Understand Tools:** Agent Zero includes default tools like knowledge (powered by SearXNG), code execution, and communication. Understand the capabilities of these tools and how to invoke them.
104
105 +### Real-Time WebSocket Features
106 +- Use WebSockets when you need bidirectional, low-latency updates. The [WebSocket Infrastructure guide](websocket-infrastructure.md) explains the backend handler framework, client API, filtering semantics, and common producer/consumer patterns.
107 +
108 ## Example of Tools Usage: Web Search and Code Execution
109 Let's say you want Agent Zero to perform some financial analysis tasks. Here's a possible prompt:
110
docs/websocket-infrastructure.md new
+731
@@ -0,0 +1,731 @@
1 +# WebSocket Infrastructure Guide
2 +
3 +**Audience**: Backend and frontend developers building real-time features on Agent Zero
4 +**Updated**: 2026-01-02
5 +**Related Specs**: `specs/003-websocket-event-handlers/*`
6 +
7 +This guide consolidates everything you need to design, implement, and troubleshoot Agent Zero WebSocket flows. It complements the feature specification by describing day-to-day developer tasks, showing how backend handlers and frontend clients cooperate, and documenting practical patterns for producers and consumers on both sides of the connection.
8 +
9 +---
10 +
11 +## Table of Contents
12 +
13 +1. [Architecture at a Glance](#architecture-at-a-glance)
14 +2. [Terminology & Metadata](#terminology--metadata)
15 +3. [Connection Lifecycle](#connection-lifecycle)
16 +4. [Backend Cookbook (Handlers & Manager)](#backend-cookbook-handlers--manager)
17 +5. [Frontend Cookbook (websocket.js)](#frontend-cookbook-websocketjs)
18 +6. [Producer & Consumer Patterns](#producer--consumer-patterns)
19 +7. [Metadata Flow & Envelopes](#metadata-flow--envelopes)
20 +8. [Diagnostics, Harness & Logging](#diagnostics-harness--logging)
21 +9. [Best Practices Checklist](#best-practices-checklist)
22 +10. [Quick Reference Tables](#quick-reference-tables)
23 +11. [Further Reading](#further-reading)
24 +
25 +---
26 +
27 +## Architecture at a Glance
28 +
29 +- **Runtime (`run_ui.py`)** – boots `python-socketio.AsyncServer` inside an ASGI stack served by Uvicorn. Flask routes are mounted via `uvicorn.middleware.wsgi.WSGIMiddleware`, and Flask + Socket.IO share the same process so session cookies and CSRF semantics stay aligned.
30 +- **Singleton handlers** – every `WebSocketHandler` subclass exposes `get_instance()` and is registered exactly once. Direct instantiation raises `SingletonInstantiationError`, keeping shared state and lifecycle hooks deterministic.
31 +- **Dispatcher offload** – handler entrypoints (`process_event`, `on_connect`, `on_disconnect`) run in a background worker loop (via `DeferredTask`) so blocking handlers cannot stall the Socket.IO transport. Socket.IO emits/disconnects are marshalled back to the dispatcher loop. Diagnostic timing and payload summaries are only built when Event Console watchers are subscribed (development mode).
32 +- **`python/helpers/websocket_manager.py`** – orchestrates routing, buffering, aggregation, metadata envelopes, and session tracking. Think of it as the “switchboard” for every WebSocket event.
33 +- **`python/helpers/websocket.py`** – base class for application handlers. Provides lifecycle hooks, helper methods (`emit_to`, `broadcast`, `request`, `request_all`) and identifier metadata.
34 +- **`webui/js/websocket.js`** – frontend singleton exposing a minimal client API (`emit`, `request`, `on`, `off`) with lazy connection management and development-only logging (no client-side `broadcast()` or `requestAll()` helpers).
35 +- **Developer Harness (`webui/components/settings/developer/websocket-test-store.js`)** – manual and automatic validation suite for emit/request flows, timeout behaviour (including the default unlimited wait), correlation ID propagation, envelope metadata, subscription persistence across reconnect, and development-mode diagnostics.
36 +- **Specs & Contracts** – canonical definitions live under `specs/003-websocket-event-handlers/`. This guide references those documents but focuses on applied usage.
37 +
38 +---
39 +
40 +## Terminology & Metadata
41 +
42 +| Term | Where it Appears | Meaning |
43 +|------|------------------|---------|
44 +| `sid` | Socket.IO | Connection identifier for a Socket.IO namespace connection. With only the root namespace (`/`), each tab has one `sid`. When connecting to multiple namespaces, a tab has one `sid` per namespace. Treat connection identity as `(namespace, sid)`. |
45 +| `handlerId` | Manager Envelope | Fully-qualified Python class name (e.g., `python.websocket_handlers.notifications.NotificationHandler`). Used for result aggregation and logging. |
46 +| `eventId` | Manager Envelope | UUIDv4 generated for every server→client delivery. Unique per emission. Useful when correlating broadcast fan-out or diagnosing duplicates. |
47 +| `correlationId` | Bidirectional flows | Thread that ties together request, response, and any follow-up events. Client may supply one; otherwise the manager generates and echoes it everywhere. |
48 +| `data` | Envelope payload | Application payload you define. Always a JSON-serialisable object. |
49 +| `user_to_sids` / `sid_to_user` | Manager session tracking | Single-user map today (`allUsers` bucket). Future-proof for multi-tenant routing but already handy when you need all active SIDs. |
50 +| Buffer | Manager | Up to 100 fire-and-forget events stored per temporarily disconnected SID (expires after 1 hour). Request/response events never buffer—clients receive standardised errors instead. |
51 +
52 +Useful mental model: **client ↔ manager ↔ handler**. The manager normalises metadata and enforces routing; handlers focus on business logic; the frontend uses the same identifiers, so logs are easy to stitch.
53 +
54 +---
55 +
56 +## Connection Lifecycle
57 +
58 +1. **Lazy Connect** – `/js/websocket.js` connects only when a consumer uses the client API (e.g., `emit`, `request`, `on`). Consumers may still explicitly `await websocket.connect()` to block UI until the socket is ready.
59 +2. **Handshake** – Socket.IO connects using the existing Flask session cookie and a CSRF token provided via the Socket.IO `auth` payload (`csrf_token`). The token is obtained from `GET /csrf_token` (see `/js/api.js#getCsrfToken()`), which also sets the runtime-scoped cookie `csrf_token_{runtime_id}`. The server validates an **Origin allowlist** (RFC 6455 / OWASP CSWSH baseline) and then checks handler requirements (`requires_auth`, `requires_csrf`) before accepting.
60 +3. **Lifecycle Hooks** – After acceptance, `WebSocketHandler.on_connect(sid)` fires for every registered handler. Use it for initial emits, state bookkeeping, or session tracking.
61 +4. **Normal Operation** – Client emits events. Manager routes them to the appropriate handlers, gathers results, and wraps outbound deliveries in the mandatory envelope.
62 +5. **Disconnection & Buffering** – If a tab goes away without a graceful disconnect, fire-and-forget events accumulate (max 100). On reconnect, the manager flushes the buffer via `emit_to`. Request flows respond with explicit `CONNECTION_NOT_FOUND` errors.
63 +6. **Reconnection Attempts** – Socket.IO handles reconnect attempts; the manager continues to buffer fire-and-forget events (up to 1 hour) for temporarily disconnected SIDs and flushes them on reconnect.
64 +
65 +### State Sync (Replacing `/poll`)
66 +
67 +Agent Zero can also push poll-shaped state snapshots over the WebSocket bus, replacing the legacy 4Hz `/poll` loop while preserving the existing UI update contract.
68 +
69 +- **Handshake**: the frontend sync store (`/components/sync/sync-store.js`) calls `websocket.request("state_request", { context, log_from, notifications_from, timezone })` to establish per-tab cursors and a `seq_base`.
70 +- **Push**: the server emits `state_push` events containing `{ runtime_epoch, seq, snapshot }`, where `snapshot` is exactly the `/poll` payload shape built by `python/helpers/state_snapshot.py`.
71 +- **Coalescing**: the backend `StateMonitor` coalesces dirties per SID (25ms window) so streaming updates stay smooth without unbounded trailing-edge debounce.
72 +- **Degraded fallback**: if the WebSocket handshake/push path is unhealthy, the UI enters `DEGRADED` and uses `/poll` as a fallback; while degraded, push snapshots are ignored to avoid racey double-writes.
73 +
74 +### Thinking in Roles
75 +
76 +- **Client** (frontend) is the page that imports `/js/websocket.js`. It acts as both a **producer** (calling `emit`, `request`) and a **consumer** (subscribing with `on`).
77 +- **Manager** (`WebSocketManager`) sits server-side and routes everything. It resolves correlation IDs, wraps envelopes, and fans out results.
78 +- **Handler** (`WebSocketHandler`) executes the application logic. Each handler may emit additional events back to the client or initiate its own requests to connected SIDs.
79 +
80 +### Flow Overview (by Operation)
81 +
82 +```
83 +Client emit() ───▶ Manager route_event() ───▶ Handler.process_event()
84 + │ │ └──(fire-and-forget, no ack)
85 + └── throws if └── validates payload + routes by namespace/event type
86 + not connected updates last_activity
87 +
88 +Client request() ─▶ Manager route_event() ─▶ Handlers (async gather)
89 + │ │ └── per-handler dict/None
90 + │ │
91 + │ └── builds {correlationId, results[]}
92 + └── Promise resolves with aggregated results (timeouts become error items)
93 +
94 +Server emit_to() ──▶ Manager.emit_to() ──▶ Socket.IO delivery/buffer
95 + │ │ └── envelope {handlerId,…}
96 + └── raises ConnectionNotFoundError for unknown sid (never seen)
97 +
98 +Server broadcast() ─▶ Manager.broadcast()
99 + │ └── iterates active sids (respecting exclude_sids)
100 + │ └── delegates to `Manager.emit_to()` → `socketio.emit(..., to=sid)`
101 + └── fire-and-forget (no ack)
102 +
103 +Server request() ─▶ Manager.request_for_sid() ─▶ route_event()
104 + │ │ └── per-handler responses
105 + └── Await aggregated {correlationId, results[]}
106 +
107 +Server request_all() ─▶ Manager.route_event_all() ─▶ route_event per sid
108 + │ │ └── per-handler results
109 + └── Await list[{sid, correlationId, results[]}]
110 +```
111 +
112 +These diagrams highlight the “who calls what” surface while the detailed semantics (envelopes, buffering, timeouts) remain consistent with the tables later in this guide.
113 +
114 +### End-to-End Examples
115 +
116 +1. **Client request ➜ multiple handlers**
117 +
118 + 1. Frontend calls `websocket.request("refresh_metrics", payload)`.
119 + 2. Manager routes to each handler registered for that event type and awaits `asyncio.gather`.
120 + 3. Each handler returns a dict (or raises); the manager wraps them in `results[]` and resolves the Promise with `{ correlationId, results }`.
121 + 4. The caller inspects per-handler data or errors, filtering by `handlerId` as needed.
122 +
123 +2. **Server broadcast with buffered replay**
124 +
125 + 1. Handler invokes `self.broadcast("notification_broadcast", data, exclude_sids=sid)`.
126 + 2. Manager iterates active connections. For connected SIDs it emits immediately with the mandatory envelope. For temporarily disconnected SIDs it enqueues into the per-SID buffer (up to 100 events).
127 + 3. When a buffered SID reconnects, `_flush_buffer()` replays the queued envelopes preserving `handlerId`, `eventId`, `correlationId`, and `ts`.
128 +
129 +3. **Server request_all ➜ client-side confirmations**
130 +
131 + 1. Handler issues `await self.request_all("confirm_close", { contextId }, timeout_ms=5000)`.
132 + 2. Manager fans out to every active SID, allowing `exclude_handlers` when provided.
133 + 3. Each subscribed client runs its `websocket.on("confirm_close", …)` callback and returns data through the Socket.IO acknowledgement.
134 + 4. The handler receives `[{ sid, correlationId, results[] }]`, inspects each response, and proceeds accordingly.
135 +
136 +These expanded flows complement the operation matrix later in the guide, ensuring every combination (client/server × emit/request and server request_all) is covered explicitly.
137 +
138 +---
139 +
140 +## Backend Cookbook (Handlers & Manager)
141 +
142 +### 1. Handler Discovery & Setup
143 +
144 +Handlers are discovered deterministically from `python/websocket_handlers/`:
145 +
146 +- **File entry**: `python/websocket_handlers/state_sync_handler.py` → namespace `/state_sync`
147 +- **Folder entry**: `python/websocket_handlers/orders/` or `python/websocket_handlers/orders_handler/` → namespace `/orders` (loads `*.py` one level deep; ignores `__init__.py` and deeper nesting)
148 +- **Reserved root**: `python/websocket_handlers/_default.py` → namespace `/` (diagnostics-only by default)
149 +
150 +Create handler modules under the appropriate namespace entry and inherit from `WebSocketHandler`.
151 +
152 +```python
153 +from python.helpers.websocket import WebSocketHandler
154 +
155 +class DashboardHandler(WebSocketHandler):
156 + @classmethod
157 + def get_event_types(cls) -> list[str]:
158 + return ["dashboard_refresh", "dashboard_push"]
159 +
160 + async def process_event(self, event_type: str, data: dict[str, Any], sid: str) -> dict | None:
161 + if event_type == "dashboard_refresh":
162 + stats = await self._load_stats(data.get("scope", "all"))
163 + return {"ok": True, "stats": stats}
164 +
165 + if event_type == "dashboard_push":
166 + await self.broadcast(
167 + "dashboard_update",
168 + {"stats": data.get("stats", {}), "source": sid},
169 + exclude_sids=sid,
170 + )
171 + return None
172 +```
173 +
174 +Handlers are auto-loaded on startup; duplicate event declarations produce warnings but are supported. Use `validate_event_types` to ensure names follow lowercase snake_case and avoid Socket.IO reserved events.
175 +
176 +### 2. Consuming Client Events (Server as Consumer)
177 +
178 +- Implement `process_event` and return either `None` (fire-and-forget) or a dict that becomes the handler’s contribution in `results[]`.
179 +- Use dependency injection (async functions, database calls, etc.) but keep event loop friendly—no blocking calls.
180 +- Validate input vigorously and return structured errors as needed.
181 +
182 +```python
183 +async def process_event(self, event_type: str, data: dict, sid: str) -> dict | None:
184 + if "query" not in data:
185 + return {"ok": False, "error": {"code": "VALIDATION", "error": "Missing query"}}
186 +
187 + rows = await self.search_backend(data["query"], limit=data.get("limit", 25))
188 + return {"ok": True, "data": rows, "count": len(rows)}
189 +```
190 +
191 +### 3. Producing Server Events (Server as Producer)
192 +
193 +Four helper methods mirror the frontend API. The table below summarises them (full table in [Quick Reference](#quick-reference-tables)).
194 +
195 +| Method | Target | Ack | Filters | Typical Use |
196 +|--------|--------|-----|---------|--------------|
197 +| `emit_to(sid, event, data, correlation_id=None)` | Single SID | No | None | Push job progress, reply to a request without using Socket.IO ack (already produced). |
198 +| `broadcast(event, data, exclude_sids=None, correlation_id=None)` | All SIDs | No | `exclude_sids` only | Fan-out notifications, multi-tab sync while skipping the caller. |
199 +| `request(sid, event, data, timeout_ms=0)` | Single SID | Yes (`results[]`) | None | Ask the client to run local logic (e.g., UI confirmation) and gather per-handler results. |
200 +| `request_all(event, data, timeout_ms=0)` | All SIDs | Yes (`[{sid, results[]}]`) | None | Fan-out to every tab, e.g., “refresh your panel” or “confirm unsaved changes”. |
201 +
202 +Each helper automatically injects `handlerId`, obeys metadata envelopes, enforces routing rules, and handles timeouts:
203 +
204 +```python
205 +aggregated = await self.request_all(
206 + "workspace_ping",
207 + {"payload": {"reason": "health_check"}},
208 + timeout_ms=2_000,
209 +)
210 +
211 +for entry in aggregated:
212 + self.log.info("sid %s replied: %s", entry["sid"], entry["results"])
213 +```
214 +
215 +Timeouts convert into `{ "ok": False, "error": {"code": "TIMEOUT", ...} }`; they do **not** raise.
216 +
217 +### 4. Multi-Handler Aggregation
218 +
219 +- When multiple handlers subscribe to the same event, the manager invokes them concurrently with `asyncio.gather`. Aggregated results preserve registration order. Use correlation IDs to map responses to original triggers.
220 +- Client-side handler include/exclude filters are intentionally not supported. Consumers filter `results[]` by `handlerId` when needed.
221 +
222 +```python
223 +if not results:
224 + return {
225 + "handlerId": self.identifier,
226 + "ok": False,
227 + "error": {"code": "NO_HANDLERS", "error": "No handler registered for this event type"},
228 + }
229 +```
230 +
231 +### 5. Session Tracking Helpers
232 +
233 +`WebSocketManager` maintains lightweight mappings that you can use from handlers:
234 +
235 +```python
236 +all_sids = self.manager.get_sids_for_user() # today: every active sid
237 +maybe_user = self.manager.get_user_for_sid(sid) # currently None or "single_user"
238 +
239 +if updated_payload:
240 + await asyncio.gather(
241 + *[
242 + self.emit_to(other_sid, "dashboard_update", updated_payload)
243 + for other_sid in all_sids if other_sid != sid
244 + ]
245 + )
246 +```
247 +
248 +These helpers are future-proof for multi-tenant evolution and already handy to broadcast to every tab except the caller.
249 +
250 +**Future Multitenancy Mechanics**
251 +- **Registration**: When multi-user support ships, `handle_connect` will resolve the authenticated user identifier (e.g., from Flask session). `register()` will stash that identifier alongside the SID and place it into `user_to_sids[user_id]` while still populating the `allUsers` bucket for backward compatibility.
252 +- **Lookups**: `get_sids_for_user(user_id)` will return the tenant-specific SID set. Omitting the argument (or passing `None`) keeps today’s behaviour and yields the full `allUsers` list. `get_user_for_sid(sid)` will expose whichever identifier was recorded at registration.
253 +- **Utility**: These primitives unlock future features such as sending workspace notifications to every tab owned by the same account, ejecting all sessions for a suspended user, or correlating request/response traffic per tenant without rewriting handlers.
254 +- **Migration Story**: Existing handler code that loops over `get_sids_for_user()` automatically gains tenant-scoped behaviour once callers pass a `user_id`. Tests will exercise both single-user (default) and multi-tenant branches to guarantee compatibility.
255 +
256 +---
257 +
258 +## Frontend Cookbook (`websocket.js`)
259 +
260 +### 1. Connecting
261 +
262 +```javascript
263 +import { getNamespacedClient } from "/js/websocket.js";
264 +
265 +const websocket = getNamespacedClient("/"); // reserved root (diagnostics-only by default)
266 +
267 +// Optional: await the handshake if you need to block UI until the socket is ready
268 +await websocket.connect();
269 +
270 +// Runtime metadata is exposed globally for Alpine stores / harness
271 +console.log(window.runtimeInfo.id, window.runtimeInfo.isDevelopment);
272 +```
273 +
274 +- The module connects lazily when a consumer uses the client API (e.g., `emit`, `request`, `on`). Components may still explicitly `await websocket.connect()` to block rendering on readiness or re-run diagnostics.
275 +- The server enforces an Origin allowlist during the Socket.IO connect handshake (baseline CSWSH mitigation). The browser session cookie remains the authentication mechanism, and CSRF is validated via the Socket.IO `auth` payload (`csrf_token`) plus the runtime-scoped CSRF cookie and session value.
276 +- Socket.IO handles reconnection attempts automatically.
277 +
278 +### Namespaces (end-state)
279 +
280 +- The root namespace (`/`) is reserved and intentionally unhandled by default for application events. Feature code should connect to an explicit namespace (for example `/state_sync`).
281 +- The frontend exposes `createNamespacedClient(namespace)` and `getNamespacedClient(namespace)` (one client instance per namespace per tab). Namespaced clients expose the same minimal API: `emit`, `request`, `on`, `off`.
282 +- Unknown namespaces are rejected deterministically during the Socket.IO connect handshake with a `connect_error` payload:
283 + - `err.message === "UNKNOWN_NAMESPACE"`
284 + - `err.data === { code: "UNKNOWN_NAMESPACE", namespace: "/requested" }`
285 +
286 +### 2. Client Operations
287 +
288 +- **Producers (client → server)** use `emit` and `request`. Payloads must be objects; primitive payloads throw.
289 +- **Consumers (server → client)** register callbacks with `on(eventType, callback)` and remove them with `off()`.
290 +
291 +Example (producer):
292 +
293 +```javascript
294 +await websocket.request("hello_request", { name: this.name }, {
295 + timeoutMs: 1500,
296 + correlationId: `greet-${crypto.randomUUID()}`,
297 +});
298 +```
299 +
300 +Example (consumer):
301 +
302 +```javascript
303 +websocket.on("dashboard_update", (envelope) => {
304 + const { handlerId, correlationId, ts, data } = envelope;
305 + this.debugLog({ handlerId, correlationId, ts });
306 + this.rows = data.rows;
307 +});
308 +
309 +// Later, during cleanup
310 +websocket.off("dashboard_update");
311 +```
312 +
313 +### 3. Envelope Awareness
314 +
315 +Subscribers always receive:
316 +
317 +```javascript
318 +interface ServerDeliveryEnvelope {
319 + handlerId: string;
320 + eventId: string;
321 + correlationId: string;
322 + ts: string; // ISO8601 UTC with millisecond precision
323 + data: object;
324 +}
325 +```
326 +
327 +Even if existing components only look at `data`, you should record `handlerId` and `correlationId` when building new features—doing so simplifies debugging multi-tab flows.
328 +
329 +### 4. Development-Only Logging
330 +
331 +`websocket.debugLog()` writes to the console only when `runtimeInfo.isDevelopment` is true. Use it liberally when diagnosing event flows without polluting production logs.
332 +
333 +```javascript
334 +websocket.debugLog("request", { correlationId: payload.correlationId, timeoutMs });
335 +```
336 +
337 +### 5. Helper Utilities
338 +
339 +`webui/js/websocket.js` exports helper utilities alongside the `websocket` singleton so correlation metadata and envelopes stay consistent:
340 +
341 +- `createCorrelationId(prefix?: string)` returns a UUID-based identifier, optionally prefixed (e.g. `createCorrelationId('hello') → hello-1234…`). Use it when chaining UI actions to backend logs.
342 +- `validateServerEnvelope(envelope)` guarantees subscribers receive the canonical `{ handlerId, eventId, correlationId, ts, data }` shape; throw if the payload is malformed.
343 +
344 +Example:
345 +
346 +```javascript
347 +import { getNamespacedClient, createCorrelationId, validateServerEnvelope } from '/js/websocket.js';
348 +
349 +const websocket = getNamespacedClient('/state_sync');
350 +
351 +const { results } = await websocket.request(
352 + 'hello_request',
353 + { name: this.name },
354 + { correlationId: createCorrelationId('hello') },
355 +);
356 +
357 +websocket.on('dashboard_update', (envelope) => {
358 + const validated = validateServerEnvelope(envelope);
359 + this.rows = validated.data.rows;
360 +});
361 +```
362 +
363 +### 6. Error Handling
364 +
365 +- Producer methods call `websocket.connect()` internally, so they wait for the handshake automatically. They only surface `Error("Not connected")` if the handshake ultimately fails (for example, the user is logged out or the server is down).
366 +- `request()` acknowledgement timeouts reject with `Error("Request timeout")`. Server-side fan-out timeouts (for example `request_all`) are represented as `results[]` entries with `error.code = "TIMEOUT"` (no Promise rejection).
367 +- For large payloads, the client throws before sending and the server rejects frames above the 50 MiB cap (`max_http_buffer_size` on the Socket.IO engine).
368 +
369 +### 7. Startup Broadcast
370 +
371 +- When **Broadcast server restart event** is enabled in Developer settings (on by default) the backend emits a fire-and-forget `server_restart` envelope the first time each connection is established after a process restart. The payload includes `runtimeId` and an ISO8601 timestamp so clients can reconcile cached state.
372 +- Disable the toggle if your deployment pipeline already publishes restart notifications.
373 +
374 +---
375 +
376 +## Frontend Error Handling (Using the Registry)
377 +
378 +Client code should treat `RequestResultItem.error.code` as one of the documented values and branch behavior accordingly. Keep UI decisions localized and reusable.
379 +
380 +Recommended patterns
381 +- Centralize mapping from `WsErrorCode` → user-facing message and remediation hint.
382 +- Always surface hard errors (timeouts); gate debug details by dev flag.
383 +
384 +Example – request()
385 +```javascript
386 +import { getNamespacedClient } from '/js/websocket.js'
387 +
388 +const websocket = getNamespacedClient('/state_sync')
389 +
390 +function renderError(code, message) {
391 + // Map codes to UI copy; keep messages concise
392 + switch (code) {
393 + case 'NO_HANDLERS': return `No handler for this action (${message})`
394 + case 'TIMEOUT': return `Request timed out; try again or increase timeout`
395 + case 'CONNECTION_NOT_FOUND': return `Target connection unavailable; retry after reconnect`
396 + default: return message || 'Unexpected error'
397 + }
398 +}
399 +
400 +const res = await websocket.request('example_event', { foo: 'bar' }, { timeoutMs: 1500 })
401 +for (const item of res.results) {
402 + if (item.ok) {
403 + // use item.data
404 + } else {
405 + const msg = renderError(item.error?.code, item.error?.error)
406 + // show toast/log based on dev flag
407 + console.error('[ws]', msg)
408 + }
409 +}
410 +```
411 +
412 +Subscriptions – envelope handler
413 +```javascript
414 +import { getNamespacedClient } from '/js/websocket.js'
415 +
416 +const websocket = getNamespacedClient('/state_sync')
417 +
418 +websocket.on('example_broadcast', ({ data, handlerId, eventId, correlationId }) => {
419 + // handle data; errors should not typically arrive via broadcast
420 + // correlationId can link UI actions to backend logs
421 +})
422 +```
423 +
424 +See also
425 +- Error Codes Registry (above) for the authoritative code list
426 +- Contracts: `frontend-api.md` for method signatures and response shapes
427 +
428 +---
429 +
430 +## Producer & Consumer Patterns
431 +
432 +### Pattern A – Fire-and-Forget Notification (Server Producer → Client Consumers)
433 +
434 +Backend:
435 +
436 +```python
437 +await self.broadcast(
438 + "notification_broadcast",
439 + {
440 + "message": data["message"],
441 + "level": data.get("level", "info"),
442 + "timestamp": datetime.now(timezone.utc).isoformat(),
443 + },
444 + exclude_sids=sid,
445 + correlation_id=data.get("correlationId"),
446 +)
447 +```
448 +
449 +Frontend:
450 +
451 +```javascript
452 +websocket.on("notification_broadcast", ({ data, correlationId, ts }) => {
453 + notifications.unshift({ ...data, correlationId, ts });
454 +});
455 +```
456 +
457 +### Pattern B – Request/Response With Multi-Handler Aggregation (Client Producer → Server Consumers)
458 +
459 +Client:
460 +
461 +```javascript
462 +const { correlationId, results } = await websocket.request(
463 + "refresh_metrics",
464 + { duration: "1h" },
465 + { timeoutMs: 2_000 }
466 +);
467 +
468 +results.forEach(({ handlerId, ok, data, error }) => {
469 + if (ok) renderMetrics(handlerId, data);
470 + else console.warn(handlerId, error);
471 +});
472 +```
473 +
474 +Server (two handlers listening to the same event):
475 +
476 +```python
477 +class TaskMetrics(WebSocketHandler):
478 + @classmethod
479 + def get_event_types(cls) -> list[str]:
480 + return ["refresh_metrics"]
481 +
482 + async def process_event(self, event_type: str, data: dict, sid: str) -> dict | None:
483 + stats = await self._load_task_metrics(data["duration"])
484 + return {"metrics": stats}
485 +
486 +class HostMetrics(WebSocketHandler):
487 + @classmethod
488 + def get_event_types(cls) -> list[str]:
489 + return ["refresh_metrics"]
490 +
491 + async def process_event(self, event_type: str, data: dict, sid: str) -> dict | None:
492 + return {"metrics": await self._load_host_metrics(data["duration"])}
493 +```
494 +
495 +### Pattern C – Fan-Out `request_all` (Server Producer → Many Client Consumers)
496 +
497 +Backend (server producer asking every tab to confirm a destructive operation):
498 +
499 +```python
500 +confirmations = await self.request_all(
501 + "confirm_close_tab",
502 + {"contextId": context_id},
503 + timeout_ms=5_000,
504 +)
505 +
506 +for entry in confirmations:
507 + self.log.info("%s responded: %s", entry["sid"], entry["results"])
508 +```
509 +
510 +Frontend consumer matching the envelope:
511 +
512 +```javascript
513 +websocket.on("confirm_close_tab", async ({ data, correlationId }) => {
514 + const accepted = await showModalAndAwaitUser(data.contextId);
515 + return { ok: accepted, correlationId, decision: accepted ? "close" : "stay" };
516 +});
517 +```
518 +
519 +### Pattern D – Server Reply Without Using `ack`
520 +
521 +Sometimes you want to acknowledge work immediately but stream additional updates later. Combine `request()` for the initial confirmation and `emit_to()` for follow-up events using the same correlation ID.
522 +
523 +```python
524 +async def process_event(self, event_type: str, data: dict, sid: str) -> dict | None:
525 + if event_type != "start_long_task":
526 + return None
527 +
528 + correlation_id = data.get("correlationId")
529 + asyncio.create_task(self._run_workflow(sid, correlation_id))
530 + return {"accepted": True, "correlationId": correlation_id}
531 +
532 +async def _run_workflow(self, sid: str, correlation_id: str | None):
533 + for step in range(10):
534 + await asyncio.sleep(1)
535 + await self.emit_to(
536 + sid,
537 + "task_progress",
538 + {"step": step, "total": 10},
539 + correlation_id=correlation_id,
540 + )
541 +```
542 +
543 +---
544 +
545 +## Metadata Flow & Envelopes
546 +
547 +### Client → Server Payload
548 +
549 +Producers send an object payload as `data` (never primitives). Request metadata like `timeoutMs` and `correlationId` are passed as method options, not embedded into `data`.
550 +
551 +The manager validates the payload, resolves/creates `correlationId`, and passes a clean copy of `data` to handlers.
552 +
553 +### Server → Client Envelope (mandatory)
554 +
555 +```json
556 +{
557 + "handlerId": "python.websocket_handlers.notifications.NotificationHandler",
558 + "eventId": "b7e2a9cd-2857-4f7a-8bf4-12a736cb6720",
559 + "correlationId": "caller-supplied-or-generated",
560 + "ts": "2025-10-31T13:13:37.123Z",
561 + "data": { "message": "Hello!" }
562 +}
563 +```
564 +
565 +**Guidance:**
566 +
567 +- Use `eventId` alongside frontend logging to spot duplicate deliveries or buffered flushes.
568 +- `correlationId` ties together the user action that triggered the event, even if multiple handlers participate.
569 +- `handlerId` helps you distinguish which handler produced the payload, especially when multiple handlers share the same event type.
570 +
571 +---
572 +
573 +## Diagnostics, Harness & Logging
574 +
575 +### Developer Harness
576 +
577 +- Location: `Settings → Developer → WebSocket Test Harness`.
578 +- Automatic mode drives emit, request, delayed request (default unlimited timeout), subscription persistence, and envelope validation. It asserts envelope metadata (handlerId, eventId, correlationId, ISO8601 timestamps) and correlation carryover.
579 +- Manual buttons let you trigger individual flows and inspect recent payloads.
580 +- Harness hides itself when `runtime.isDevelopment` is false so production builds incur zero overhead.
581 +- Helper APIs (`createCorrelationId`, `validateServerEnvelope`) are exercised end to end; subscription logs record the `server_restart` broadcast emitted on first connection after a runtime restart.
582 +
583 +### WebSocket Event Console
584 +
585 +- Location: `Settings → Developer → WebSocket Event Console`.
586 +- Enabling capture calls `websocket.request("ws_event_console_subscribe", { requestedAt })`. The handler (`DevWebsocketTestHandler`) refuses the subscription outside development mode and registers the SID as a **diagnostic watcher** by calling `WebSocketManager.register_diagnostic_watcher`. Only connected SIDs can subscribe.
587 +- Disabling capture calls `websocket.request("ws_event_console_unsubscribe", {})`. Disconnecting also triggers `WebSocketManager.unregister_diagnostic_watcher`, so stranded watchers never accumulate.
588 +- While at least one watcher exists, the manager streams `ws_dev_console_event` envelopes (documented in `contracts/event-schemas.md`). Each payload contains:
589 + - `kind`: `"inbound" | "outbound" | "lifecycle"`
590 + - `eventType`, `sid`, `targets[]`, delivery/buffer flags
591 + - `resultSummary` (handler counts, per-handler status, durationMs)
592 + - `payloadSummary` (first few keys + byte size)
593 +- Lifecycle broadcasts (`ws_lifecycle_connect` / `ws_lifecycle_disconnect`) are emitted asynchronously via `broadcast(..., diagnostic=True)` so long-running handlers can’t block dispatch.
594 +- The modal UI exposes:
595 + - Start/stop capture (explicitly controls subscription state).
596 + - Resubscribe button (detach + resubscribe) to recover gracefully after Socket.IO reconnects.
597 + - Clear button (resets the in-memory ring buffer).
598 + - “Handled-only” toggle that filters inbound entries to ones that resolved to registered handlers or produced errors.
599 +- When the watcher set becomes empty the manager immediately stops streaming diagnostics, guaranteeing zero steady-state overhead outside development.
600 +
601 +### Instrumentation & Logging
602 +
603 +- `WebSocketManager` offloads handler execution via `DeferredTask` and may record `durationMs` when development diagnostics are active (Event Console watchers subscribed). These metrics flow into the Event Console stream (and may also appear in `request()` / `request_all()` results), keeping steady-state overhead near zero when diagnostics are closed.
604 +- Lifecycle events capture `connectionCount`, ISO8601 timestamps, and SID so dashboards can correlate UI behaviour with connection churn.
605 +- Backend logging: use `PrintStyle.debug/info/warning` and always include `handlerId`, `eventType`, `sid`, and `correlationId`. The manager already logs connection events, missing handlers, and buffer overflows.
606 +- Frontend logging: `websocket.debugLog()` mirrors backend debug messages but only when `window.runtimeInfo.isDevelopment` is true.
607 +
608 +### Access Logs & Transport Troubleshooting
609 +
610 +- Settings → Developer includes a persisted `uvicorn_access_logs_enabled` switch. When enabled, `run_ui.py` enables Uvicorn access logs so transport issues (CORS, handshake failures) can be traced.
611 +- The long-standing `websocket_server_restart_enabled` switch (same section) controls whether newly connected clients receive the `server_restart` broadcast that carries `runtimeId` metadata.
612 +
613 +### Common Issues
614 +
615 +1. **`CONNECTION_NOT_FOUND`** – `emit_to` called with an SID that never existed or expired long ago. Use `get_sids_for_user` before emitting or guard on connection presence.
616 +2. **Timeout Rejections** – `request()` and `request_all()` reject only when the transport times out, not when a handler takes too long. Inspect the returned result arrays for `TIMEOUT` entries and consider increasing `timeoutMs`.
617 +3. **Origin Rejected** – the Socket.IO handshake was rejected because the `Origin` header did not match the expected UI origin. Ensure you access the UI and the WebSocket endpoint on the same scheme/host/port, and verify any reverse proxy preserves the `Origin` header.
618 +4. **Diagnostics Subscriptions Failing** – only available in development mode and for connected SIDs. Verify the browser tab still holds an active session and that `window.runtimeInfo.isDevelopment` is true before opening the modal.
619 +
620 +---
621 +
622 +## Best Practices Checklist
623 +
624 +- [ ] Always validate inbound payloads in `process_event` (required fields, type constraints, length limits).
625 +- [ ] Propagate `correlationId` through multi-step workflows so logs and envelopes align.
626 +- [ ] Respect the 50 MB payload cap; prefer HTTP + polling for bulk data transfers.
627 +- [ ] Ensure long-running operations emit progress via `emit_to` or switch to an async task with periodic updates.
628 +- [ ] Buffer-sensitive actions (`emit_to`) should handle `ConnectionNotFoundError` from unknown SIDs gracefully.
629 +- [ ] When adding new handlers, update the developer harness if new scenarios need coverage.
630 +- [ ] Keep `PrintStyle` logs meaningful—include `handlerId`, `eventType`, `sid`, and `correlationId`.
631 +- [ ] In Alpine components, call `websocket.off()` during teardown to avoid duplicate subscriptions.
632 +
633 +---
634 +
635 +## Quick Reference Tables
636 +
637 +### Operation Matrix
638 +
639 +| Direction | API | Ack? | Filters | Notes |
640 +|-----------|-----|------|---------|-------|
641 +| Client → Server | `emit(event, data, { correlationId? })` | No | None | Fire-and-forget. |
642 +| Client → Server | `request(event, data, { timeoutMs?, correlationId? })` | Yes (`{ correlationId, results[] }`) | None | Aggregates per handler. Timeout entries appear inside `results`. |
643 +| Server → Client | `emit_to(sid, ...)` | No | None | Raises `ConnectionNotFoundError` for unknown `sid`. Buffers if disconnected. |
644 +| Server → Client | `broadcast(...)` | No | `exclude_sids` only | Iterates over current connections; uses the same envelope as `emit_to`. |
645 +| Server → Client | `request(...)` | Yes (`{ correlationId, results[] }`) | None | Equivalent of client `request` but targeted at one SID from the server. |
646 +| Server → Client | `request_all(...)` | Yes (`[{ sid, correlationId, results[] }]`) | None | Server-initiated fan-out. |
647 +
648 +### Metadata Cheat Sheet
649 +
650 +| Field | Produced By | Guarantees |
651 +|-------|-------------|------------|
652 +| `correlationId` | Manager | Present on every response/envelope. Caller-supplied ID is preserved; otherwise manager generates UUIDv4 hex. |
653 +| `eventId` | Manager | Unique UUIDv4 per server→client delivery. Helpful for dedup / auditing. |
654 +| `handlerId` | Handler / Manager | Deterministic value `module.Class`. Used for results. |
655 +| `ts` | Manager | ISO8601 UTC with millisecond precision. Replaces `+00:00` with `Z`. |
656 +| `results[]` | Manager | Array of `{ handlerId, ok, data?, error? }`. Errors include `code`, `error`, and optional `details`. |
657 +
658 +---
659 +
660 +## Further Reading
661 +
662 +- **QuickStart** – [`specs/003-websocket-event-handlers/quickstart.md`](../specs/003-websocket-event-handlers/quickstart.md) for a step-by-step introduction.
663 +- **Contracts** – Backend, frontend, schema, and security contracts define the canonical API surface:
664 + - [`websocket-handler-interface.md`](../specs/003-websocket-event-handlers/contracts/websocket-handler-interface.md)
665 + - [`frontend-api.md`](../specs/003-websocket-event-handlers/contracts/frontend-api.md)
666 + - [`event-schemas.md`](../specs/003-websocket-event-handlers/contracts/event-schemas.md)
667 + - [`security-contract.md`](../specs/003-websocket-event-handlers/contracts/security-contract.md)
668 +- **Implementation Reference** – Inspect `python/helpers/websocket_manager.py`, `python/helpers/websocket.py`, `webui/js/websocket.js`, and the developer harness in `webui/components/settings/developer/websocket-test-store.js` for concrete examples.
669 +
670 +> **Tip:** When extending the infrastructure (new metadata) start by updating the contracts, sync the manager/frontend helpers, and then document the change here so producers and consumers stay in lockstep.
671 +
672 +## Error Codes Registry (Draft for Phase 6)
673 +
674 +The WebSocket stack standardizes backend error codes returned in `RequestResultItem.error.code`. This registry documents the currently used codes and their intended meaning. Client and server implementations should reference these values verbatim (UPPER_SNAKE_CASE).
675 +
676 +| Code | Scope | Meaning | Typical Remediation | Example Payload |
677 +|------|-------|---------|---------------------|-----------------|
678 +| `NO_HANDLERS` | Manager routing | No handler is registered for the requested `eventType`. | Register a handler for the event or correct the event name. | `{ "handlerId": "WebSocketManager", "ok": false, "error": { "code": "NO_HANDLERS", "error": "No handler for 'missing'" } }` |
679 +| `TIMEOUT` | Aggregated or single request | The request exceeded `timeoutMs`. | Increase `timeoutMs`, reduce handler processing time, or split work. | `{ "handlerId": "ExampleHandler", "ok": false, "error": { "code": "TIMEOUT", "error": "Request timeout" } }` |
680 +| `CONNECTION_NOT_FOUND` | Single‑sid request | Target `sid` is not connected/known. | Use an active `sid` or retry after reconnect. | `{ "handlerId": "WebSocketManager", "ok": false, "error": { "code": "CONNECTION_NOT_FOUND", "error": "Connection 'sid-123' not found" } }` |
681 +| `HARNESS_UNKNOWN_EVENT` | Developer harness | Harness test handler received an unsupported event name. | Update harness sources or disable the step before running automation. | `{ "handlerId": "python.websocket_handlers.dev_websocket_test_handler.DevWebsocketTestHandler", "ok": false, "error": { "code": "HARNESS_UNKNOWN_EVENT", "error": "Unhandled event", "details": "ws_tester_foo" } }` |
682 +
683 +Notes
684 +- Error payload shape follows the contract documented in `contracts/event-schemas.md` (`RequestResultItem.error`).
685 +- Codes are case‑sensitive. Use exactly as listed.
686 +- Future codes will be appended here and referenced by inline docstrings/JSDoc.
687 +
688 +### Client-Side Error Codes (Draft)
689 +
690 +The frontend can originate errors during validation, connection, or request execution. Today these surface as thrown exceptions/promise rejections (not as `RequestResultItem`). When server→client request/ack lands in the future, these codes will also be serialised in `RequestResultItem.error.code` for protocol symmetry.
691 +
692 +| Code | Scope | Current Delivery | Meaning | Typical Remediation | Example |
693 +|------|-------|------------------|---------|---------------------|---------|
694 +| `VALIDATION_ERROR` | Producer options / payload | Exception (throw) | Invalid options (e.g., bad `timeoutMs`/`correlationId`) or non-object payload | Fix caller options and payload shapes | `new Error("timeoutMs must be a non-negative number")` |
695 +| `PAYLOAD_TOO_LARGE` | Size precheck (50MB cap) | Exception (throw) | Client precheck rejects payloads exceeding cap before emit | Reduce payload or chunk via HTTP; keep binaries off WS | `new Error("Payload size exceeds maximum (.. > .. bytes)")` |
696 +| `NOT_CONNECTED` | Socket status | Exception (throw) | Auto-connect could not establish a session (user logged out, server offline, handshake rejected) | Check login state, server availability, and Origin policy; optional `await websocket.connect()` for diagnostics | `new Error("Not connected")` |
697 +| `REQUEST_TIMEOUT` | request() | Not used (end-state) | Timeouts are represented inside `results[]` as `error.code="TIMEOUT"` (Promise resolves). | Inspect `results[]` for `TIMEOUT` items and handle in UI. | N/A |
698 +| `CONNECT_ERROR` | Socket connect_error | Exception (throw/log) | Transport/handshake failure | Check server availability, CORS, or network | `new Error("WebSocket connection failed: ...")` |
699 +
700 +Notes
701 +- These are currently local exceptions, not part of the aggregated results payload. Calling code should `try/catch` or handle promise rejections.
702 +- When server→client request/ack is introduced, the same codes will be serialised into `RequestResultItem.error.code` to maintain symmetry with backend codes.
703 +- Prefer branching on `code` when available; avoid coupling to full message strings.
704 +
705 +### IDE Hints (Non‑enforcing)
706 +
707 +To surface recognized codes without adding toolchain dependencies, front‑end can use a JSDoc union type near the helper exports:
708 +
709 +```javascript
710 +/** @typedef {('NO_HANDLERS'|'TIMEOUT'|'CONNECTION_NOT_FOUND')} WsErrorCode */
711 +```
712 +
713 +Back‑end can reference this registry via concise docstrings at error construction points (e.g., `_build_error_result`) to improve discoverability.
714 +
715 +---
716 +
717 +## Phase 6 – Registry & Helper Work Status
718 +
719 +Current status
720 +- This registry table is drafted and linked; it documents codes already produced by the manager/helpers today.
721 +
722 +Remaining work (tracked in Phase 6 tasks)
723 +- T148: Ensure the registry is complete and cross‑referenced from comments/docstrings (backend) and JSDoc typedefs (frontend). No new linter/tooling.
724 +- T144: Reference the registry from contracts and quickstart examples; align all examples to documented codes.
725 +- T141/T143: Add/adjust tests to assert known codes only in helper/manager paths.
726 +- T145–T147: Ensure the harness logs/validates codes in envelopes/results as part of the automatic and manual suites.
727 +
728 +Related references
729 +- [`event-schemas.md`](../specs/003-websocket-event-handlers/contracts/event-schemas.md)
730 +- [`websocket-handler-interface.md`](../specs/003-websocket-event-handlers/contracts/websocket-handler-interface.md)
731 +- [`frontend-api.md`](../specs/003-websocket-event-handlers/contracts/frontend-api.md)
python/api/api_log_get.py
+1 -1
@@ -55,7 +55,7 @@ class ApiLogGet(ApiHandler):
55 "returned_items": len(log_items),
56 "start_position": start_pos,
57 "progress": context.log.progress,
58 - "progress_active": context.log.progress_active,
58 + "progress_active": bool(context.log.progress_active),
59 "items": log_items
60 }
61 }
python/api/chat_create.py
+5 -1
@@ -12,7 +12,7 @@ class CreateChat(ApiHandler):
12
13 # context instance - get or create
14 current_context = AgentContext.get(current_ctxid)
15 -
15 +
16 # get/create new context
17 new_context = self.use_context(new_ctxid)
18
@@ -26,6 +26,10 @@ class CreateChat(ApiHandler):
26 # if current_data_2:
27 # new_context.set_output_data(projects.CONTEXT_DATA_KEY_PROJECT, current_data_2)
28
29 + # New context should appear in other tabs' chat lists via state_push.
30 + from python.helpers.state_monitor_integration import mark_dirty_all
31 + mark_dirty_all(reason="api.chat_create.CreateChat")
32 +
33 return {
34 "ok": True,
35 "ctxid": new_context.id,
python/api/chat_remove.py
+4
@@ -25,6 +25,10 @@ class RemoveChat(ApiHandler):
25 for task in tasks:
26 await scheduler.remove_task_by_uuid(task.uuid)
27
28 + # Context removal affects global chat/task lists in all tabs.
29 + from python.helpers.state_monitor_integration import mark_dirty_all
30 + mark_dirty_all(reason="api.chat_remove.RemoveChat")
31 +
32 return {
33 "message": "Context removed.",
34 }
python/api/chat_reset.py
+4
@@ -18,6 +18,10 @@ class Reset(ApiHandler):
18 persist_chat.save_tmp_chat(context)
19 persist_chat.remove_msg_files(ctxid)
20
21 + # Reset updates context metadata (log guid/version) and must refresh other tabs' lists.
22 + from python.helpers.state_monitor_integration import mark_dirty_all
23 + mark_dirty_all(reason="api.chat_reset.Reset")
24 +
25 return {
26 "message": "Agent restarted.",
27 }
python/api/message_queue_add.py
+4 -1
@@ -1,6 +1,7 @@
1 from python.helpers.api import ApiHandler, Request, Response
2 from python.helpers import message_queue as mq
3 from agent import AgentContext
4 +from python.helpers.state_monitor_integration import mark_dirty_for_context
5
6
7 class MessageQueueAdd(ApiHandler):
@@ -13,9 +14,11 @@ class MessageQueueAdd(ApiHandler):
14
15 text = input.get("text", "").strip()
16 attachments = input.get("attachments", []) # filenames from /upload API
17 + item_id = input.get("item_id")
18
19 if not text and not attachments:
20 return Response("Empty message", status=400)
21
20 - item = mq.add(context, text, attachments)
22 + item = mq.add(context, text, attachments, item_id)
23 + mark_dirty_for_context(context.id, reason="message_queue_add")
24 return {"ok": True, "item_id": item["id"], "queue_length": len(mq.get_queue(context))}
python/api/message_queue_remove.py
+3 -1
@@ -1,7 +1,7 @@
1 from python.helpers.api import ApiHandler, Request, Response
2 from python.helpers import message_queue as mq
3 from agent import AgentContext
4 -
4 +from python.helpers.state_monitor_integration import mark_dirty_for_context
5
6 class MessageQueueRemove(ApiHandler):
7 """Remove message(s) from queue."""
@@ -13,4 +13,6 @@ class MessageQueueRemove(ApiHandler):
13
14 item_id = input.get("item_id") # None means clear all
15 remaining = mq.remove(context, item_id)
16 + mark_dirty_for_context(context.id, reason="message_queue_remove")
17 +
18 return {"ok": True, "remaining": remaining}
python/api/message_queue_send.py
+2 -1
@@ -1,7 +1,7 @@
1 from python.helpers.api import ApiHandler, Request, Response
2 from python.helpers import message_queue as mq
3 from agent import AgentContext
4 -
4 +from python.helpers.state_monitor_integration import mark_dirty_for_context
5
6 class MessageQueueSend(ApiHandler):
7 """Send queued message(s) immediately."""
@@ -27,4 +27,5 @@ class MessageQueueSend(ApiHandler):
27 return Response("Item not found", status=404)
28
29 mq.send_message(context, item)
30 + mark_dirty_for_context(context.id, reason="message_queue_send")
31 return {"ok": True, "sent_item_id": item["id"]}
python/api/notifications_history.py
+3 -2
@@ -13,8 +13,9 @@ class NotificationsHistory(ApiHandler):
13 notification_manager = AgentContext.get_notification_manager()
14
15 # Return all notifications for history modal
16 + notifications = notification_manager.output_all()
17 return {
17 - "notifications": [n.output() for n in notification_manager.notifications],
18 + "notifications": notifications,
19 "guid": notification_manager.guid,
19 - "count": len(notification_manager.notifications),
20 + "count": len(notifications),
21 }
python/api/notifications_mark_read.py
+4 -8
@@ -21,15 +21,11 @@ class NotificationsMarkRead(ApiHandler):
21 if not notification_ids:
22 return {"success": False, "error": "No notification IDs provided"}
23
24 + if not isinstance(notification_ids, list):
25 + return {"success": False, "error": "notification_ids must be a list"}
26 +
27 # Mark specific notifications as read
25 - marked_count = 0
26 - for notification_id in notification_ids:
27 - # Find notification by ID and mark as read
28 - for notification in notification_manager.notifications:
29 - if notification.id == notification_id and not notification.read:
30 - notification.mark_read()
31 - marked_count += 1
32 - break
28 + marked_count = notification_manager.mark_read_by_ids(notification_ids)
29
30 return {
31 "success": True,
python/api/poll.py
+7 -120
@@ -1,127 +1,14 @@
1 from python.helpers.api import ApiHandler, Request, Response
2
3 -from agent import AgentContext, AgentContextType
4 -
5 -from python.helpers.task_scheduler import TaskScheduler
6 -from python.helpers.localization import Localization
7 -from python.helpers.dotenv import get_dotenv_value
3 +from python.helpers.state_snapshot import build_snapshot
4
5
6 class Poll(ApiHandler):
7
8 async def process(self, input: dict, request: Request) -> dict | Response:
13 - ctxid = input.get("context", "")
14 - from_no = input.get("log_from", 0)
15 - notifications_from = input.get("notifications_from", 0)
16 -
17 - # Get timezone from input (default to dotenv default or UTC if not provided)
18 - timezone = input.get("timezone", get_dotenv_value("DEFAULT_USER_TIMEZONE", "UTC"))
19 - Localization.get().set_timezone(timezone)
20 -
21 - # context instance - get or create only if ctxid is provided
22 - if ctxid:
23 - try:
24 - context = self.use_context(ctxid, create_if_not_exists=False)
25 - except Exception as e:
26 - context = None
27 - else:
28 - context = None
29 -
30 - # Get logs only if we have a context
31 - logs = context.log.output(start=from_no) if context else []
32 -
33 - # Get notifications from global notification manager
34 - notification_manager = AgentContext.get_notification_manager()
35 - notifications = notification_manager.output(start=notifications_from)
36 -
37 - # loop AgentContext._contexts
38 -
39 - # Get a task scheduler instance
40 - scheduler = TaskScheduler.get()
41 -
42 - # Always reload the scheduler on each poll to ensure we have the latest task state
43 - # await scheduler.reload() # does not seem to be needed
44 -
45 - # loop AgentContext._contexts and divide into contexts and tasks
46 -
47 - ctxs = []
48 - tasks = []
49 - processed_contexts = set() # Track processed context IDs
50 -
51 - all_ctxs = list(AgentContext._contexts.values())
52 - # First, identify all tasks
53 - for ctx in all_ctxs:
54 - # Skip if already processed
55 - if ctx.id in processed_contexts:
56 - continue
57 -
58 - # Skip BACKGROUND contexts as they should be invisible to users
59 - if ctx.type == AgentContextType.BACKGROUND:
60 - processed_contexts.add(ctx.id)
61 - continue
62 -
63 - # Create the base context data that will be returned
64 - context_data = ctx.output()
65 -
66 - context_task = scheduler.get_task_by_uuid(ctx.id)
67 - # Determine if this is a task-dedicated context by checking if a task with this UUID exists
68 - is_task_context = (
69 - context_task is not None and context_task.context_id == ctx.id
70 - )
71 -
72 - if not is_task_context:
73 - ctxs.append(context_data)
74 - else:
75 - # If this is a task, get task details from the scheduler
76 - task_details = scheduler.serialize_task(ctx.id)
77 - if task_details:
78 - # Add task details to context_data with the same field names
79 - # as used in scheduler endpoints to maintain UI compatibility
80 - context_data.update({
81 - "task_name": task_details.get("name"), # name is for context, task_name for the task name
82 - "uuid": task_details.get("uuid"),
83 - "state": task_details.get("state"),
84 - "type": task_details.get("type"),
85 - "system_prompt": task_details.get("system_prompt"),
86 - "prompt": task_details.get("prompt"),
87 - "last_run": task_details.get("last_run"),
88 - "last_result": task_details.get("last_result"),
89 - "attachments": task_details.get("attachments", []),
90 - "context_id": task_details.get("context_id"),
91 - })
92 -
93 - # Add type-specific fields
94 - if task_details.get("type") == "scheduled":
95 - context_data["schedule"] = task_details.get("schedule")
96 - elif task_details.get("type") == "planned":
97 - context_data["plan"] = task_details.get("plan")
98 - else:
99 - context_data["token"] = task_details.get("token")
100 -
101 - tasks.append(context_data)
102 -
103 - # Mark as processed
104 - processed_contexts.add(ctx.id)
105 -
106 - # Sort tasks and chats by their creation date, descending
107 - ctxs.sort(key=lambda x: x["created_at"], reverse=True)
108 - tasks.sort(key=lambda x: x["created_at"], reverse=True)
109 -
110 - # data from this server
111 - return {
112 - "deselect_chat": ctxid and not context,
113 - "context": context.id if context else "",
114 - "contexts": ctxs,
115 - "tasks": tasks,
116 - "logs": logs,
117 - "log_guid": context.log.guid if context else "",
118 - "log_version": len(context.log.updates) if context else 0,
119 - "log_progress": context.log.progress if context else 0,
120 - "log_progress_active": context.log.progress_active if context else False,
121 - "paused": context.paused if context else False,
122 - "notifications": notifications,
123 - "notifications_guid": notification_manager.guid,
124 - "notifications_version": len(notification_manager.updates),
125 - "message_queue": context.output_data.get("message_queue", []) if context else [],
126 - "running": context.is_running() if context else False,
127 - }
9 + return await build_snapshot(
10 + context=input.get("context"),
11 + log_from=input.get("log_from", 0),
12 + notifications_from=input.get("notifications_from", 0),
13 + timezone=input.get("timezone"),
14 + )
python/helpers/api.py
+3 -1
@@ -10,12 +10,14 @@ from python.helpers.print_style import PrintStyle
10 from python.helpers.errors import format_error
11 from werkzeug.serving import make_server
12
13 +ThreadLockType = Union[threading.Lock, threading.RLock]
14 +
15 Input = dict
16 Output = Union[Dict[str, Any], Response, TypedDict] # type: ignore
17
18
19 class ApiHandler:
18 - def __init__(self, app: Flask, thread_lock: threading.Lock):
20 + def __init__(self, app: Flask, thread_lock: ThreadLockType):
21 self.app = app
22 self.thread_lock = thread_lock
23
python/helpers/log.py
+173 -91
@@ -1,20 +1,44 @@
1 -from dataclasses import dataclass, field
1 +import copy
2 import json
3 +import threading
4 import time
4 -from typing import Any, Literal, Optional, Dict, TypeVar, TYPE_CHECKING
5 -
6 -T = TypeVar("T")
5 import uuid
8 -from collections import OrderedDict # Import OrderedDict
9 -from python.helpers.strings import truncate_text_by_ratio
10 -import copy
11 -from typing import TypeVar
6 +from collections import OrderedDict
7 +from dataclasses import dataclass
8 +from typing import Any, Literal, Optional, TYPE_CHECKING, TypeVar, cast
9 +
10 from python.helpers.secrets import get_secrets_manager
11 +from python.helpers.strings import truncate_text_by_ratio
12
13
14 if TYPE_CHECKING:
15 from agent import AgentContext
16
17 +
18 +_MARK_DIRTY_ALL = None
19 +_MARK_DIRTY_FOR_CONTEXT = None
20 +
21 +
22 +def _lazy_mark_dirty_all(*, reason: str | None = None) -> None:
23 + # Lazy import to avoid circular import at module load time (AgentContext -> Log).
24 + global _MARK_DIRTY_ALL
25 + if _MARK_DIRTY_ALL is None:
26 + from python.helpers.state_monitor_integration import mark_dirty_all
27 +
28 + _MARK_DIRTY_ALL = mark_dirty_all
29 + _MARK_DIRTY_ALL(reason=reason)
30 +
31 +
32 +def _lazy_mark_dirty_for_context(context_id: str, *, reason: str | None = None) -> None:
33 + # Lazy import to avoid circular import at module load time (AgentContext -> Log).
34 + global _MARK_DIRTY_FOR_CONTEXT
35 + if _MARK_DIRTY_FOR_CONTEXT is None:
36 + from python.helpers.state_monitor_integration import mark_dirty_for_context
37 +
38 + _MARK_DIRTY_FOR_CONTEXT = mark_dirty_for_context
39 + _MARK_DIRTY_FOR_CONTEXT(context_id, reason=reason)
40 +
41 +
42 T = TypeVar("T")
43
44 Type = Literal[
@@ -69,14 +93,14 @@ def _truncate_value(val: T) -> T:
93 v = val[k]
94 del val[k]
95 val[_truncate_key(k)] = _truncate_value(v)
72 - return val
96 + return cast(T, val)
97 # If list or tuple, recursively truncate each item
98 if isinstance(val, list):
99 for i in range(len(val)):
100 val[i] = _truncate_value(val[i])
77 - return val
101 + return cast(T, val)
102 if isinstance(val, tuple):
79 - return tuple(_truncate_value(x) for x in val) # type: ignore
103 + return cast(T, tuple(_truncate_value(x) for x in val))
104
105 # Convert non-str values to json for consistent length measurement
106 if isinstance(val, str):
@@ -94,7 +118,7 @@ def _truncate_value(val: T) -> T:
118 removed = len(raw) - VALUE_MAX_LEN
119 replacement = f"\n\n<< {removed} Characters hidden >>\n\n"
120 truncated = truncate_text_by_ratio(raw, VALUE_MAX_LEN, replacement, ratio=0.3)
97 - return truncated
121 + return cast(T, truncated)
122
123
124 def _truncate_content(text: str | None, type: Type) -> str:
@@ -119,9 +143,6 @@ def _truncate_content(text: str | None, type: Type) -> str:
143 return truncated
144
145
122 -
123 -
124 -
146 @dataclass
147 class LogItem:
148 log: "Log"
@@ -191,10 +212,14 @@ class LogItem:
212 class Log:
213
214 def __init__(self):
194 - self.context: "AgentContext|None" = None # set from outside
215 + self._lock = threading.RLock()
216 + self.context: "AgentContext|None" = None # set from outside
217 self.guid: str = str(uuid.uuid4())
218 self.updates: list[int] = []
219 self.logs: list[LogItem] = []
220 + self.progress: str = ""
221 + self.progress_no: int = 0
222 + self.progress_active: bool = False
223 self.set_initial_progress()
224
225 def log(
@@ -207,26 +232,24 @@ class Log:
232 id: Optional[str] = None,
233 **kwargs,
234 ) -> LogItem:
235 + with self._lock:
236 + # add a minimal item to the log
237 + # Determine agent number from streaming agent
238 + agentno = 0
239 + if self.context and self.context.streaming_agent:
240 + agentno = self.context.streaming_agent.number
241 +
242 + item = LogItem(
243 + log=self,
244 + no=len(self.logs),
245 + type=type,
246 + agentno=agentno,
247 + )
248
211 - # add a minimal item to the log
212 - # Determine agent number from streaming agent
213 - agentno = 0
214 - if self.context and self.context.streaming_agent:
215 - agentno = self.context.streaming_agent.number
216 -
217 - item = LogItem(
218 - log=self,
219 - no=len(self.logs),
220 - type=type,
221 - agentno=agentno,
222 - )
223 - # Set duration on previous item and mark it as updated
224 - if self.logs:
225 - prev = self.logs[-1]
226 - self.updates += [prev.no]
227 - self.logs.append(item)
249 + self.logs.append(item)
250
229 - # and update it (to have just one implementation)
251 + # Update outside the lock - the heavy masking/truncation work should not hold
252 + # the lock; we only need locking while mutating shared arrays/fields.
253 self._update_item(
254 no=item.no,
255 type=type,
@@ -235,8 +258,11 @@ class Log:
258 kvps=kvps,
259 update_progress=update_progress,
260 id=id,
261 + notify_state_monitor=False,
262 **kwargs,
263 )
264 +
265 + self._notify_state_monitor()
266 return item
267
268 def _update_item(
@@ -248,85 +274,141 @@ class Log:
274 kvps: dict | None = None,
275 update_progress: ProgressUpdate | None = None,
276 id: Optional[str] = None,
277 + notify_state_monitor: bool = True,
278 **kwargs,
279 ):
253 - item = self.logs[no]
280 + # Capture the effective type for truncation without holding the lock during
281 + # masking/truncation work.
282 + with self._lock:
283 + current_type = self.logs[no].type
284 + type_for_truncation = type if type is not None else current_type
285
255 - if id is not None:
256 - item.id = id
257 -
258 - if type is not None:
259 - item.type = type
260 -
261 - if update_progress is not None:
262 - item.update_progress = update_progress
263 -
264 -
265 - # adjust all content before processing
286 + heading_out: str | None = None
287 if heading is not None:
267 - heading = self._mask_recursive(heading)
268 - heading = _truncate_heading(heading)
269 - item.heading = heading
288 + heading_out = _truncate_heading(self._mask_recursive(heading))
289 +
290 + content_out: str | None = None
291 if content is not None:
271 - content = self._mask_recursive(content)
272 - content = _truncate_content(content, item.type)
273 - item.content = content
292 + content_out = _truncate_content(self._mask_recursive(content), type_for_truncation)
293 +
294 + kvps_out: OrderedDict | None = None
295 if kvps is not None:
275 - kvps = OrderedDict(copy.deepcopy(kvps))
276 - kvps = self._mask_recursive(kvps)
277 - kvps = _truncate_value(kvps)
278 - item.kvps = kvps
279 - elif item.kvps is None:
280 - item.kvps = OrderedDict()
281 - if kwargs:
282 - kwargs = copy.deepcopy(kwargs)
283 - kwargs = self._mask_recursive(kwargs)
284 - item.kvps.update(kwargs)
296 + kvps_out_tmp = OrderedDict(copy.deepcopy(kvps))
297 + kvps_out_tmp = self._mask_recursive(kvps_out_tmp)
298 + kvps_out_tmp = _truncate_value(kvps_out_tmp)
299 + kvps_out = OrderedDict(kvps_out_tmp)
300
286 - self.updates += [item.no]
287 - self._update_progress_from_item(item)
301 + kwargs_out: dict | None = None
302 + if kwargs:
303 + kwargs_out = copy.deepcopy(kwargs)
304 + kwargs_out = self._mask_recursive(kwargs_out)
305 +
306 + with self._lock:
307 + item = self.logs[no]
308 +
309 + if id is not None:
310 + item.id = id
311 +
312 + if type is not None:
313 + item.type = type
314 +
315 + if update_progress is not None:
316 + item.update_progress = update_progress
317 +
318 + if heading_out is not None:
319 + item.heading = heading_out
320 +
321 + if content_out is not None:
322 + item.content = content_out
323 +
324 + if kvps_out is not None:
325 + item.kvps = kvps_out
326 + elif item.kvps is None:
327 + item.kvps = OrderedDict()
328 +
329 + if kwargs_out:
330 + if item.kvps is None:
331 + item.kvps = OrderedDict()
332 + item.kvps.update(kwargs_out)
333 +
334 + self.updates.append(item.no)
335 +
336 + if item.heading and item.update_progress != "none":
337 + if item.no >= self.progress_no:
338 + self.progress = item.heading
339 + self.progress_no = (
340 + item.no if item.update_progress == "persistent" else -1
341 + )
342 + self.progress_active = True
343 + if notify_state_monitor:
344 + self._notify_state_monitor_for_context_update()
345 +
346 + def _notify_state_monitor(self) -> None:
347 + ctx = self.context
348 + if not ctx:
349 + return
350 + # Logs update both the active chat stream (sid-bound) and the global chats list
351 + # (context metadata like last_message/log_version). Broadcast so all tabs refresh
352 + # their chat/task lists without leaking logs (logs are still scoped per-sid).
353 + _lazy_mark_dirty_all(reason="log.Log._notify_state_monitor")
354 +
355 + def _notify_state_monitor_for_context_update(self) -> None:
356 + ctx = self.context
357 + if not ctx:
358 + return
359 + # Log item updates only need to refresh the active chat stream for any sid
360 + # currently projecting this context. Avoid global fanout at high frequency.
361 + _lazy_mark_dirty_for_context(ctx.id, reason="log.Log._update_item")
362
363 def set_progress(self, progress: str, no: int = 0, active: bool = True):
364 progress = self._mask_recursive(progress)
365 progress = _truncate_progress(progress)
292 - self.progress = progress
293 - if not no:
294 - no = len(self.logs)
295 - self.progress_no = no
296 - self.progress_active = active
366 + changed = False
367 + ctx = self.context
368 + with self._lock:
369 + prev_progress = self.progress
370 + prev_active = self.progress_active
371 +
372 + self.progress = progress
373 + if not no:
374 + no = len(self.logs)
375 + self.progress_no = no
376 + self.progress_active = active
377 +
378 + changed = self.progress != prev_progress or self.progress_active != prev_active
379 +
380 + if changed and ctx:
381 + # Progress changes are included in every snapshot, but push sync requires a
382 + # dirty mark even when no log items changed.
383 + _lazy_mark_dirty_for_context(ctx.id, reason="log.Log.set_progress")
384
385 def set_initial_progress(self):
386 self.set_progress("Waiting for input", 0, False)
387
388 def output(self, start=None, end=None):
302 - if start is None:
303 - start = 0
304 - if end is None:
305 - end = len(self.updates)
389 + with self._lock:
390 + if start is None:
391 + start = 0
392 + if end is None:
393 + end = len(self.updates)
394 + updates = self.updates[start:end]
395 + logs = list(self.logs)
396
397 out = []
398 seen = set()
309 - for update in self.updates[start:end]:
310 - if update not in seen:
311 - out.append(self.logs[update].output())
399 + for update in updates:
400 + if update not in seen and update < len(logs):
401 + out.append(logs[update].output())
402 seen.add(update)
313 -
403 return out
404
405 def reset(self):
317 - self.guid = str(uuid.uuid4())
318 - self.updates = []
319 - self.logs = []
406 + with self._lock:
407 + self.guid = str(uuid.uuid4())
408 + self.updates = []
409 + self.logs = []
410 self.set_initial_progress()
411
322 - def _update_progress_from_item(self, item: LogItem):
323 - if item.heading and item.update_progress != "none":
324 - if item.no >= self.progress_no:
325 - self.set_progress(
326 - item.heading,
327 - (item.no if item.update_progress == "persistent" else -1),
328 - )
329 -
412 def _mask_recursive(self, obj: T) -> T:
413 """Recursively mask secrets in nested objects."""
414 try:
@@ -341,13 +423,13 @@ class Log:
423 # print(f"Context ID mismatch: {self_id} != {current_id}")
424
425 if isinstance(obj, str):
344 - return secrets_mgr.mask_values(obj)
426 + return cast(Any, secrets_mgr.mask_values(obj))
427 elif isinstance(obj, dict):
428 return {k: self._mask_recursive(v) for k, v in obj.items()} # type: ignore
429 elif isinstance(obj, list):
430 return [self._mask_recursive(item) for item in obj] # type: ignore
431 else:
432 return obj
351 - except Exception as _e:
433 + except Exception:
434 # If masking fails, return original object
353 - return obj
\ No newline at end of file
435 + return obj
python/helpers/mcp_server.py
+45 -43
@@ -1,9 +1,11 @@
1 import os
2 +import asyncio
3 from typing import Annotated, Literal, Union
4 from urllib.parse import urlparse
5 from openai import BaseModel
6 from pydantic import Field
6 -from fastmcp import FastMCP # type: ignore
7 +import fastmcp
8 +from fastmcp import FastMCP
9 import contextvars
10
11 from agent import AgentContext, AgentContextType, UserMessage
@@ -15,7 +17,8 @@ from starlette.middleware import Middleware
17 from starlette.middleware.base import BaseHTTPMiddleware
18 from starlette.exceptions import HTTPException as StarletteHTTPException
19 from starlette.types import ASGIApp, Receive, Scope, Send
18 -from fastmcp.server.http import create_sse_app # type: ignore
20 +from fastmcp.server.http import create_sse_app, create_base_app, build_resource_metadata_url # type: ignore
21 +from starlette.routing import Mount # type: ignore
22 from starlette.requests import Request
23 import threading
24
@@ -319,37 +322,39 @@ class DynamicMcpProxy:
322 message_path = f"/t-{self.token}/messages/"
323
324 # Update settings in the MCP server instance if provided
322 - mcp_server.settings.message_path = message_path
323 - mcp_server.settings.sse_path = sse_path
325 + # Keep FastMCP settings synchronized so downstream helpers that read these
326 + # values (including deprecated accessors) resolve the runtime paths.
327 + fastmcp.settings.message_path = message_path
328 + fastmcp.settings.sse_path = sse_path
329 + fastmcp.settings.streamable_http_path = http_path
330
331 # Create new MCP apps with updated settings
332 with self._lock:
333 + middleware = [Middleware(BaseHTTPMiddleware, dispatch=mcp_middleware)]
334 +
335 self.sse_app = create_sse_app(
336 server=mcp_server,
329 - message_path=mcp_server.settings.message_path,
330 - sse_path=mcp_server.settings.sse_path,
331 - auth_server_provider=mcp_server._auth_server_provider,
332 - auth_settings=mcp_server.settings.auth,
333 - debug=mcp_server.settings.debug,
334 - routes=mcp_server._additional_http_routes,
335 - middleware=[Middleware(BaseHTTPMiddleware, dispatch=mcp_middleware)],
337 + message_path=message_path,
338 + sse_path=sse_path,
339 + auth=mcp_server.auth,
340 + debug=fastmcp.settings.debug,
341 + middleware=list(middleware),
342 )
343
338 - # For HTTP, we need to create a custom app since the lifespan manager
339 - # doesn't work properly in our Flask/Werkzeug environment
344 self.http_app = self._create_custom_http_app(
345 http_path,
342 - mcp_server._auth_server_provider,
343 - mcp_server.settings.auth,
344 - mcp_server.settings.debug,
345 - mcp_server._additional_http_routes,
346 + middleware=list(middleware),
347 )
348
348 - def _create_custom_http_app(self, streamable_http_path, auth_server_provider, auth_settings, debug, routes):
349 - """Create a custom HTTP app that manages the session manager manually."""
350 - from fastmcp.server.http import setup_auth_middleware_and_routes, create_base_app # type: ignore
349 + def _create_custom_http_app(
350 + self,
351 + streamable_http_path: str,
352 + *,
353 + middleware: list[Middleware],
354 + ) -> ASGIApp:
355 + """Create a Streamable HTTP app with manual session manager lifecycle."""
356 +
357 from mcp.server.streamable_http_manager import StreamableHTTPSessionManager # type: ignore
352 - from starlette.routing import Mount
358 from mcp.server.auth.middleware.bearer_auth import RequireAuthMiddleware # type: ignore
359 import anyio
360
@@ -357,9 +362,6 @@ class DynamicMcpProxy:
362 server_middleware = []
363
364 self.http_session_task_group = None
360 -
361 -
362 - # Create session manager
365 self.http_session_manager = StreamableHTTPSessionManager(
366 app=mcp_server._mcp_server,
367 event_store=None,
@@ -367,10 +369,7 @@ class DynamicMcpProxy:
369 stateless=False,
370 )
371
370 -
371 - # Custom ASGI handler that ensures task group is initialized
372 async def handle_streamable_http(scope, receive, send):
373 - # Lazy initialization of task group
373 if self.http_session_task_group is None:
374 self.http_session_task_group = anyio.create_task_group()
375 await self.http_session_task_group.__aenter__()
@@ -380,20 +379,25 @@ class DynamicMcpProxy:
379 if self.http_session_manager:
380 await self.http_session_manager.handle_request(scope, receive, send)
381
383 - # Get auth middleware and routes
384 - auth_middleware, auth_routes, required_scopes = setup_auth_middleware_and_routes(
385 - auth_server_provider, auth_settings
386 - )
382 + auth_provider = mcp_server.auth
383
388 - server_routes.extend(auth_routes)
389 - server_middleware.extend(auth_middleware)
384 + if auth_provider:
385 + server_routes.extend(auth_provider.get_routes(mcp_path=streamable_http_path))
386 + server_middleware.extend(auth_provider.get_middleware())
387 +
388 + resource_url = auth_provider._get_resource_url(streamable_http_path)
389 + resource_metadata_url = (
390 + build_resource_metadata_url(resource_url) if resource_url else None
391 + )
392
391 - # Add StreamableHTTP routes with or without auth
392 - if auth_server_provider:
393 server_routes.append(
394 Mount(
395 streamable_http_path,
396 - app=RequireAuthMiddleware(handle_streamable_http, required_scopes),
396 + app=RequireAuthMiddleware(
397 + handle_streamable_http,
398 + auth_provider.required_scopes,
399 + resource_metadata_url,
400 + ),
401 )
402 )
403 else:
@@ -404,18 +408,16 @@ class DynamicMcpProxy:
408 )
409 )
410
407 - # Add custom routes with lowest precedence
408 - if routes:
409 - server_routes.extend(routes)
411 + additional_routes = mcp_server._get_additional_http_routes()
412 + if additional_routes:
413 + server_routes.extend(additional_routes)
414
411 - # Add middleware
412 - server_middleware.append(Middleware(BaseHTTPMiddleware, dispatch=mcp_middleware))
415 + server_middleware.extend(middleware)
416
414 - # Create and return the app
417 return create_base_app(
418 routes=server_routes,
419 middleware=server_middleware,
418 - debug=debug,
420 + debug=fastmcp.settings.debug,
421 )
422
423 async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
python/helpers/message_queue.py
+8 -2
@@ -1,6 +1,7 @@
1 import os
2 import uuid
3 from typing import TYPE_CHECKING
4 +from python.helpers import guids
5
6 if TYPE_CHECKING:
7 from agent import AgentContext
@@ -41,7 +42,12 @@ def _sync_output(context: "AgentContext"):
42 context.set_output_data(QUEUE_KEY, truncated)
43
44
44 -def add(context: "AgentContext", text: str, attachments: list[str] | None = None) -> dict:
45 +def add(
46 + context: "AgentContext",
47 + text: str,
48 + attachments: list[str] | None = None,
49 + item_id: str | None = None,
50 +) -> dict:
51 """Add message to queue. Attachments should be filenames, will be converted to full paths."""
52 queue = get_queue(context)
53
@@ -54,7 +60,7 @@ def add(context: "AgentContext", text: str, attachments: list[str] | None = None
60 full_paths.append(f"{UPLOAD_FOLDER}/{att}")
61
62 item = {
57 - "id": str(uuid.uuid4())[:8],
63 + "id": item_id or guids.generate_id(),
64 "seq": _get_next_seq(context),
65 "text": text,
66 "attachments": full_paths,
python/helpers/notification.py
+116 -52
@@ -1,5 +1,6 @@
1 from dataclasses import dataclass
2 import uuid
3 +import threading
4 from datetime import datetime, timezone, timedelta
5 from enum import Enum
6
@@ -11,6 +12,7 @@ class NotificationType(Enum):
12 ERROR = "error"
13 PROGRESS = "progress"
14
15 +
16 class NotificationPriority(Enum):
17 NORMAL = 10
18 HIGH = 20
@@ -40,7 +42,7 @@ class NotificationItem:
42
43 def mark_read(self):
44 self.read = True
43 - self.manager._update_item(self.no, read=True)
45 + self.manager.update_item(self.no, read=True)
46
47 def output(self):
48 return {
@@ -60,6 +62,7 @@ class NotificationItem:
62
63 class NotificationManager:
64 def __init__(self, max_notifications: int = 100):
65 + self._lock = threading.RLock()
66 self.guid: str = str(uuid.uuid4())
67 self.updates: list[int] = []
68 self.notifications: list[NotificationItem] = []
@@ -90,75 +93,136 @@ class NotificationManager:
93 display_time: int = 3,
94 group: str = "",
95 ) -> NotificationItem:
93 - # Create notification item
94 - item = NotificationItem(
95 - manager=self,
96 - no=len(self.notifications),
97 - type=NotificationType(type),
98 - priority=NotificationPriority(priority),
99 - title=title,
100 - message=message,
101 - detail=detail,
102 - timestamp=datetime.now(timezone.utc),
103 - display_time=display_time,
104 - group=group,
105 - )
106 -
107 - # Add to notifications
108 - self.notifications.append(item)
109 - self.updates.append(item.no)
110 -
111 - # Enforce limit
112 - self._enforce_limit()
113 -
96 + with self._lock:
97 + # Create notification item
98 + item = NotificationItem(
99 + manager=self,
100 + no=len(self.notifications),
101 + type=NotificationType(type),
102 + priority=NotificationPriority(priority),
103 + title=title,
104 + message=message,
105 + detail=detail,
106 + timestamp=datetime.now(timezone.utc),
107 + display_time=display_time,
108 + group=group,
109 + )
110 +
111 + # Add to notifications
112 + self.notifications.append(item)
113 + self.updates.append(item.no)
114 +
115 + # Enforce limit
116 + self._enforce_limit()
117 +
118 + from python.helpers.state_monitor_integration import mark_dirty_all
119 + mark_dirty_all(reason="notification.NotificationManager.add_notification")
120 return item
121
122 def _enforce_limit(self):
117 - if len(self.notifications) > self.max_notifications:
118 - # Remove oldest notifications
119 - to_remove = len(self.notifications) - self.max_notifications
120 - self.notifications = self.notifications[to_remove:]
121 - # Adjust notification numbers
122 - for i, notification in enumerate(self.notifications):
123 - notification.no = i
124 - # Adjust updates list
125 - self.updates = [no - to_remove for no in self.updates if no >= to_remove]
123 + with self._lock:
124 + if len(self.notifications) > self.max_notifications:
125 + # Remove oldest notifications
126 + to_remove = len(self.notifications) - self.max_notifications
127 + self.notifications = self.notifications[to_remove:]
128 + # Adjust notification numbers
129 + for i, notification in enumerate(self.notifications):
130 + notification.no = i
131 + # Adjust updates list
132 + self.updates = [no - to_remove for no in self.updates if no >= to_remove]
133
134 def get_recent_notifications(self, seconds: int = 30) -> list[NotificationItem]:
135 cutoff = datetime.now(timezone.utc) - timedelta(seconds=seconds)
129 - return [n for n in self.notifications if n.timestamp >= cutoff]
136 + with self._lock:
137 + return [n for n in self.notifications if n.timestamp >= cutoff]
138
139 def output(self, start: int | None = None, end: int | None = None) -> list[dict]:
132 - if start is None:
133 - start = 0
134 - if end is None:
135 - end = len(self.updates)
140 + with self._lock:
141 + if start is None:
142 + start = 0
143 + if end is None:
144 + end = len(self.updates)
145 + updates = self.updates[start:end]
146 + notifications = list(self.notifications)
147
148 out = []
149 seen = set()
139 - for update in self.updates[start:end]:
140 - if update not in seen and update < len(self.notifications):
141 - out.append(self.notifications[update].output())
150 + for update in updates:
151 + if update not in seen and update < len(notifications):
152 + out.append(notifications[update].output())
153 seen.add(update)
143 -
154 return out
155
156 + def output_all(self) -> list[dict]:
157 + with self._lock:
158 + notifications = list(self.notifications)
159 + return [n.output() for n in notifications]
160 +
161 + def mark_read_by_ids(self, notification_ids: list[str]) -> int:
162 + ids = {nid for nid in notification_ids if isinstance(nid, str) and nid.strip()}
163 + if not ids:
164 + return 0
165 +
166 + changed_nos: list[int] = []
167 + with self._lock:
168 + for notification in self.notifications:
169 + if notification.id in ids and not notification.read:
170 + notification.read = True
171 + changed_nos.append(notification.no)
172 + if changed_nos:
173 + self.updates.extend(changed_nos)
174 +
175 + if not changed_nos:
176 + return 0
177 +
178 + from python.helpers.state_monitor_integration import mark_dirty_all
179 + mark_dirty_all(reason="notification.NotificationManager.mark_read_by_ids")
180 + return len(changed_nos)
181 +
182 + def update_item(self, no: int, **kwargs) -> None:
183 + self._update_item(no, **kwargs)
184 +
185 def _update_item(self, no: int, **kwargs):
147 - if no < len(self.notifications):
148 - item = self.notifications[no]
149 - for key, value in kwargs.items():
150 - if hasattr(item, key):
151 - setattr(item, key, value)
152 - self.updates.append(no)
186 + changed = False
187 + with self._lock:
188 + if no < len(self.notifications):
189 + item = self.notifications[no]
190 + for key, value in kwargs.items():
191 + if hasattr(item, key):
192 + setattr(item, key, value)
193 + self.updates.append(no)
194 + changed = True
195 +
196 + if not changed:
197 + return
198 +
199 + from python.helpers.state_monitor_integration import mark_dirty_all
200 + mark_dirty_all(reason="notification.NotificationManager._update_item")
201
202 def mark_all_read(self):
155 - for notification in self.notifications:
156 - notification.read = True
203 + changed_nos: list[int] = []
204 + with self._lock:
205 + for notification in self.notifications:
206 + if not notification.read:
207 + notification.read = True
208 + changed_nos.append(notification.no)
209 + if changed_nos:
210 + self.updates.extend(changed_nos)
211 +
212 + if not changed_nos:
213 + return
214 +
215 + from python.helpers.state_monitor_integration import mark_dirty_all
216 + mark_dirty_all(reason="notification.NotificationManager.mark_all_read")
217
218 def clear_all(self):
159 - self.notifications = []
160 - self.updates = []
161 - self.guid = str(uuid.uuid4())
219 + with self._lock:
220 + self.notifications = []
221 + self.updates = []
222 + self.guid = str(uuid.uuid4())
223 + from python.helpers.state_monitor_integration import mark_dirty_all
224 + mark_dirty_all(reason="notification.NotificationManager.clear_all")
225
226 def get_notifications_by_type(self, type: NotificationType) -> list[NotificationItem]:
164 - return [n for n in self.notifications if n.type == type]
\ No newline at end of file
227 + with self._lock:
228 + return [n for n in self.notifications if n.type == type]
python/helpers/persist_chat.py
+27 -18
@@ -44,7 +44,7 @@ def save_tmp_chat(context: AgentContext):
44
45 def save_tmp_chats():
46 """Save all contexts to the chats folder"""
47 - for _, context in AgentContext._contexts.items():
47 + for context in AgentContext.all():
48 # Skip BACKGROUND contexts as they should be ephemeral
49 if context.type == AgentContextType.BACKGROUND:
50 continue
@@ -164,13 +164,17 @@ def _serialize_agent(agent: Agent):
164
165
166 def _serialize_log(log: Log):
167 + # Guard against concurrent log mutations while serializing.
168 + with log._lock:
169 + logs = [item.output() for item in log.logs[-LOG_SIZE:]] # serialize LogItem objects
170 + guid = log.guid
171 + progress = log.progress
172 + progress_no = log.progress_no
173 return {
168 - "guid": log.guid,
169 - "logs": [
170 - item.output() for item in log.logs[-LOG_SIZE:]
171 - ], # serialize LogItem objects
172 - "progress": log.progress,
173 - "progress_no": log.progress_no,
174 + "guid": guid,
175 + "logs": logs,
176 + "progress": progress,
177 + "progress_no": progress_no,
178 }
179
180
@@ -262,17 +266,22 @@ def _deserialize_log(data: dict[str, Any]) -> "Log":
266 # Deserialize the list of LogItem objects
267 i = 0
268 for item_data in data.get("logs", []):
265 - log.logs.append(LogItem(
266 - log=log, # restore the log reference
267 - no=i, # item_data["no"],
268 - type=item_data["type"],
269 - heading=item_data.get("heading", ""),
270 - content=item_data.get("content", ""),
271 - kvps=OrderedDict(item_data["kvps"]) if item_data["kvps"] else None,
272 - timestamp=item_data.get("timestamp", 0.0),
273 - agentno=item_data.get("agentno", 0),
274 - id=item_data.get("id"),
275 - ))
269 + agentno = item_data.get("agentno")
270 + if agentno is None:
271 + agentno = item_data.get("agent_number", 0)
272 + log.logs.append(
273 + LogItem(
274 + log=log, # restore the log reference
275 + no=i, # item_data["no"],
276 + type=item_data["type"],
277 + heading=item_data.get("heading", ""),
278 + content=item_data.get("content", ""),
279 + kvps=OrderedDict(item_data["kvps"]) if item_data["kvps"] else None,
280 + timestamp=item_data.get("timestamp", 0.0),
281 + agentno=agentno,
282 + id=item_data.get("id"),
283 + )
284 + )
285 log.updates.append(i)
286 i += 1
287
python/helpers/print_style.py
+87 -27
@@ -1,8 +1,20 @@
1 import os, webcolors, html
2 import sys
3 from datetime import datetime
4 +from collections.abc import Mapping
5 from . import files
6
7 +_runtime_module = None
8 +
9 +
10 +def _get_runtime():
11 + global _runtime_module
12 + if _runtime_module is None:
13 + from . import runtime as runtime_module # Local import to avoid circular dependency
14 +
15 + _runtime_module = runtime_module
16 + return _runtime_module
17 +
18 class PrintStyle:
19 last_endline = True
20 log_file_path = None
@@ -90,9 +102,39 @@ class PrintStyle:
102 with open(PrintStyle.log_file_path, "a") as f:
103 f.write("</pre></body></html>")
104
105 + @staticmethod
106 + def _format_args(args, sep):
107 + if not args:
108 + return ""
109 +
110 + head, *tail = args
111 +
112 + if isinstance(head, str) and tail and ("%" in head or "{" in head):
113 + is_mapping = len(tail) == 1 and isinstance(tail[0], Mapping)
114 + try:
115 + return head % (tail[0] if is_mapping else tuple(tail))
116 + except (TypeError, ValueError, KeyError):
117 + try:
118 + return head.format(**tail[0]) if is_mapping else head.format(*tail)
119 + except (KeyError, IndexError, ValueError):
120 + pass
121 +
122 + return sep.join(str(item) for item in args)
123 +
124 + @staticmethod
125 + def _prefixed_args(prefix: str, args: tuple) -> tuple:
126 + if not args:
127 + return (f"{prefix}:",)
128 +
129 + first, *rest = args
130 + if isinstance(first, str):
131 + return (f"{prefix}: {first}", *rest)
132 +
133 + return (f"{prefix}:", *args)
134 +
135 def get(self, *args, sep=' ', **kwargs):
94 - text = sep.join(map(str, args))
95 -
136 + text = self._format_args(args, sep)
137 +
138 # Automatically mask secrets in all print output
139 try:
140 if not hasattr(self, "secrets_mgr"):
@@ -102,25 +144,29 @@ class PrintStyle:
144 except Exception:
145 # If masking fails, proceed without masking to avoid breaking functionality
146 pass
105 -
147 +
148 return text, self._get_styled_text(text), self._get_html_styled_text(text)
149
108 - def print(self, *args, sep=' ', **kwargs):
150 + def print(self, *args, sep=' ', end='\n', flush=True):
151 self._add_padding_if_needed()
152 if not PrintStyle.last_endline:
111 - print()
153 + if not self.log_only:
154 + print()
155 self._log_html("<br>")
113 - plain_text, styled_text, html_text = self.get(*args, sep=sep, **kwargs)
156 + plain_text, styled_text, html_text = self.get(*args, sep=sep)
157 if not self.log_only:
115 - print(styled_text, end='\n', flush=True)
116 - self._log_html(html_text+"<br>\n")
117 - PrintStyle.last_endline = True
118 -
119 - def stream(self, *args, sep=' ', **kwargs):
158 + print(styled_text, end=end, flush=flush)
159 + if end.endswith('\n'):
160 + self._log_html(html_text + "<br>\n")
161 + else:
162 + self._log_html(html_text)
163 + PrintStyle.last_endline = end.endswith('\n')
164 +
165 + def stream(self, *args, sep=' ', flush=True):
166 self._add_padding_if_needed()
121 - plain_text, styled_text, html_text = self.get(*args, sep=sep, **kwargs)
167 + plain_text, styled_text, html_text = self.get(*args, sep=sep)
168 if not self.log_only:
123 - print(styled_text, end='', flush=True)
169 + print(styled_text, end='', flush=flush)
170 self._log_html(html_text)
171 PrintStyle.last_endline = False
172
@@ -129,32 +175,46 @@ class PrintStyle:
175 return bool(lines) and not lines[-1].strip()
176
177 @staticmethod
132 - def standard(text: str):
133 - PrintStyle().print(text)
178 + def standard(*args, sep=' ', end='\n', flush=True):
179 + PrintStyle().print(*args, sep=sep, end=end, flush=flush)
180
181 @staticmethod
136 - def hint(text: str):
137 - PrintStyle(font_color="#6C3483", padding=True).print("Hint: "+text)
182 + def hint(*args, sep=' ', end='\n', flush=True):
183 + prefixed = PrintStyle._prefixed_args("Hint", args)
184 + PrintStyle(font_color="#6C3483", padding=True).print(*prefixed, sep=sep, end=end, flush=flush)
185
186 @staticmethod
140 - def info(text: str):
141 - PrintStyle(font_color="#0000FF", padding=True).print("Info: "+text)
187 + def info(*args, sep=' ', end='\n', flush=True):
188 + prefixed = PrintStyle._prefixed_args("Info", args)
189 + PrintStyle(font_color="#0000FF", padding=True).print(*prefixed, sep=sep, end=end, flush=flush)
190
191 @staticmethod
144 - def success(text: str):
145 - PrintStyle(font_color="#008000", padding=True).print("Success: "+text)
192 + def success(*args, sep=' ', end='\n', flush=True):
193 + prefixed = PrintStyle._prefixed_args("Success", args)
194 + PrintStyle(font_color="#008000", padding=True).print(*prefixed, sep=sep, end=end, flush=flush)
195
196 @staticmethod
148 - def warning(text: str):
149 - PrintStyle(font_color="#FFA500", padding=True).print("Warning: "+text)
197 + def warning(*args, sep=' ', end='\n', flush=True):
198 + prefixed = PrintStyle._prefixed_args("Warning", args)
199 + PrintStyle(font_color="#FFA500", padding=True).print(*prefixed, sep=sep, end=end, flush=flush)
200
201 @staticmethod
152 - def debug(text: str):
153 - PrintStyle(font_color="#808080", padding=True).print("Debug: "+text)
202 + def debug(*args, sep=' ', end='\n', flush=True):
203 + # Only emit debug output when running in development mode
204 + try:
205 + runtime_module = _get_runtime()
206 + if not runtime_module.is_development():
207 + return
208 + except Exception:
209 + # If runtime detection fails, default to emitting to avoid hiding logs during development setup
210 + pass
211 + prefixed = PrintStyle._prefixed_args("Debug", args)
212 + PrintStyle(font_color="#808080", padding=True).print(*prefixed, sep=sep, end=end, flush=flush)
213
214 @staticmethod
156 - def error(text: str):
157 - PrintStyle(font_color="red", padding=True).print("Error: "+text)
215 + def error(*args, sep=' ', end='\n', flush=True):
216 + prefixed = PrintStyle._prefixed_args("Error", args)
217 + PrintStyle(font_color="red", padding=True).print(*prefixed, sep=sep, end=end, flush=flush)
218
219 # Ensure HTML file is closed properly when the program exits
220 import atexit
python/helpers/projects.py
+20 -8
@@ -27,7 +27,7 @@ class FileStructureInjectionSettings(TypedDict):
27
28 class SubAgentSettings(TypedDict):
29 enabled: bool
30 -
30 +
31 class BasicProjectData(TypedDict):
32 title: str
33 description: str
@@ -229,7 +229,7 @@ def _get_projects_list(parent_dir):
229 return projects
230
231
232 -def activate_project(context_id: str, name: str):
232 +def activate_project(context_id: str, name: str, *, mark_dirty: bool = True):
233 from agent import AgentContext
234
235 data = load_edit_project_data(name)
@@ -247,8 +247,12 @@ def activate_project(context_id: str, name: str):
247 # persist
248 persist_chat.save_tmp_chat(context)
249
250 + if mark_dirty:
251 + from python.helpers.state_monitor_integration import mark_dirty_all
252 + mark_dirty_all(reason="projects.activate_project")
253 +
254
251 -def deactivate_project(context_id: str):
255 +def deactivate_project(context_id: str, *, mark_dirty: bool = True):
256 from agent import AgentContext
257
258 context = AgentContext.get(context_id)
@@ -260,24 +264,34 @@ def deactivate_project(context_id: str):
264 # persist
265 persist_chat.save_tmp_chat(context)
266
267 + if mark_dirty:
268 + from python.helpers.state_monitor_integration import mark_dirty_all
269 + mark_dirty_all(reason="projects.deactivate_project")
270 +
271
272 def reactivate_project_in_chats(name: str):
273 from agent import AgentContext
274
275 for context in AgentContext.all():
276 if context.get_data(CONTEXT_DATA_KEY_PROJECT) == name:
269 - activate_project(context.id, name)
277 + activate_project(context.id, name, mark_dirty=False)
278 persist_chat.save_tmp_chat(context)
279
280 + from python.helpers.state_monitor_integration import mark_dirty_all
281 + mark_dirty_all(reason="projects.reactivate_project_in_chats")
282 +
283
284 def deactivate_project_in_chats(name: str):
285 from agent import AgentContext
286
287 for context in AgentContext.all():
288 if context.get_data(CONTEXT_DATA_KEY_PROJECT) == name:
278 - deactivate_project(context.id)
289 + deactivate_project(context.id, mark_dirty=False)
290 persist_chat.save_tmp_chat(context)
291
292 + from python.helpers.state_monitor_integration import mark_dirty_all
293 + mark_dirty_all(reason="projects.deactivate_project_in_chats")
294 +
295
296 def build_system_prompt_vars(name: str):
297 project_data = load_basic_project_data(name)
@@ -409,7 +423,7 @@ def get_file_structure(name: str, basic_data: BasicProjectData|None=None) -> str
423 project_folder = get_project_folder(name)
424 if basic_data is None:
425 basic_data = load_basic_project_data(name)
412 -
426 +
427 tree = str(file_tree.file_tree(
428 project_folder,
429 max_depth=basic_data["file_structure"]["max_depth"],
@@ -425,5 +439,3 @@ def get_file_structure(name: str, basic_data: BasicProjectData|None=None) -> str
439 tree += "\n # Empty"
440
441 return tree
428 -
429 -
\ No newline at end of file
python/helpers/settings.py
+29 -4
@@ -124,6 +124,8 @@ class Settings(TypedDict):
124 rfc_port_ssh: int
125
126 shell_interface: Literal['local','ssh']
127 + websocket_server_restart_enabled: bool
128 + uvicorn_access_logs_enabled: bool
129
130 stt_model_size: str
131 stt_language: str
@@ -199,6 +201,8 @@ class SettingsOutputAdditional(TypedDict):
201 knowledge_subdirs: list[FieldOption]
202 stt_models: list[FieldOption]
203 is_dockerized: bool
204 + runtime_settings: dict[str, Any]
205 +
206
207 class SettingsOutput(TypedDict):
208 settings: Settings
@@ -210,6 +214,7 @@ API_KEY_PLACEHOLDER = "************"
214
215 SETTINGS_FILE = files.get_abs_path("usr/settings.json")
216 _settings: Settings | None = None
217 +_runtime_settings_snapshot: Settings | None = None
218
219 OptionT = TypeVar("OptionT", bound=FieldOption)
220
@@ -247,15 +252,26 @@ def convert_out(settings: Settings) -> SettingsOutput:
252 {"value": "medium", "label": "Medium (769M, English)"},
253 {"value": "large", "label": "Large (1.5B, Multilingual)"},
254 {"value": "turbo", "label": "Turbo (Multilingual)"},
250 - ]
251 -
252 - )
255 + ],
256 + runtime_settings={},
257 + ),
258 )
259
260 # ensure dropdown options include currently selected values
261 additional = out["additional"]
262 current = out["settings"]
263
264 + default_settings = get_default_settings()
265 + runtime_settings = _runtime_settings_snapshot or settings
266 + additional["runtime_settings"] = {
267 + "uvicorn_access_logs_enabled": bool(
268 + runtime_settings.get(
269 + "uvicorn_access_logs_enabled",
270 + default_settings["uvicorn_access_logs_enabled"],
271 + )
272 + ),
273 + }
274 +
275 additional["chat_providers"] = _ensure_option_present(additional.get("chat_providers"), current.get("chat_model_provider"))
276 additional["chat_providers"] = _ensure_option_present(additional.get("chat_providers"), current.get("util_model_provider"))
277 additional["chat_providers"] = _ensure_option_present(additional.get("chat_providers"), current.get("browser_model_provider"))
@@ -345,6 +361,11 @@ def reload_settings() -> Settings:
361 return get_settings()
362
363
364 +def set_runtime_settings_snapshot(settings: Settings) -> None:
365 + global _runtime_settings_snapshot
366 + _runtime_settings_snapshot = settings.copy()
367 +
368 +
369 def set_settings(settings: Settings, apply: bool = True):
370 global _settings
371 previous = _settings
@@ -407,6 +428,7 @@ def _adjust_to_version(settings: Settings, default: Settings):
428 settings["agent_profile"] = "agent0"
429
430
431 +
432 def _read_settings_file() -> Settings | None:
433 if os.path.exists(SETTINGS_FILE):
434 content = files.read_file(SETTINGS_FILE)
@@ -520,6 +542,8 @@ def get_default_settings() -> Settings:
542 rfc_port_http=get_default_value("rfc_port_http", 55080),
543 rfc_port_ssh=get_default_value("rfc_port_ssh", 55022),
544 shell_interface=get_default_value("shell_interface", "local" if runtime.is_dockerized() else "ssh"),
545 + websocket_server_restart_enabled=get_default_value("websocket_server_restart_enabled", True),
546 + uvicorn_access_logs_enabled=get_default_value("uvicorn_access_logs_enabled", False),
547 stt_model_size=get_default_value("stt_model_size", "base"),
548 stt_language=get_default_value("stt_language", "en"),
549 stt_silence_threshold=get_default_value("stt_silence_threshold", 0.3),
@@ -546,7 +570,7 @@ def _apply_settings(previous: Settings | None):
570 from initialize import initialize_agent
571
572 config = initialize_agent()
549 - for ctx in AgentContext._contexts.values():
573 + for ctx in AgentContext.all():
574 ctx.config = config # reinitialize context config with new settings
575 # apply config to agents
576 agent = ctx.agent0
@@ -750,3 +774,4 @@ def create_auth_token() -> str:
774
775 def _get_version():
776 return git.get_version()
777 +
python/helpers/state_monitor.py new
+380
@@ -0,0 +1,380 @@
1 +from __future__ import annotations
2 +
3 +import asyncio
4 +import threading
5 +import time
6 +from dataclasses import dataclass, field
7 +from typing import Any, TYPE_CHECKING
8 +
9 +from python.helpers import runtime
10 +from python.helpers.print_style import PrintStyle
11 +from python.helpers.state_snapshot import (
12 + StateRequestV1,
13 + advance_state_request_after_snapshot,
14 + build_snapshot_from_request,
15 +)
16 +from python.helpers.websocket import ConnectionNotFoundError
17 +
18 +if TYPE_CHECKING: # pragma: no cover - hints only
19 + from python.helpers.websocket_manager import WebSocketManager
20 +
21 +
22 +ConnectionIdentity = tuple[str, str] # (namespace, sid)
23 +
24 +
25 +@dataclass
26 +class ConnectionProjection:
27 + namespace: str
28 + sid: str
29 + request: StateRequestV1 | None = None
30 + seq: int = 0
31 + seq_base: int = 0
32 + # Incremented on every dirty signal. Used to coalesce bursts without delaying
33 + # pushes indefinitely during continuous activity (throttled coalescing).
34 + dirty_version: int = 0
35 + pushed_version: int = 0
36 + # Development-only diagnostics - last known cause of the most recent dirty wave.
37 + dirty_reason: str | None = None
38 + dirty_wave_id: str | None = None
39 + created_at: float = field(default_factory=time.time)
40 +
41 +
42 +class StateMonitor:
43 + """Per-sid dirty tracking with debounced snapshot push scheduling."""
44 +
45 + def __init__(self, debounce_seconds: float = 0.025) -> None:
46 + self.debounce_seconds = float(debounce_seconds)
47 + self._lock = threading.RLock()
48 + self._projections: dict[ConnectionIdentity, ConnectionProjection] = {}
49 + self._debounce_handles: dict[ConnectionIdentity, asyncio.TimerHandle] = {}
50 + self._push_tasks: dict[ConnectionIdentity, asyncio.Task[None]] = {}
51 + self._manager: WebSocketManager | None = None
52 + self._emit_handler_id: str | None = None
53 + self._dispatcher_loop: asyncio.AbstractEventLoop | None = None
54 + self._dirty_wave_seq: int = 0
55 +
56 + def bind_manager(self, manager: "WebSocketManager", *, handler_id: str | None = None) -> None:
57 + with self._lock:
58 + self._manager = manager
59 + if handler_id:
60 + self._emit_handler_id = handler_id
61 + # Use the manager's dispatcher loop for all scheduling so mark_dirty can be
62 + # invoked safely from non-async contexts and other threads.
63 + self._dispatcher_loop = getattr(manager, "_dispatcher_loop", None)
64 + if runtime.is_development():
65 + PrintStyle.debug(
66 + f"[StateMonitor] bind_manager handler_id={handler_id or self._emit_handler_id}"
67 + )
68 +
69 + def register_sid(self, namespace: str, sid: str) -> None:
70 + identity: ConnectionIdentity = (namespace, sid)
71 + with self._lock:
72 + self._projections.setdefault(
73 + identity, ConnectionProjection(namespace=namespace, sid=sid)
74 + )
75 + if runtime.is_development():
76 + PrintStyle.debug(f"[StateMonitor] register_sid namespace={namespace} sid={sid}")
77 +
78 + def unregister_sid(self, namespace: str, sid: str) -> None:
79 + identity: ConnectionIdentity = (namespace, sid)
80 + with self._lock:
81 + handle = self._debounce_handles.pop(identity, None)
82 + if handle is not None:
83 + handle.cancel()
84 + task = self._push_tasks.pop(identity, None)
85 + if task is not None:
86 + task.cancel()
87 + self._projections.pop(identity, None)
88 + if runtime.is_development():
89 + PrintStyle.debug(
90 + f"[StateMonitor] unregister_sid namespace={namespace} sid={sid}"
91 + )
92 +
93 + def mark_dirty_all(self, *, reason: str | None = None) -> None:
94 + wave_id = None
95 + if runtime.is_development():
96 + with self._lock:
97 + self._dirty_wave_seq += 1
98 + wave_id = f"all_{self._dirty_wave_seq}"
99 + with self._lock:
100 + identities = list(self._projections.keys())
101 + for namespace, sid in identities:
102 + self.mark_dirty(namespace, sid, reason=reason, wave_id=wave_id)
103 +
104 + def mark_dirty_for_context(self, context_id: str, *, reason: str | None = None) -> None:
105 + if not isinstance(context_id, str) or not context_id.strip():
106 + return
107 + target = context_id.strip()
108 + wave_id = None
109 + if runtime.is_development():
110 + with self._lock:
111 + self._dirty_wave_seq += 1
112 + wave_id = f"ctx_{self._dirty_wave_seq}"
113 + with self._lock:
114 + identities = [
115 + identity
116 + for identity, projection in self._projections.items()
117 + if projection.request is not None and projection.request.context == target
118 + ]
119 + for namespace, sid in identities:
120 + self.mark_dirty(namespace, sid, reason=reason, wave_id=wave_id)
121 +
122 + def update_projection(
123 + self,
124 + namespace: str,
125 + sid: str,
126 + *,
127 + request: StateRequestV1,
128 + seq_base: int,
129 + ) -> None:
130 + identity: ConnectionIdentity = (namespace, sid)
131 + with self._lock:
132 + projection = self._projections.setdefault(
133 + identity, ConnectionProjection(namespace=namespace, sid=sid)
134 + )
135 + projection.request = request
136 + projection.seq_base = seq_base
137 + projection.seq = seq_base
138 + if runtime.is_development():
139 + PrintStyle.debug(
140 + f"[StateMonitor] update_projection namespace={namespace} sid={sid} context={request.context!r} "
141 + f"log_from={request.log_from} notifications_from={request.notifications_from} "
142 + f"timezone={request.timezone!r} seq_base={seq_base}"
143 + )
144 +
145 + def mark_dirty(
146 + self,
147 + namespace: str,
148 + sid: str,
149 + *,
150 + reason: str | None = None,
151 + wave_id: str | None = None,
152 + ) -> None:
153 + identity: ConnectionIdentity = (namespace, sid)
154 + loop = self._dispatcher_loop
155 + if loop is None or loop.is_closed():
156 + try:
157 + loop = asyncio.get_running_loop()
158 + except RuntimeError:
159 + return
160 +
161 + try:
162 + running_loop = asyncio.get_running_loop()
163 + except RuntimeError:
164 + running_loop = None
165 +
166 + if running_loop is loop:
167 + self._mark_dirty_on_loop(identity, reason=reason, wave_id=wave_id)
168 + return
169 +
170 + loop.call_soon_threadsafe(self._mark_dirty_on_loop, identity, reason, wave_id)
171 +
172 + def _mark_dirty_on_loop(
173 + self,
174 + identity: ConnectionIdentity,
175 + reason: str | None = None,
176 + wave_id: str | None = None,
177 + ) -> None:
178 + with self._lock:
179 + projection = self._projections.get(identity)
180 + if projection is None:
181 + return
182 + projection.dirty_version += 1
183 + if runtime.is_development():
184 + projection.dirty_reason = (
185 + reason.strip()
186 + if isinstance(reason, str) and reason.strip()
187 + else "unknown"
188 + )
189 + projection.dirty_wave_id = wave_id
190 + self._schedule_debounce_on_loop(identity)
191 +
192 + def _schedule_debounce_on_loop(self, identity: ConnectionIdentity) -> None:
193 + loop = asyncio.get_running_loop()
194 + with self._lock:
195 + projection = self._projections.get(identity)
196 + if projection is None:
197 + return
198 + # INVARIANT.STATE.GATING: do not schedule pushes until a successful state_request
199 + # established seq_base for this sid.
200 + if projection.seq_base <= 0:
201 + return
202 +
203 + # Throttled coalescing: schedule at most one push per debounce window.
204 + # Do not postpone the scheduled push on subsequent dirties; this keeps
205 + # streaming updates smooth while still capping to <= 1 push / 100ms / sid.
206 + existing = self._debounce_handles.get(identity)
207 + if existing is not None and not existing.cancelled():
208 + return
209 +
210 + running = self._push_tasks.get(identity)
211 + if running is not None and not running.done():
212 + return
213 +
214 + handle = loop.call_later(
215 + self.debounce_seconds, self._on_debounce_fire, identity
216 + )
217 + self._debounce_handles[identity] = handle
218 + if runtime.is_development():
219 + PrintStyle.debug(
220 + f"[StateMonitor] schedule_push namespace={projection.namespace} sid={projection.sid} "
221 + f"delay_s={self.debounce_seconds} "
222 + f"dirty={projection.dirty_version} pushed={projection.pushed_version} "
223 + f"reason={projection.dirty_reason!r} wave={projection.dirty_wave_id!r}"
224 + )
225 +
226 + def _on_debounce_fire(self, identity: ConnectionIdentity) -> None:
227 + with self._lock:
228 + self._debounce_handles.pop(identity, None)
229 + existing = self._push_tasks.get(identity)
230 + if existing is not None and not existing.done():
231 + return
232 + task = asyncio.create_task(self._flush_push(identity))
233 + self._push_tasks[identity] = task
234 +
235 + async def _flush_push(self, identity: ConnectionIdentity) -> None:
236 + namespace, sid = identity
237 + task = asyncio.current_task()
238 + base_version = 0
239 + dirty_reason: str | None = None
240 + dirty_wave_id: str | None = None
241 + try:
242 + with self._lock:
243 + projection = self._projections.get(identity)
244 + manager = self._manager
245 + handler_id = self._emit_handler_id
246 +
247 + if projection is None:
248 + return
249 + if manager is None:
250 + # The handler binds the manager on connect; if not bound yet,
251 + # we cannot emit. Keep dirty cleared to avoid infinite retry loops.
252 + return
253 + if projection.seq_base <= 0:
254 + # INVARIANT.STATE.GATING: no push before a successful state_request.
255 + return
256 +
257 + request = projection.request
258 + if request is None:
259 + return
260 + base_version = projection.dirty_version
261 + dirty_reason = projection.dirty_reason
262 + dirty_wave_id = projection.dirty_wave_id
263 +
264 + snapshot = await build_snapshot_from_request(request=request)
265 +
266 + with self._lock:
267 + projection = self._projections.get(identity)
268 + if projection is None:
269 + return
270 + if projection.request != request:
271 + return
272 +
273 + # INVARIANT.STATE.SEQ_MONOTONIC + SEQ_RESET_ON_REQUEST
274 + projection.seq += 1
275 + seq = projection.seq
276 +
277 + # Advance cursors after successful snapshot emission (incremental mode).
278 + projection.request = advance_state_request_after_snapshot(request, snapshot)
279 +
280 + # Mark all dirties up to `base_version` as pushed. If new dirties
281 + # arrived while building/emitting, a follow-up push will be scheduled.
282 + projection.pushed_version = max(projection.pushed_version, base_version)
283 +
284 + payload = {
285 + "runtime_epoch": runtime.get_runtime_id(),
286 + "seq": seq,
287 + "snapshot": snapshot,
288 + }
289 +
290 + try:
291 + if runtime.is_development():
292 + logs_len = (
293 + len(snapshot.get("logs", []))
294 + if isinstance(snapshot.get("logs"), list)
295 + else None
296 + )
297 + PrintStyle.debug(
298 + f"[StateMonitor] emit state_push namespace={namespace} sid={sid} seq={seq} "
299 + f"context={request.context!r} logs_len={logs_len} "
300 + f"reason={dirty_reason!r} wave={dirty_wave_id!r}"
301 + )
302 + await manager.emit_to(
303 + namespace,
304 + sid,
305 + "state_push",
306 + payload,
307 + handler_id=handler_id,
308 + )
309 + except ConnectionNotFoundError:
310 + # Sid was removed before the emit; treat as benign.
311 + if runtime.is_development():
312 + PrintStyle.debug(
313 + f"[StateMonitor] emit skipped: sid not found namespace={namespace} sid={sid}"
314 + )
315 + return
316 + except RuntimeError:
317 + # Dispatcher loop may be closing (e.g., during shutdown or test teardown).
318 + if runtime.is_development():
319 + PrintStyle.debug(
320 + f"[StateMonitor] emit skipped: dispatcher closing namespace={namespace} sid={sid}"
321 + )
322 + return
323 + finally:
324 + follow_up = False
325 + dirty_version = 0
326 + pushed_version = 0
327 + with self._lock:
328 + if task is not None and self._push_tasks.get(identity) is task:
329 + self._push_tasks.pop(identity, None)
330 + projection = self._projections.get(identity)
331 + if projection is not None:
332 + dirty_version = projection.dirty_version
333 + pushed_version = projection.pushed_version
334 + follow_up = dirty_version > pushed_version
335 +
336 + # More dirties accumulated during push; schedule another coalesced push.
337 + # IMPORTANT: this must not run from inside the `finally` block (a `return` in
338 + # `finally` can swallow exceptions from the push task).
339 + if not follow_up:
340 + return
341 +
342 + if runtime.is_development():
343 + PrintStyle.debug(
344 + f"[StateMonitor] follow_up_push namespace={namespace} sid={sid} dirty={dirty_version} pushed={pushed_version}"
345 + )
346 + try:
347 + loop = self._dispatcher_loop or asyncio.get_running_loop()
348 + except RuntimeError:
349 + return
350 + if loop.is_closed():
351 + return
352 + loop.call_soon_threadsafe(self._schedule_debounce_on_loop, identity)
353 +
354 + # Testing hook: keep argument surface stable for future extensions
355 + def _debug_state(self) -> dict[str, Any]: # pragma: no cover - helper
356 + with self._lock:
357 + return {
358 + "identities": list(self._projections.keys()),
359 + "handles": list(self._debounce_handles.keys()),
360 + }
361 +
362 +
363 +# Store singleton in a mutable container to avoid `global` assignment warnings while
364 +# keeping a simple module-level accessor API.
365 +_STATE_MONITOR_HOLDER: dict[str, StateMonitor | None] = {"monitor": None}
366 +_STATE_MONITOR_LOCK = threading.RLock()
367 +
368 +
369 +def get_state_monitor() -> StateMonitor:
370 + with _STATE_MONITOR_LOCK:
371 + monitor = _STATE_MONITOR_HOLDER.get("monitor")
372 + if monitor is None:
373 + monitor = StateMonitor()
374 + _STATE_MONITOR_HOLDER["monitor"] = monitor
375 + return monitor
376 +
377 +
378 +def _reset_state_monitor_for_testing() -> None: # pragma: no cover - helper
379 + with _STATE_MONITOR_LOCK:
380 + _STATE_MONITOR_HOLDER["monitor"] = None
python/helpers/state_monitor_integration.py new
+13
@@ -0,0 +1,13 @@
1 +from __future__ import annotations
2 +
3 +
4 +def mark_dirty_all(*, reason: str | None = None) -> None:
5 + from python.helpers.state_monitor import get_state_monitor
6 +
7 + get_state_monitor().mark_dirty_all(reason=reason)
8 +
9 +
10 +def mark_dirty_for_context(context_id: str, *, reason: str | None = None) -> None:
11 + from python.helpers.state_monitor import get_state_monitor
12 +
13 + get_state_monitor().mark_dirty_for_context(context_id, reason=reason)
python/helpers/state_snapshot.py new
+319
@@ -0,0 +1,319 @@
1 +from __future__ import annotations
2 +
3 +import types
4 +from typing import Any, Mapping, TypedDict, Union, get_args, get_origin, get_type_hints
5 +
6 +from dataclasses import dataclass
7 +
8 +import pytz # type: ignore[import-untyped]
9 +
10 +from agent import AgentContext, AgentContextType
11 +
12 +from python.helpers.dotenv import get_dotenv_value
13 +from python.helpers.localization import Localization
14 +from python.helpers.task_scheduler import TaskScheduler
15 +
16 +
17 +class SnapshotV1(TypedDict):
18 + deselect_chat: bool
19 + context: str
20 + contexts: list[dict[str, Any]]
21 + tasks: list[dict[str, Any]]
22 + logs: list[dict[str, Any]]
23 + log_guid: str
24 + log_version: int
25 + # Historical behavior: when no context is selected, log_progress is 0 (falsy).
26 + # When a context is active, it is usually a string.
27 + log_progress: str | int
28 + log_progress_active: bool
29 + paused: bool
30 + notifications: list[dict[str, Any]]
31 + notifications_guid: str
32 + notifications_version: int
33 +
34 +@dataclass(frozen=True)
35 +class StateRequestV1:
36 + context: str | None
37 + log_from: int
38 + notifications_from: int
39 + timezone: str
40 +
41 +
42 +class StateRequestValidationError(ValueError):
43 + def __init__(
44 + self,
45 + *,
46 + reason: str,
47 + message: str,
48 + details: dict[str, Any] | None = None,
49 + ) -> None:
50 + super().__init__(message)
51 + self.reason = reason
52 + self.details = details or {}
53 +
54 +
55 +def _annotation_to_isinstance_types(annotation: Any) -> tuple[type, ...]:
56 + """Convert type annotation to tuple suitable for isinstance()."""
57 + origin = get_origin(annotation)
58 +
59 + # Handle Union (typing.Union or types.UnionType from X | Y)
60 + _union_type = getattr(types, "UnionType", None)
61 + if origin is Union or origin is _union_type:
62 + result: list[type] = []
63 + for arg in get_args(annotation):
64 + result.extend(_annotation_to_isinstance_types(arg))
65 + return tuple(result)
66 +
67 + # Generic aliases: list[X] -> list, dict[K,V] -> dict
68 + if origin is not None:
69 + return (origin,)
70 +
71 + if isinstance(annotation, type):
72 + return (annotation,)
73 +
74 + return ()
75 +
76 +
77 +def _build_schema_from_typeddict(td: type) -> dict[str, tuple[type, ...]]:
78 + """Extract field names and isinstance-compatible types from TypedDict."""
79 + return {k: _annotation_to_isinstance_types(v) for k, v in get_type_hints(td).items()}
80 +
81 +
82 +_SNAPSHOT_V1_SCHEMA = _build_schema_from_typeddict(SnapshotV1)
83 +SNAPSHOT_SCHEMA_V1_KEYS: tuple[str, ...] = tuple(_SNAPSHOT_V1_SCHEMA.keys())
84 +
85 +
86 +def validate_snapshot_schema_v1(snapshot: Mapping[str, Any]) -> None:
87 + if not isinstance(snapshot, dict):
88 + raise TypeError("snapshot must be a dict")
89 + expected = set(SNAPSHOT_SCHEMA_V1_KEYS)
90 + actual = set(snapshot.keys())
91 + missing = sorted(expected - actual)
92 + extra = sorted(actual - expected)
93 + if missing or extra:
94 + message = "snapshot schema mismatch"
95 + if missing:
96 + message += f"; missing={missing}"
97 + if extra:
98 + message += f"; unexpected={extra}"
99 + raise ValueError(message)
100 +
101 + for key, expected_types in _SNAPSHOT_V1_SCHEMA.items():
102 + if expected_types and not isinstance(snapshot.get(key), expected_types):
103 + type_desc = " | ".join(t.__name__ for t in expected_types)
104 + raise TypeError(f"snapshot.{key} must be {type_desc}")
105 +
106 +
107 +def _coerce_non_negative_int(value: Any, default: int = 0) -> int:
108 + try:
109 + as_int = int(value)
110 + except (TypeError, ValueError):
111 + return default
112 + return as_int if as_int >= 0 else default
113 +
114 +
115 +def parse_state_request_payload(payload: Mapping[str, Any]) -> StateRequestV1:
116 + context = payload.get("context")
117 + log_from = payload.get("log_from")
118 + notifications_from = payload.get("notifications_from")
119 + timezone = payload.get("timezone")
120 +
121 + if context is not None and not isinstance(context, str):
122 + raise StateRequestValidationError(
123 + reason="context_type",
124 + message="context must be a string or null",
125 + details={"context_type": type(context).__name__},
126 + )
127 + if not isinstance(log_from, int) or log_from < 0:
128 + raise StateRequestValidationError(
129 + reason="log_from",
130 + message="log_from must be an integer >= 0",
131 + details={"log_from": log_from},
132 + )
133 + if not isinstance(notifications_from, int) or notifications_from < 0:
134 + raise StateRequestValidationError(
135 + reason="notifications_from",
136 + message="notifications_from must be an integer >= 0",
137 + details={"notifications_from": notifications_from},
138 + )
139 + if not isinstance(timezone, str) or not timezone.strip():
140 + raise StateRequestValidationError(
141 + reason="timezone_empty",
142 + message="timezone must be a non-empty string",
143 + details={"timezone": timezone},
144 + )
145 +
146 + tz = timezone.strip()
147 + try:
148 + pytz.timezone(tz)
149 + except pytz.exceptions.UnknownTimeZoneError as exc:
150 + raise StateRequestValidationError(
151 + reason="timezone_invalid",
152 + message="timezone must be a valid IANA timezone name",
153 + details={"timezone": tz},
154 + ) from exc
155 +
156 + ctxid: str | None = context.strip() if isinstance(context, str) else None
157 + if ctxid == "":
158 + ctxid = None
159 + return StateRequestV1(
160 + context=ctxid,
161 + log_from=log_from,
162 + notifications_from=notifications_from,
163 + timezone=tz,
164 + )
165 +
166 +
167 +def _coerce_state_request_inputs(
168 + *,
169 + context: Any,
170 + log_from: Any,
171 + notifications_from: Any,
172 + timezone: Any,
173 +) -> StateRequestV1:
174 + tz = timezone if isinstance(timezone, str) and timezone else None
175 + tz = tz or get_dotenv_value("DEFAULT_USER_TIMEZONE", "UTC")
176 +
177 + ctxid: str | None = context.strip() if isinstance(context, str) else None
178 + if ctxid == "":
179 + ctxid = None
180 +
181 + return StateRequestV1(
182 + context=ctxid,
183 + log_from=_coerce_non_negative_int(log_from, default=0),
184 + notifications_from=_coerce_non_negative_int(notifications_from, default=0),
185 + timezone=tz,
186 + )
187 +
188 +
189 +def advance_state_request_after_snapshot(
190 + request: StateRequestV1,
191 + snapshot: Mapping[str, Any],
192 +) -> StateRequestV1:
193 + log_from = request.log_from
194 + notifications_from = request.notifications_from
195 +
196 + try:
197 + log_from = int(snapshot.get("log_version", log_from))
198 + except (TypeError, ValueError):
199 + pass
200 +
201 + try:
202 + notifications_from = int(snapshot.get("notifications_version", notifications_from))
203 + except (TypeError, ValueError):
204 + pass
205 +
206 + return StateRequestV1(
207 + context=request.context,
208 + log_from=log_from,
209 + notifications_from=notifications_from,
210 + timezone=request.timezone,
211 + )
212 +
213 +
214 +async def build_snapshot_from_request(*, request: StateRequestV1) -> SnapshotV1:
215 + """Build a poll-shaped snapshot for both /poll and state_push."""
216 +
217 + Localization.get().set_timezone(request.timezone)
218 +
219 + ctxid = request.context if isinstance(request.context, str) else ""
220 + ctxid = ctxid.strip()
221 +
222 + from_no = _coerce_non_negative_int(request.log_from, default=0)
223 + notifications_from_no = _coerce_non_negative_int(request.notifications_from, default=0)
224 +
225 + active_context = AgentContext.get(ctxid) if ctxid else None
226 +
227 + logs = active_context.log.output(start=from_no) if active_context else []
228 +
229 + notification_manager = AgentContext.get_notification_manager()
230 + notifications = notification_manager.output(start=notifications_from_no)
231 +
232 + scheduler = TaskScheduler.get()
233 +
234 + ctxs: list[dict[str, Any]] = []
235 + tasks: list[dict[str, Any]] = []
236 + processed_contexts: set[str] = set()
237 +
238 + all_ctxs = AgentContext.all()
239 + for ctx in all_ctxs:
240 + if ctx.id in processed_contexts:
241 + continue
242 +
243 + if ctx.type == AgentContextType.BACKGROUND:
244 + processed_contexts.add(ctx.id)
245 + continue
246 +
247 + context_data = ctx.output()
248 +
249 + context_task = scheduler.get_task_by_uuid(ctx.id)
250 + is_task_context = context_task is not None and context_task.context_id == ctx.id
251 +
252 + if not is_task_context:
253 + ctxs.append(context_data)
254 + else:
255 + task_details = scheduler.serialize_task(ctx.id)
256 + if task_details:
257 + context_data.update(
258 + {
259 + "task_name": task_details.get("name"),
260 + "uuid": task_details.get("uuid"),
261 + "state": task_details.get("state"),
262 + "type": task_details.get("type"),
263 + "system_prompt": task_details.get("system_prompt"),
264 + "prompt": task_details.get("prompt"),
265 + "last_run": task_details.get("last_run"),
266 + "last_result": task_details.get("last_result"),
267 + "attachments": task_details.get("attachments", []),
268 + "context_id": task_details.get("context_id"),
269 + }
270 + )
271 +
272 + if task_details.get("type") == "scheduled":
273 + context_data["schedule"] = task_details.get("schedule")
274 + elif task_details.get("type") == "planned":
275 + context_data["plan"] = task_details.get("plan")
276 + else:
277 + context_data["token"] = task_details.get("token")
278 +
279 + tasks.append(context_data)
280 +
281 + processed_contexts.add(ctx.id)
282 +
283 + ctxs.sort(key=lambda x: x["created_at"], reverse=True)
284 + tasks.sort(key=lambda x: x["created_at"], reverse=True)
285 +
286 + snapshot: SnapshotV1 = {
287 + "deselect_chat": bool(ctxid) and active_context is None,
288 + "context": active_context.id if active_context else "",
289 + "contexts": ctxs,
290 + "tasks": tasks,
291 + "logs": logs,
292 + "log_guid": active_context.log.guid if active_context else "",
293 + "log_version": len(active_context.log.updates) if active_context else 0,
294 + "log_progress": active_context.log.progress if active_context else 0,
295 + "log_progress_active": bool(active_context.log.progress_active) if active_context else False,
296 + "paused": active_context.paused if active_context else False,
297 + "notifications": notifications,
298 + "notifications_guid": notification_manager.guid,
299 + "notifications_version": len(notification_manager.updates),
300 + }
301 +
302 + validate_snapshot_schema_v1(snapshot)
303 + return snapshot
304 +
305 +
306 +async def build_snapshot(
307 + *,
308 + context: str | None,
309 + log_from: int,
310 + notifications_from: int,
311 + timezone: str | None,
312 +) -> SnapshotV1:
313 + request = _coerce_state_request_inputs(
314 + context=context,
315 + log_from=log_from,
316 + notifications_from=notifications_from,
317 + timezone=timezone,
318 + )
319 + return await build_snapshot_from_request(request=request)
python/helpers/task_scheduler.py
+11 -1
@@ -677,14 +677,20 @@ class TaskScheduler:
677 async def add_task(self, task: Union[ScheduledTask, AdHocTask, PlannedTask]) -> "TaskScheduler":
678 await self._tasks.add_task(task)
679 ctx = await self._get_chat_context(task) # invoke context creation
680 + from python.helpers.state_monitor_integration import mark_dirty_all
681 + mark_dirty_all(reason="task_scheduler.TaskScheduler.add_task")
682 return self
683
684 async def remove_task_by_uuid(self, task_uuid: str) -> "TaskScheduler":
685 await self._tasks.remove_task_by_uuid(task_uuid)
686 + from python.helpers.state_monitor_integration import mark_dirty_all
687 + mark_dirty_all(reason="task_scheduler.TaskScheduler.remove_task_by_uuid")
688 return self
689
690 async def remove_task_by_name(self, name: str) -> "TaskScheduler":
691 await self._tasks.remove_task_by_name(name)
692 + from python.helpers.state_monitor_integration import mark_dirty_all
693 + mark_dirty_all(reason="task_scheduler.TaskScheduler.remove_task_by_name")
694 return self
695
696 def get_task_by_uuid(self, task_uuid: str) -> Union[ScheduledTask, AdHocTask, PlannedTask] | None:
@@ -754,7 +760,11 @@ class TaskScheduler:
760 def _update_task(task):
761 task.update(**update_params)
762
757 - return await self._tasks.update_task_by_uuid(task_uuid, _update_task, verify_func)
763 + updated = await self._tasks.update_task_by_uuid(task_uuid, _update_task, verify_func)
764 + if updated is not None:
765 + from python.helpers.state_monitor_integration import mark_dirty_all
766 + mark_dirty_all(reason="task_scheduler.TaskScheduler.update_task_checked")
767 + return updated
768
769 async def update_task(self, task_uuid: str, **update_params) -> Union[ScheduledTask, AdHocTask, PlannedTask] | None:
770 return await self.update_task_checked(task_uuid, lambda task: True, **update_params)
python/helpers/websocket.py new
+568
@@ -0,0 +1,568 @@
1 +from __future__ import annotations
2 +
3 +import re
4 +import threading
5 +from abc import ABC, abstractmethod
6 +from urllib.parse import urlparse
7 +from typing import Any, Iterable, Optional, TYPE_CHECKING
8 +
9 +import socketio
10 +
11 +if TYPE_CHECKING: # pragma: no cover - hints only
12 + from python.helpers.websocket_manager import WebSocketManager
13 +
14 +_EVENT_NAME_PATTERN = re.compile(r"^[a-z][a-z0-9_]*$")
15 +_RESERVED_EVENT_NAMES: set[str] = {
16 + "connect",
17 + "disconnect",
18 + "error",
19 + "ping",
20 + "pong",
21 + "connect_error",
22 + "reconnect",
23 + "reconnect_attempt",
24 + "reconnect_error",
25 + "reconnect_failed",
26 +}
27 +
28 +
29 +def _default_port_for_scheme(scheme: str) -> int | None:
30 + if scheme == "http":
31 + return 80
32 + if scheme == "https":
33 + return 443
34 + return None
35 +
36 +
37 +def normalize_origin(value: Any) -> str | None:
38 + """Normalize an Origin/Referer header value to scheme://host[:port]."""
39 + if not isinstance(value, str) or not value.strip():
40 + return None
41 + parsed = urlparse(value.strip())
42 + if not parsed.scheme or not parsed.hostname:
43 + return None
44 + origin = f"{parsed.scheme}://{parsed.hostname}"
45 + if parsed.port:
46 + origin += f":{parsed.port}"
47 + return origin
48 +
49 +
50 +def _parse_host_header(value: Any) -> tuple[str | None, int | None]:
51 + if not isinstance(value, str) or not value.strip():
52 + return None, None
53 + parsed = urlparse(f"http://{value.strip()}")
54 + return parsed.hostname, parsed.port
55 +
56 +
57 +def validate_ws_origin(environ: dict[str, Any]) -> tuple[bool, str | None]:
58 + """Validate the browser Origin during the Socket.IO handshake.
59 +
60 + This is the minimum baseline recommended by RFC 6455 (Origin considerations)
61 + and OWASP (CSWSH mitigation): reject cross-origin WebSocket handshakes when
62 + the server is intended for a specific web UI origin.
63 + """
64 +
65 + raw_origin = environ.get("HTTP_ORIGIN") or environ.get("HTTP_REFERER")
66 + origin = normalize_origin(raw_origin)
67 + if origin is None:
68 + return False, "missing_origin"
69 +
70 + origin_parsed = urlparse(origin)
71 + origin_host = origin_parsed.hostname.lower() if origin_parsed.hostname else None
72 + origin_port = origin_parsed.port or _default_port_for_scheme(origin_parsed.scheme)
73 + if origin_host is None or origin_port is None:
74 + return False, "invalid_origin"
75 +
76 + # Build candidate request host/port pairs. Prefer explicit Host header, fall back to
77 + # forwarded headers (reverse proxies) and finally SERVER_NAME.
78 + raw_host = environ.get("HTTP_HOST")
79 + req_host, req_port = _parse_host_header(raw_host)
80 + if not req_host:
81 + req_host = environ.get("SERVER_NAME")
82 +
83 + if req_port is None:
84 + server_port_raw = environ.get("SERVER_PORT")
85 + try:
86 + server_port = int(server_port_raw) if server_port_raw is not None else None
87 + except (TypeError, ValueError):
88 + server_port = None
89 + if server_port is not None and server_port > 0:
90 + req_port = server_port
91 +
92 + if req_host:
93 + req_host = req_host.lower()
94 + if req_port is None:
95 + req_port = origin_port
96 +
97 + forwarded_host_raw = environ.get("HTTP_X_FORWARDED_HOST")
98 + forwarded_host = None
99 + forwarded_port = None
100 + if isinstance(forwarded_host_raw, str) and forwarded_host_raw.strip():
101 + first = forwarded_host_raw.split(",")[0].strip()
102 + forwarded_host, forwarded_port = _parse_host_header(first)
103 + if forwarded_host:
104 + forwarded_host = forwarded_host.lower()
105 +
106 + forwarded_proto_raw = environ.get("HTTP_X_FORWARDED_PROTO")
107 + forwarded_scheme = None
108 + if isinstance(forwarded_proto_raw, str) and forwarded_proto_raw.strip():
109 + forwarded_scheme = forwarded_proto_raw.split(",")[0].strip().lower()
110 + forwarded_scheme = forwarded_scheme or origin_parsed.scheme
111 + forwarded_port = (
112 + forwarded_port
113 + if forwarded_port is not None
114 + else _default_port_for_scheme(forwarded_scheme) or origin_port
115 + )
116 +
117 + candidates: list[tuple[str, int]] = []
118 + if req_host:
119 + candidates.append((req_host, int(req_port)))
120 + if forwarded_host:
121 + candidates.append((forwarded_host, int(forwarded_port)))
122 +
123 + if not candidates:
124 + return False, "missing_host"
125 +
126 + for host, port in candidates:
127 + if origin_host == host and origin_port == port:
128 + return True, None
129 +
130 + # Preserve the original mismatch semantics for debugging.
131 + if origin_host not in {host for host, _ in candidates}:
132 + return False, "origin_host_mismatch"
133 + return False, "origin_port_mismatch"
134 +
135 +
136 +class SingletonInstantiationError(RuntimeError):
137 + """Raised when a WebSocketHandler subclass is instantiated directly.
138 +
139 + Handlers must be retrieved via ``get_instance`` to guarantee singleton
140 + semantics and consistent lifecycle behaviour.
141 + """
142 +
143 +
144 +class ConnectionNotFoundError(RuntimeError):
145 + """Raised when attempting to emit to a non-existent WebSocket connection."""
146 +
147 + def __init__(self, sid: str, *, namespace: str | None = None) -> None:
148 + self.sid = sid
149 + self.namespace = namespace
150 + if namespace:
151 + super().__init__(f"Connection not found: namespace={namespace} sid={sid}")
152 + else:
153 + super().__init__(f"Connection not found: {sid}")
154 +
155 +
156 +class WebSocketResult:
157 + """Helper wrapper for standardized handler results.
158 +
159 + Instances are converted to the canonical ``RequestResultItem`` shape by
160 + :class:`WebSocketManager`. Helper constructors enforce payload validation so
161 + handlers no longer need to hand‑craft dictionaries.
162 + """
163 +
164 + __slots__ = ("_ok", "_data", "_error", "_correlation_id", "_duration_ms")
165 +
166 + def __init__(
167 + self,
168 + ok: bool,
169 + data: dict[str, Any] | None = None,
170 + error: dict[str, Any] | None = None,
171 + correlation_id: str | None = None,
172 + duration_ms: float | None = None,
173 + ) -> None:
174 + if ok and error:
175 + raise ValueError("Cannot be both ok and have an error")
176 + if not ok and not error:
177 + raise ValueError("Must either be ok or have an error")
178 + if data is not None and not isinstance(data, dict):
179 + raise TypeError("Data payload must be a dictionary or None")
180 + if error is not None and not isinstance(error, dict):
181 + raise TypeError("Error payload must be a dictionary or None")
182 + if correlation_id is not None and not isinstance(correlation_id, str):
183 + raise TypeError("Correlation ID must be a string or None")
184 + if duration_ms is not None and not isinstance(duration_ms, (int, float)):
185 + raise TypeError("Duration must be a number or None")
186 +
187 + self._ok = bool(ok)
188 + self._data = dict(data) if data is not None else None
189 + self._error = dict(error) if error is not None else None
190 + self._correlation_id = correlation_id
191 + self._duration_ms = float(duration_ms) if duration_ms is not None else None
192 +
193 + @classmethod
194 + def ok(
195 + cls,
196 + data: dict[str, Any] | None = None,
197 + *,
198 + correlation_id: str | None = None,
199 + duration_ms: float | None = None,
200 + ) -> "WebSocketResult":
201 + if data is not None and not isinstance(data, dict):
202 + raise TypeError("WebSocketResult.ok data must be a dict or None")
203 + payload = dict(data) if data is not None else None
204 + return cls(
205 + ok=True,
206 + data=payload,
207 + correlation_id=correlation_id,
208 + duration_ms=duration_ms,
209 + )
210 +
211 + @classmethod
212 + def error(
213 + cls,
214 + *,
215 + code: str,
216 + message: str,
217 + details: Any | None = None,
218 + correlation_id: str | None = None,
219 + duration_ms: float | None = None,
220 + ) -> "WebSocketResult":
221 + if not isinstance(code, str) or not code.strip():
222 + raise ValueError("Error code must be a non-empty string")
223 + if not isinstance(message, str) or not message.strip():
224 + raise ValueError("Error message must be a non-empty string")
225 +
226 + error_payload: dict[str, Any] = {"code": code, "error": message}
227 + if details is not None:
228 + error_payload["details"] = details
229 + return cls(
230 + ok=False,
231 + error=error_payload,
232 + correlation_id=correlation_id,
233 + duration_ms=duration_ms,
234 + )
235 +
236 + def as_result(
237 + self,
238 + *,
239 + handler_id: str,
240 + fallback_correlation_id: str | None,
241 + duration_ms: float | None = None,
242 + ) -> dict[str, Any]:
243 + result: dict[str, Any] = {
244 + "handlerId": handler_id,
245 + "ok": self._ok,
246 + }
247 +
248 + effective_duration = (
249 + self._duration_ms if self._duration_ms is not None else duration_ms
250 + )
251 + if effective_duration is not None:
252 + result["durationMs"] = round(effective_duration, 4)
253 +
254 + correlation = (
255 + self._correlation_id
256 + if self._correlation_id is not None
257 + else fallback_correlation_id
258 + )
259 + if correlation is not None:
260 + result["correlationId"] = correlation
261 +
262 + if self._ok:
263 + result["data"] = dict(self._data) if self._data is not None else {}
264 + else:
265 + result["error"] = dict(self._error) if self._error is not None else {
266 + "code": "INTERNAL_ERROR",
267 + "error": "Internal server error",
268 + }
269 + return result
270 +
271 +
272 +class WebSocketHandler(ABC):
273 + """Base class for WebSocket event handlers.
274 +
275 + The interface mirrors :class:`python.helpers.api.ApiHandler` with declarative
276 + security configuration and lifecycle hooks while enforcing event-naming
277 + conventions.
278 + """
279 +
280 + _instances: dict[type["WebSocketHandler"], "WebSocketHandler"] = {}
281 + _construction_tokens: dict[type["WebSocketHandler"], bool] = {}
282 + _singleton_lock = threading.RLock()
283 +
284 + def __init__(self, socketio: socketio.AsyncServer, lock: threading.RLock) -> None:
285 + """Create a handler bound to the shared Socket.IO instance."""
286 +
287 + cls = self.__class__
288 + if not WebSocketHandler._construction_tokens.get(cls):
289 + raise SingletonInstantiationError(
290 + f"{cls.__name__} must be instantiated via {cls.__name__}.get_instance()"
291 + )
292 +
293 + self.socketio: socketio.AsyncServer = socketio
294 + self.lock: threading.RLock = lock
295 + self._manager: Optional[WebSocketManager] = None
296 + self._namespace: str | None = None
297 +
298 + @classmethod
299 + def get_instance(
300 + cls,
301 + socketio: socketio.AsyncServer | None = None,
302 + lock: threading.RLock | None = None,
303 + *args: Any,
304 + **kwargs: Any,
305 + ) -> "WebSocketHandler":
306 + """Return the singleton instance for ``cls``.
307 +
308 + Args:
309 + socketio: Shared AsyncServer instance (required on first call).
310 + lock: Shared threading lock (required on first call).
311 + *args: Optional subclass-specific constructor args.
312 + **kwargs: Optional subclass-specific constructor kwargs.
313 + """
314 +
315 + if cls is WebSocketHandler:
316 + raise TypeError("WebSocketHandler must be subclassed before use")
317 +
318 + with WebSocketHandler._singleton_lock:
319 + instance = WebSocketHandler._instances.get(cls)
320 + if instance is not None:
321 + return instance
322 +
323 + if socketio is None or lock is None:
324 + raise ValueError(
325 + f"{cls.__name__}.get_instance() requires socketio and lock on first call"
326 + )
327 +
328 + WebSocketHandler._construction_tokens[cls] = True
329 + try:
330 + instance = cls(socketio, lock, *args, **kwargs)
331 + finally:
332 + WebSocketHandler._construction_tokens.pop(cls, None)
333 +
334 + WebSocketHandler._instances[cls] = instance
335 + return instance
336 +
337 + @classmethod
338 + def _reset_instance_for_testing(cls) -> None:
339 + """Reset the cached singleton instance (testing helper)."""
340 +
341 + with WebSocketHandler._singleton_lock:
342 + WebSocketHandler._instances.pop(cls, None)
343 + WebSocketHandler._construction_tokens.pop(cls, None)
344 +
345 + @classmethod
346 + @abstractmethod
347 + def get_event_types(cls) -> list[str]:
348 + """Return the list of event types this handler subscribes to."""
349 +
350 + @classmethod
351 + def validate_event_types(cls, event_types: Iterable[str]) -> list[str]:
352 + """Validate event type declarations.
353 +
354 + Ensures that every event name follows ``lowercase_snake_case`` naming,
355 + does not collide with Socket.IO reserved events, and that the handler
356 + does not declare duplicates.
357 + """
358 +
359 + validated: list[str] = []
360 + seen: set[str] = set()
361 + for event in event_types:
362 + if not isinstance(event, str):
363 + raise TypeError("Event type declarations must be strings")
364 + if not _EVENT_NAME_PATTERN.fullmatch(event):
365 + raise ValueError(
366 + f"Invalid event type '{event}' – must match lowercase_snake_case"
367 + )
368 + if event in _RESERVED_EVENT_NAMES:
369 + raise ValueError(
370 + f"Event type '{event}' is reserved by Socket.IO and cannot be used"
371 + )
372 + if event in seen:
373 + raise ValueError(f"Duplicate event type '{event}' declared in handler")
374 + seen.add(event)
375 + validated.append(event)
376 + if not validated:
377 + raise ValueError("Handlers must declare at least one event type")
378 + return validated
379 +
380 + @classmethod
381 + def requires_auth(cls) -> bool:
382 + """Return whether an authenticated Flask session is required."""
383 +
384 + return True
385 +
386 + @classmethod
387 + def requires_csrf(cls) -> bool:
388 + """Return whether CSRF validation is required for the handler.
389 +
390 + This mirrors ApiHandler.requires_csrf(): by default, authenticated
391 + WebSocket handlers also require CSRF validation during the Socket.IO
392 + connect step.
393 + """
394 +
395 + return cls.requires_auth()
396 +
397 + async def on_connect(self, sid: str) -> None:
398 + """Lifecycle hook invoked when a client connects."""
399 +
400 + return None
401 +
402 + async def on_disconnect(self, sid: str) -> None:
403 + """Lifecycle hook invoked when a client disconnects."""
404 +
405 + return None
406 +
407 + @abstractmethod
408 + async def process_event(
409 + self,
410 + event_type: str,
411 + data: dict[str, Any],
412 + sid: str,
413 + ) -> dict[str, Any] | WebSocketResult | None:
414 + """Process an incoming event dispatched to the handler.
415 +
416 + Returning ``None`` indicates fire-and-forget semantics. Returning a
417 + dictionary includes the payload in the Socket.IO acknowledgement.
418 + """
419 +
420 + def bind_manager(self, manager: WebSocketManager, *, namespace: str) -> None:
421 + """Associate this handler instance with the shared WebSocket manager."""
422 +
423 + self._manager = manager
424 + self._namespace = namespace
425 +
426 + @property
427 + def namespace(self) -> str:
428 + if not self._namespace:
429 + raise RuntimeError("WebSocketHandler is missing namespace binding")
430 + return self._namespace
431 +
432 + @property
433 + def manager(self) -> WebSocketManager:
434 + """Return the bound WebSocket manager.
435 +
436 + Raises:
437 + RuntimeError: If the handler has not been registered yet.
438 + """
439 +
440 + if not self._manager:
441 + raise RuntimeError("WebSocketHandler is not registered with a manager")
442 + return self._manager
443 +
444 + @property
445 + def identifier(self) -> str:
446 + """Return a stable identifier used in aggregated responses."""
447 +
448 + return f"{self.__class__.__module__}.{self.__class__.__name__}"
449 +
450 + async def emit_to(
451 + self,
452 + sid: str,
453 + event_type: str,
454 + data: dict[str, Any],
455 + *,
456 + correlation_id: str | None = None,
457 + ) -> None:
458 + """Emit an event to a specific connection or buffer it if offline."""
459 + await self.manager.emit_to(
460 + self.namespace,
461 + sid,
462 + event_type,
463 + data,
464 + handler_id=self.identifier,
465 + correlation_id=correlation_id,
466 + )
467 +
468 + async def broadcast(
469 + self,
470 + event_type: str,
471 + data: dict[str, Any],
472 + *,
473 + exclude_sids: str | Iterable[str] | None = None,
474 + correlation_id: str | None = None,
475 + ) -> None:
476 + """Broadcast an event to all connections, optionally excluding one."""
477 + await self.manager.broadcast(
478 + self.namespace,
479 + event_type,
480 + data,
481 + exclude_sids=exclude_sids,
482 + handler_id=self.identifier,
483 + correlation_id=correlation_id,
484 + )
485 +
486 + # ------------------------------------------------------------------
487 + # Convenience wrappers for standardized result helpers
488 + # ------------------------------------------------------------------
489 +
490 + @staticmethod
491 + def result_ok(
492 + data: dict[str, Any] | None = None,
493 + *,
494 + correlation_id: str | None = None,
495 + duration_ms: float | None = None,
496 + ) -> WebSocketResult:
497 + """Return a standardized success result."""
498 +
499 + return WebSocketResult.ok(
500 + data=data,
501 + correlation_id=correlation_id,
502 + duration_ms=duration_ms,
503 + )
504 +
505 + @staticmethod
506 + def result_error(
507 + *,
508 + code: str,
509 + message: str,
510 + details: Any | None = None,
511 + correlation_id: str | None = None,
512 + duration_ms: float | None = None,
513 + ) -> WebSocketResult:
514 + """Return a standardized error result."""
515 +
516 + return WebSocketResult.error(
517 + code=code,
518 + message=message,
519 + details=details,
520 + correlation_id=correlation_id,
521 + duration_ms=duration_ms,
522 + )
523 +
524 + async def request(
525 + self,
526 + sid: str,
527 + event_type: str,
528 + data: dict[str, Any],
529 + *,
530 + timeout_ms: int = 0,
531 + include_handlers: Iterable[str] | None = None,
532 + ) -> dict[str, Any]:
533 + """Send a request-response event to a specific connection and aggregate results.
534 +
535 + Returns a payload shaped as ``{"correlationId": str, "results": RequestResultItem[]}``.
536 + """
537 +
538 + return await self.manager.request_for_sid(
539 + namespace=self.namespace,
540 + sid=sid,
541 + event_type=event_type,
542 + data=data,
543 + timeout_ms=timeout_ms,
544 + handler_id=self.identifier,
545 + include_handlers=set(include_handlers) if include_handlers else None,
546 + )
547 +
548 + async def request_all(
549 + self,
550 + event_type: str,
551 + data: dict[str, Any],
552 + *,
553 + timeout_ms: int = 0,
554 + exclude_handlers: Iterable[str] | None = None,
555 + ) -> list[dict[str, Any]]:
556 + """Fan a request out to every active connection and aggregate responses.
557 +
558 + Each entry in the returned list is ``{"sid": str, "correlationId": str, "results": RequestResultItem[]}``.
559 + """
560 +
561 + return await self.manager.route_event_all(
562 + self.namespace,
563 + event_type=event_type,
564 + data=data,
565 + timeout_ms=timeout_ms,
566 + exclude_handlers=set(exclude_handlers) if exclude_handlers else None,
567 + handler_id=self.identifier,
568 + )
python/helpers/websocket_manager.py new
+1147
@@ -0,0 +1,1147 @@
1 +from __future__ import annotations
2 +
3 +import asyncio
4 +import time
5 +import threading
6 +from collections import defaultdict, deque
7 +from dataclasses import dataclass, field
8 +from datetime import datetime, timedelta, timezone
9 +from typing import Any, Callable, Deque, Dict, Iterable, List, Optional, Set
10 +
11 +import socketio
12 +import uuid
13 +
14 +from python.helpers.defer import DeferredTask
15 +from python.helpers.print_style import PrintStyle
16 +from python.helpers import runtime
17 +from python.helpers.websocket import ConnectionNotFoundError, WebSocketHandler, WebSocketResult
18 +
19 +BUFFER_MAX_SIZE = 100
20 +BUFFER_TTL = timedelta(hours=1)
21 +
22 +
23 +def _utcnow() -> datetime:
24 + return datetime.now(timezone.utc)
25 +
26 +
27 +@dataclass
28 +class BufferedEvent:
29 + event_type: str
30 + data: dict[str, Any]
31 + handler_id: str | None = None
32 + correlation_id: str | None = None
33 + timestamp: datetime = field(default_factory=_utcnow)
34 +
35 +
36 +@dataclass
37 +class ConnectionInfo:
38 + namespace: str
39 + sid: str
40 + connected_at: datetime = field(default_factory=_utcnow)
41 + last_activity: datetime = field(default_factory=_utcnow)
42 +
43 +
44 +ConnectionIdentity = tuple[str, str] # (namespace, sid)
45 +
46 +
47 +@dataclass
48 +class _HandlerExecution:
49 + handler: WebSocketHandler
50 + value: Any
51 + duration_ms: float | None
52 +
53 +
54 +DIAGNOSTIC_EVENT = "ws_dev_console_event"
55 +LIFECYCLE_CONNECT_EVENT = "ws_lifecycle_connect"
56 +LIFECYCLE_DISCONNECT_EVENT = "ws_lifecycle_disconnect"
57 +
58 +
59 +class WebSocketManager:
60 + def __init__(self, socketio: socketio.AsyncServer, lock) -> None:
61 + self.socketio = socketio
62 + self.lock = lock
63 + self.handlers: defaultdict[str, defaultdict[str, List[WebSocketHandler]]] = defaultdict(
64 + lambda: defaultdict(list)
65 + )
66 + self.connections: Dict[ConnectionIdentity, ConnectionInfo] = {}
67 + self.buffers: defaultdict[ConnectionIdentity, Deque[BufferedEvent]] = defaultdict(deque)
68 + self._known_sids: Set[ConnectionIdentity] = set()
69 + self._identifier: str = f"{self.__class__.__module__}.{self.__class__.__name__}"
70 + # Session tracking (single-user default)
71 + self.user_to_sids: defaultdict[str, Set[ConnectionIdentity]] = defaultdict(set)
72 + self.sid_to_user: Dict[ConnectionIdentity, str | None] = {}
73 + self._ALL_USERS_BUCKET = "allUsers"
74 + self._server_restart_enabled: bool = False
75 + self._diagnostic_watchers: Set[ConnectionIdentity] = set()
76 + self._diagnostics_enabled: bool = runtime.is_development()
77 + self._dispatcher_loop: asyncio.AbstractEventLoop | None = None
78 + self._handler_worker: DeferredTask | None = None
79 +
80 + # Internal: development-only debug logging to avoid noise in production
81 + def _debug(self, message: str) -> None:
82 + if runtime.is_development():
83 + PrintStyle.debug(message)
84 +
85 + def _ensure_dispatcher_loop(self) -> None:
86 + if self._dispatcher_loop is None:
87 + try:
88 + self._dispatcher_loop = asyncio.get_running_loop()
89 + except RuntimeError:
90 + return
91 +
92 + def _get_handler_worker(self) -> DeferredTask:
93 + if self._handler_worker is None:
94 + self._handler_worker = DeferredTask(thread_name="WebSocketHandlers")
95 + return self._handler_worker
96 +
97 + async def _run_on_dispatcher_loop(self, coro: Any) -> Any:
98 + self._ensure_dispatcher_loop()
99 + dispatcher_loop = self._dispatcher_loop
100 + if dispatcher_loop is None:
101 + return await coro
102 + if dispatcher_loop.is_closed():
103 + try:
104 + coro.close()
105 + except Exception: # pragma: no cover - best-effort cleanup
106 + pass
107 + raise RuntimeError("Dispatcher event loop is closed")
108 +
109 + try:
110 + running_loop = asyncio.get_running_loop()
111 + except RuntimeError:
112 + running_loop = None
113 +
114 + if running_loop is dispatcher_loop:
115 + return await coro
116 +
117 + future = asyncio.run_coroutine_threadsafe(coro, dispatcher_loop)
118 + return await asyncio.wrap_future(future)
119 +
120 + def _diagnostics_active(self) -> bool:
121 + if not self._diagnostics_enabled:
122 + return False
123 + with self.lock:
124 + return bool(self._diagnostic_watchers)
125 +
126 + def _copy_diagnostic_watchers(self) -> list[ConnectionIdentity]:
127 + with self.lock:
128 + return list(self._diagnostic_watchers)
129 +
130 + def register_diagnostic_watcher(self, namespace: str, sid: str) -> bool:
131 + if not self._diagnostics_enabled:
132 + return False
133 + identity: ConnectionIdentity = (namespace, sid)
134 + with self.lock:
135 + if identity not in self.connections:
136 + return False
137 + self._diagnostic_watchers.add(identity)
138 + return True
139 +
140 + def unregister_diagnostic_watcher(self, namespace: str, sid: str) -> None:
141 + identity: ConnectionIdentity = (namespace, sid)
142 + with self.lock:
143 + self._diagnostic_watchers.discard(identity)
144 +
145 + def _timestamp(self) -> str:
146 + return _utcnow().isoformat(timespec="milliseconds").replace("+00:00", "Z")
147 +
148 + def _summarize_payload(self, payload: dict[str, Any] | None) -> dict[str, Any]:
149 + if not isinstance(payload, dict):
150 + return {}
151 + summary: dict[str, Any] = {}
152 + for key in list(payload.keys())[:5]:
153 + value = payload[key]
154 + if isinstance(value, (str, int, float, bool)) or value is None:
155 + preview = value
156 + elif isinstance(value, dict):
157 + preview = f"dict({len(value)})"
158 + elif isinstance(value, list):
159 + preview = f"list({len(value)})"
160 + else:
161 + preview = value.__class__.__name__
162 + summary[key] = preview
163 + summary["__sizeBytes__"] = len(str(payload).encode("utf-8"))
164 + return summary
165 +
166 + def _summarize_results(self, results: List[dict[str, Any]]) -> dict[str, Any]:
167 + summary = {"ok": 0, "error": 0, "handlers": []}
168 + for result in results:
169 + handler_id = result.get("handlerId")
170 + ok = bool(result.get("ok"))
171 + if ok:
172 + summary["ok"] += 1
173 + else:
174 + summary["error"] += 1
175 + summary["handlers"].append(
176 + {
177 + "handlerId": handler_id,
178 + "ok": ok,
179 + "errorCode": (result.get("error") or {}).get("code"),
180 + "durationMs": result.get("durationMs"),
181 + }
182 + )
183 + summary["handlerCount"] = len(summary["handlers"])
184 + return summary
185 +
186 + async def _publish_diagnostic_event(
187 + self, payload: dict[str, Any] | Callable[[], dict[str, Any]]
188 + ) -> None:
189 + if not self._diagnostics_enabled:
190 + return
191 + watchers = self._copy_diagnostic_watchers()
192 + if not watchers:
193 + return
194 + effective_payload = payload() if callable(payload) else payload
195 + if (
196 + isinstance(effective_payload, dict)
197 + and "sourceNamespace" not in effective_payload
198 + ):
199 + origin = effective_payload.get("namespace")
200 + if isinstance(origin, str) and origin.strip():
201 + effective_payload = {
202 + **effective_payload,
203 + "sourceNamespace": origin.strip(),
204 + }
205 +
206 + async def _emit_to_watcher(identity: ConnectionIdentity) -> None:
207 + namespace, sid = identity
208 + try:
209 + await self.emit_to(
210 + namespace,
211 + sid,
212 + DIAGNOSTIC_EVENT,
213 + effective_payload,
214 + handler_id=self._identifier,
215 + diagnostic=True,
216 + )
217 + except ConnectionNotFoundError:
218 + self.unregister_diagnostic_watcher(namespace, sid)
219 +
220 + await asyncio.gather(*(_emit_to_watcher(identity) for identity in watchers))
221 +
222 + def _schedule_lifecycle_broadcast(
223 + self, namespace: str, event_type: str, payload: dict[str, Any]
224 + ) -> None:
225 + async def _broadcast() -> None:
226 + try:
227 + await self.broadcast(
228 + namespace,
229 + event_type,
230 + payload,
231 + diagnostic=True,
232 + )
233 + except Exception as exc: # pragma: no cover - diagnostic
234 + self._debug(f"Failed to broadcast lifecycle event {event_type}: {exc}")
235 +
236 + asyncio.create_task(_broadcast())
237 +
238 + def _normalize_handler_filter(
239 + self, value: Any, field_name: str
240 + ) -> Set[str] | None:
241 + if value is None:
242 + return None
243 + if isinstance(value, str):
244 + return {value}
245 + try:
246 + iterator = iter(value)
247 + except TypeError as exc: # pragma: no cover - defensive
248 + raise ValueError(f"{field_name} must be an array of handler identifiers") from exc
249 +
250 + normalized: Set[str] = set()
251 + for item in iterator:
252 + if not isinstance(item, str):
253 + raise ValueError(
254 + f"{field_name} values must be handler identifier strings"
255 + )
256 + normalized.add(item)
257 + return normalized
258 +
259 + def _normalize_sid_filter(
260 + self, value: str | Iterable[str] | None
261 + ) -> Set[str]:
262 + if value is None:
263 + return set()
264 + if isinstance(value, str):
265 + return {value}
266 + normalized: Set[str] = set()
267 + for item in value:
268 + normalized.add(str(item))
269 + return normalized
270 +
271 + def _select_handlers(
272 + self,
273 + namespace: str,
274 + event_type: str,
275 + *,
276 + include: Set[str] | None,
277 + exclude: Set[str] | None,
278 + ) -> tuple[list[WebSocketHandler], Set[str]]:
279 + registered = self.handlers.get(namespace, {}).get(event_type, [])
280 + available_ids = {handler.identifier for handler in registered}
281 +
282 + if include is not None:
283 + unknown = include - available_ids
284 + if unknown:
285 + raise ValueError(
286 + f"Unknown handler(s) in includeHandlers for namespace '{namespace}': "
287 + f"{', '.join(sorted(unknown))}"
288 + )
289 + if exclude is not None:
290 + unknown = exclude - available_ids
291 + if unknown:
292 + raise ValueError(
293 + f"Unknown handler(s) in excludeHandlers for namespace '{namespace}': "
294 + f"{', '.join(sorted(unknown))}"
295 + )
296 +
297 + selected: list[WebSocketHandler] = []
298 + for handler in registered:
299 + ident = handler.identifier
300 + if include is not None and ident not in include:
301 + continue
302 + if exclude is not None and ident in exclude:
303 + continue
304 + selected.append(handler)
305 +
306 + return selected, available_ids
307 +
308 + def _resolve_correlation_id(self, payload: dict[str, Any]) -> str:
309 + value = payload.get("correlationId")
310 + if isinstance(value, str) and value.strip():
311 + correlation_id = value.strip()
312 + else:
313 + correlation_id = uuid.uuid4().hex
314 + payload["correlationId"] = correlation_id
315 + return correlation_id
316 +
317 + def register_handlers(
318 + self, handlers_by_namespace: dict[str, Iterable[WebSocketHandler]]
319 + ) -> None:
320 + for namespace, handlers in handlers_by_namespace.items():
321 + for handler in handlers:
322 + handler.bind_manager(self, namespace=namespace)
323 + declared = handler.get_event_types()
324 + try:
325 + validated_events = handler.validate_event_types(declared)
326 + except Exception as exc:
327 + PrintStyle.error(
328 + f"Failed to register handler {handler.identifier}: {exc}"
329 + )
330 + raise
331 +
332 + PrintStyle.info(
333 + "Registered WebSocket handler %s namespace=%s for events: %s"
334 + % (handler.identifier, namespace, ", ".join(validated_events))
335 + )
336 + for event_type in validated_events:
337 + existing = self.handlers[namespace].get(event_type)
338 + if existing:
339 + PrintStyle.warning(
340 + f"Duplicate handler registration for namespace '{namespace}' event '{event_type}'"
341 + )
342 + self.handlers[namespace][event_type].append(handler)
343 + self._debug(
344 + f"Registered handler {handler.identifier} namespace={namespace} event='{event_type}'"
345 + )
346 +
347 + def iter_event_types(self, namespace: str) -> Iterable[str]:
348 + return list(self.handlers.get(namespace, {}).keys())
349 +
350 + def iter_namespaces(self) -> list[str]:
351 + return list(self.handlers.keys())
352 +
353 + async def _invoke_handler(
354 + self,
355 + handler: WebSocketHandler,
356 + event_type: str,
357 + payload: dict[str, Any],
358 + sid: str,
359 + ) -> _HandlerExecution:
360 + instrument = self._diagnostics_active()
361 + start = time.perf_counter() if instrument else None
362 + try:
363 + value = await self._get_handler_worker().execute_inside(
364 + handler.process_event, event_type, payload, sid
365 + )
366 + except Exception as exc: # pragma: no cover - handled by caller
367 + duration_ms = (
368 + (time.perf_counter() - start) * 1000 if start is not None else None
369 + )
370 + return _HandlerExecution(handler, exc, duration_ms)
371 + duration_ms = (
372 + (time.perf_counter() - start) * 1000 if start is not None else None
373 + )
374 + return _HandlerExecution(handler, value, duration_ms)
375 +
376 + async def handle_connect(
377 + self, namespace: str, sid: str, user_id: str | None = None
378 + ) -> None:
379 + self._ensure_dispatcher_loop()
380 + user_bucket = user_id or "single_user"
381 + identity: ConnectionIdentity = (namespace, sid)
382 + with self.lock:
383 + self.connections[identity] = ConnectionInfo(namespace=namespace, sid=sid)
384 + self._known_sids.add(identity)
385 + self.sid_to_user[identity] = user_bucket
386 + self.user_to_sids[self._ALL_USERS_BUCKET].add(identity)
387 + self.user_to_sids[user_bucket].add(identity)
388 + connection_count = sum(
389 + 1 for conn_identity in self.connections if conn_identity[0] == namespace
390 + )
391 + PrintStyle.info(f"WebSocket connected: namespace={namespace} sid={sid}")
392 + await self._run_lifecycle(namespace, lambda h: h.on_connect(sid))
393 + await self._flush_buffer(identity)
394 + if self._server_restart_enabled:
395 + await self.emit_to(
396 + namespace,
397 + sid,
398 + "server_restart",
399 + {
400 + "emittedAt": _utcnow()
401 + .isoformat(timespec="milliseconds")
402 + .replace("+00:00", "Z"),
403 + "runtimeId": runtime.get_runtime_id(),
404 + },
405 + handler_id=self._identifier,
406 + )
407 + PrintStyle.info(
408 + f"server_restart broadcast emitted to namespace={namespace} sid={sid}"
409 + )
410 + lifecycle_payload = {
411 + "namespace": namespace,
412 + "sid": sid,
413 + "connectionCount": connection_count,
414 + "timestamp": self._timestamp(),
415 + }
416 + await self._publish_diagnostic_event(
417 + {
418 + "kind": "lifecycle",
419 + "event": "connect",
420 + **lifecycle_payload,
421 + }
422 + )
423 + self._schedule_lifecycle_broadcast(
424 + namespace, LIFECYCLE_CONNECT_EVENT, lifecycle_payload
425 + )
426 +
427 + async def handle_disconnect(self, namespace: str, sid: str) -> None:
428 + self._ensure_dispatcher_loop()
429 + identity: ConnectionIdentity = (namespace, sid)
430 + with self.lock:
431 + self.connections.pop(identity, None)
432 + # session tracking cleanup
433 + user_bucket = self.sid_to_user.pop(identity, None)
434 + if self._ALL_USERS_BUCKET in self.user_to_sids:
435 + self.user_to_sids[self._ALL_USERS_BUCKET].discard(identity)
436 + if not self.user_to_sids[self._ALL_USERS_BUCKET]:
437 + self.user_to_sids.pop(self._ALL_USERS_BUCKET, None)
438 + if user_bucket and user_bucket in self.user_to_sids:
439 + self.user_to_sids[user_bucket].discard(identity)
440 + if not self.user_to_sids[user_bucket]:
441 + self.user_to_sids.pop(user_bucket, None)
442 + connection_count = sum(
443 + 1 for conn_identity in self.connections if conn_identity[0] == namespace
444 + )
445 + self.unregister_diagnostic_watcher(namespace, sid)
446 + PrintStyle.info(f"WebSocket disconnected: namespace={namespace} sid={sid}")
447 + await self._run_lifecycle(namespace, lambda h: h.on_disconnect(sid))
448 + lifecycle_payload = {
449 + "namespace": namespace,
450 + "sid": sid,
451 + "connectionCount": connection_count,
452 + "timestamp": self._timestamp(),
453 + }
454 + await self._publish_diagnostic_event(
455 + {
456 + "kind": "lifecycle",
457 + "event": "disconnect",
458 + **lifecycle_payload,
459 + }
460 + )
461 + self._schedule_lifecycle_broadcast(
462 + namespace, LIFECYCLE_DISCONNECT_EVENT, lifecycle_payload
463 + )
464 +
465 + async def route_event(
466 + self,
467 + namespace: str,
468 + event_type: str,
469 + data: dict[str, Any],
470 + sid: str,
471 + ack: Optional[Callable[[Any], None]] = None,
472 + *,
473 + include_handlers: Set[str] | None = None,
474 + exclude_handlers: Set[str] | None = None,
475 + allow_exclude: bool = False,
476 + handler_id: str | None = None,
477 + ) -> dict[str, Any]:
478 + self._ensure_dispatcher_loop()
479 + incoming = dict(data or {})
480 + correlation_id = self._resolve_correlation_id(incoming)
481 + self._debug(
482 + f"Routing event namespace={namespace} '{event_type}' sid={sid} correlation={correlation_id}"
483 + )
484 +
485 + include_meta_raw = incoming.pop("includeHandlers", None)
486 + exclude_meta_raw = incoming.pop("excludeHandlers", None)
487 +
488 + if "data" in incoming and isinstance(incoming.get("data"), dict):
489 + handler_payload = dict(incoming.get("data") or {})
490 + if "excludeSids" in incoming:
491 + handler_payload["excludeSids"] = incoming.get("excludeSids")
492 + else:
493 + handler_payload = dict(incoming)
494 +
495 + handler_payload["correlationId"] = correlation_id
496 +
497 + try:
498 + include_meta = self._normalize_handler_filter(
499 + include_meta_raw, "includeHandlers"
500 + )
501 + except ValueError as exc:
502 + error = self._build_error_result(
503 + handler_id=handler_id or self._identifier,
504 + code="INVALID_FILTER",
505 + message=str(exc),
506 + correlation_id=correlation_id,
507 + )
508 + if ack:
509 + ack({"correlationId": correlation_id, "results": [error]})
510 + return {"correlationId": correlation_id, "results": [error]}
511 +
512 + try:
513 + exclude_meta = self._normalize_handler_filter(
514 + exclude_meta_raw, "excludeHandlers"
515 + )
516 + except ValueError as exc:
517 + error = self._build_error_result(
518 + handler_id=handler_id or self._identifier,
519 + code="INVALID_FILTER",
520 + message=str(exc),
521 + correlation_id=correlation_id,
522 + )
523 + payload_error = {"correlationId": correlation_id, "results": [error]}
524 + if ack:
525 + ack(payload_error)
526 + return payload_error
527 +
528 + if exclude_meta_raw is not None and not allow_exclude:
529 + error = self._build_error_result(
530 + handler_id=handler_id or self._identifier,
531 + code="INVALID_FILTER",
532 + message="excludeHandlers is not supported for this operation",
533 + correlation_id=correlation_id,
534 + )
535 + if ack:
536 + ack({"correlationId": correlation_id, "results": [error]})
537 + return {"correlationId": correlation_id, "results": [error]}
538 +
539 + if include_handlers is not None and include_meta is not None:
540 + if include_handlers != include_meta:
541 + error = self._build_error_result(
542 + handler_id=handler_id or self._identifier,
543 + code="INVALID_FILTER",
544 + message="Conflicting includeHandlers filters supplied",
545 + correlation_id=correlation_id,
546 + )
547 + if ack:
548 + ack({"correlationId": correlation_id, "results": [error]})
549 + return {"correlationId": correlation_id, "results": [error]}
550 +
551 + if allow_exclude and exclude_handlers is not None and exclude_meta is not None:
552 + if exclude_handlers != exclude_meta:
553 + error = self._build_error_result(
554 + handler_id=handler_id or self._identifier,
555 + code="INVALID_FILTER",
556 + message="Conflicting excludeHandlers filters supplied",
557 + correlation_id=correlation_id,
558 + )
559 + if ack:
560 + ack({"correlationId": correlation_id, "results": [error]})
561 + return {"correlationId": correlation_id, "results": [error]}
562 +
563 + include = include_handlers or include_meta
564 + exclude = exclude_handlers or (exclude_meta if allow_exclude else None)
565 +
566 + registered = self.handlers.get(namespace, {}).get(event_type, [])
567 + if not registered:
568 + PrintStyle.warning(f"No handlers registered for event '{event_type}'")
569 + error = self._build_error_result(
570 + handler_id=handler_id or self._identifier,
571 + code="NO_HANDLERS",
572 + message=f"No handler for namespace '{namespace}' event '{event_type}'",
573 + correlation_id=correlation_id,
574 + )
575 + if ack:
576 + ack({"correlationId": correlation_id, "results": [error]})
577 + return {"correlationId": correlation_id, "results": [error]}
578 +
579 + try:
580 + selected_handlers, _ = self._select_handlers(
581 + namespace, event_type, include=include, exclude=exclude
582 + )
583 + except ValueError as exc:
584 + error = self._build_error_result(
585 + handler_id=handler_id or self._identifier,
586 + code="INVALID_FILTER",
587 + message=str(exc),
588 + correlation_id=correlation_id,
589 + )
590 + if ack:
591 + ack({"correlationId": correlation_id, "results": [error]})
592 + return {"correlationId": correlation_id, "results": [error]}
593 +
594 + if not selected_handlers:
595 + error = self._build_error_result(
596 + handler_id=handler_id or self._identifier,
597 + code="NO_HANDLERS",
598 + message=f"No handler for '{event_type}' after applying filters",
599 + correlation_id=correlation_id,
600 + )
601 + if ack:
602 + ack({"correlationId": correlation_id, "results": [error]})
603 + return {"correlationId": correlation_id, "results": [error]}
604 +
605 + with self.lock:
606 + info = self.connections.get((namespace, sid))
607 + if info:
608 + info.last_activity = _utcnow()
609 +
610 + executions = await asyncio.gather(
611 + *[
612 + self._invoke_handler(handler, event_type, dict(handler_payload), sid)
613 + for handler in selected_handlers
614 + ]
615 + )
616 +
617 + results: List[dict[str, Any]] = []
618 + for execution in executions:
619 + handler = execution.handler
620 + value = execution.value
621 + duration_ms = execution.duration_ms
622 +
623 + if isinstance(value, Exception): # pragma: no cover - defensive logging
624 + PrintStyle.error(
625 + f"Error in handler {handler.identifier} for '{event_type}' (correlation {correlation_id}): {value}"
626 + )
627 + results.append(
628 + self._build_error_result(
629 + handler_id=handler.identifier,
630 + code="HANDLER_ERROR",
631 + message="Internal server error",
632 + details=str(value),
633 + correlation_id=correlation_id,
634 + duration_ms=duration_ms,
635 + )
636 + )
637 + continue
638 +
639 + if isinstance(value, WebSocketResult):
640 + results.append(
641 + value.as_result(
642 + handler_id=handler.identifier,
643 + fallback_correlation_id=correlation_id,
644 + duration_ms=duration_ms,
645 + )
646 + )
647 + continue
648 +
649 + if value is None:
650 + helper_result = WebSocketResult(ok=True)
651 + elif isinstance(value, dict):
652 + helper_result = WebSocketResult(ok=True, data=value)
653 + else:
654 + helper_result = WebSocketResult(ok=True, data={"result": value})
655 +
656 + results.append(
657 + helper_result.as_result(
658 + handler_id=handler.identifier,
659 + fallback_correlation_id=correlation_id,
660 + duration_ms=duration_ms,
661 + )
662 + )
663 +
664 + await self._publish_diagnostic_event(
665 + lambda: {
666 + "kind": "inbound",
667 + "sourceNamespace": namespace,
668 + "namespace": namespace,
669 + "eventType": event_type,
670 + "sid": sid,
671 + "correlationId": correlation_id,
672 + "timestamp": self._timestamp(),
673 + "handlerCount": len(selected_handlers),
674 + "durationMs": sum((exec.duration_ms or 0.0) for exec in executions),
675 + "resultSummary": self._summarize_results(results),
676 + "payloadSummary": self._summarize_payload(handler_payload),
677 + }
678 + )
679 +
680 + response_payload = {"correlationId": correlation_id, "results": results}
681 + if ack:
682 + ack(response_payload)
683 + self._debug(
684 + f"Completed event namespace={namespace} '{event_type}' sid={sid} correlation={correlation_id}"
685 + )
686 + return response_payload
687 +
688 + async def request_for_sid(
689 + self,
690 + *,
691 + namespace: str,
692 + sid: str,
693 + event_type: str,
694 + data: dict[str, Any],
695 + timeout_ms: int = 0,
696 + handler_id: str | None = None,
697 + include_handlers: Set[str] | None = None,
698 + ) -> dict[str, Any]:
699 + payload = dict(data or {})
700 + correlation_id = self._resolve_correlation_id(payload)
701 +
702 + with self.lock:
703 + connected = (namespace, sid) in self.connections
704 + if not connected:
705 + return {
706 + "correlationId": correlation_id,
707 + "results": [
708 + self._build_error_result(
709 + handler_id=handler_id or self._identifier,
710 + code="CONNECTION_NOT_FOUND",
711 + message=f"Connection '{sid}' not found in namespace '{namespace}'",
712 + correlation_id=correlation_id,
713 + )
714 + ],
715 + }
716 +
717 + async def _invoke() -> dict[str, Any]:
718 + return await self.route_event(
719 + namespace,
720 + event_type,
721 + payload,
722 + sid,
723 + include_handlers=include_handlers,
724 + handler_id=handler_id,
725 + )
726 +
727 + if timeout_ms and timeout_ms > 0:
728 + try:
729 + return await asyncio.wait_for(_invoke(), timeout=timeout_ms / 1000)
730 + except asyncio.TimeoutError:
731 + PrintStyle.warning(
732 + f"request timeout for sid {sid} event '{event_type}'"
733 + )
734 + return {
735 + "correlationId": correlation_id,
736 + "results": [
737 + self._build_error_result(
738 + handler_id=handler_id or self._identifier,
739 + code="TIMEOUT",
740 + message="Request timeout",
741 + correlation_id=correlation_id,
742 + )
743 + ],
744 + }
745 + return await _invoke()
746 +
747 + async def route_event_all(
748 + self,
749 + namespace: str,
750 + event_type: str,
751 + data: dict[str, Any],
752 + *,
753 + timeout_ms: int = 0,
754 + exclude_handlers: Set[str] | None = None,
755 + handler_id: str | None = None,
756 + ) -> list[dict[str, Any]]:
757 + """Fan-out a request to all active connections and aggregate responses."""
758 +
759 + base_payload = dict(data or {})
760 + exclude_meta_raw = base_payload.pop("excludeHandlers", None)
761 + exclude_combined: Set[str] | None = exclude_handlers
762 + correlation_id = self._resolve_correlation_id(base_payload)
763 +
764 + if exclude_meta_raw is not None:
765 + try:
766 + exclude_meta = self._normalize_handler_filter(
767 + exclude_meta_raw, "excludeHandlers"
768 + )
769 + except ValueError as exc:
770 + error = self._build_error_result(
771 + handler_id=handler_id or self._identifier,
772 + code="INVALID_FILTER",
773 + message=str(exc),
774 + correlation_id=correlation_id,
775 + )
776 + return [
777 + {
778 + "sid": "__invalid__",
779 + "correlationId": correlation_id,
780 + "results": [error],
781 + }
782 + ]
783 +
784 + if exclude_combined is None:
785 + exclude_combined = exclude_meta
786 + elif exclude_meta is not None and exclude_combined != exclude_meta:
787 + error = self._build_error_result(
788 + handler_id=handler_id or self._identifier,
789 + code="INVALID_FILTER",
790 + message="Conflicting excludeHandlers filters supplied",
791 + correlation_id=correlation_id,
792 + )
793 + return [
794 + {
795 + "sid": "__invalid__",
796 + "correlationId": correlation_id,
797 + "results": [error],
798 + }
799 + ]
800 +
801 + self._debug(
802 + f"Starting requestAll namespace={namespace} for '{event_type}' correlation={correlation_id}"
803 + )
804 +
805 + with self.lock:
806 + active_sids = [
807 + conn_identity[1]
808 + for conn_identity in self.connections.keys()
809 + if conn_identity[0] == namespace
810 + ]
811 + if not active_sids:
812 + self._debug(
813 + f"No active connections for requestAll namespace={namespace} '{event_type}' correlation={correlation_id}"
814 + )
815 + return []
816 +
817 + timeout_seconds = timeout_ms / 1000 if timeout_ms and timeout_ms > 0 else None
818 +
819 + async def _invoke_for_sid(target_sid: str) -> dict[str, Any]:
820 + async def _dispatch() -> dict[str, Any]:
821 + return await self.route_event(
822 + namespace,
823 + event_type,
824 + base_payload,
825 + target_sid,
826 + allow_exclude=True,
827 + exclude_handlers=exclude_combined,
828 + handler_id=handler_id,
829 + )
830 +
831 + if timeout_seconds is None:
832 + return await _dispatch()
833 +
834 + try:
835 + task = asyncio.create_task(_dispatch())
836 + return await asyncio.wait_for(asyncio.shield(task), timeout=timeout_seconds)
837 + except asyncio.TimeoutError:
838 + PrintStyle.warning(
839 + f"requestAll timeout for sid {target_sid} correlation={correlation_id}"
840 + )
841 + # Ensure any late exceptions are observed so asyncio does not log
842 + # "Task exception was never retrieved".
843 + try:
844 + task.add_done_callback(lambda t: t.exception()) # type: ignore[arg-type]
845 + except Exception: # pragma: no cover - defensive
846 + pass
847 + return {
848 + "correlationId": correlation_id,
849 + "results": [
850 + self._build_error_result(
851 + handler_id=handler_id or self._identifier,
852 + code="TIMEOUT",
853 + message="Request timeout",
854 + correlation_id=correlation_id,
855 + )
856 + ],
857 + }
858 +
859 + tasks = {
860 + sid: asyncio.create_task(_invoke_for_sid(sid)) for sid in active_sids
861 + }
862 +
863 + aggregated: list[dict[str, Any]] = []
864 + for sid, task in tasks.items():
865 + result = await task
866 + if isinstance(result, dict):
867 + aggregated.append(
868 + {
869 + "sid": sid,
870 + "correlationId": result.get("correlationId", correlation_id),
871 + "results": result.get("results", []),
872 + }
873 + )
874 + else:
875 + aggregated.append(
876 + {
877 + "sid": sid,
878 + "correlationId": correlation_id,
879 + "results": result,
880 + }
881 + )
882 +
883 + self._debug(
884 + f"Completed requestAll namespace={namespace} for '{event_type}' correlation={correlation_id}"
885 + )
886 + return aggregated
887 +
888 + def _wrap_envelope(
889 + self,
890 + handler_id: str | None,
891 + data: dict[str, Any],
892 + *,
893 + correlation_id: str | None = None,
894 + ) -> dict[str, Any]:
895 + hid = handler_id or self._identifier
896 + ts = _utcnow().isoformat(timespec="milliseconds").replace("+00:00", "Z")
897 + event_id = str(uuid.uuid4())
898 + correlation = correlation_id or str(uuid.uuid4())
899 + return {
900 + "handlerId": hid,
901 + "eventId": event_id,
902 + "correlationId": correlation,
903 + "ts": ts,
904 + "data": data or {},
905 + }
906 +
907 + async def emit_to(
908 + self,
909 + namespace: str,
910 + sid: str,
911 + event_type: str,
912 + data: dict[str, Any],
913 + *,
914 + handler_id: str | None = None,
915 + correlation_id: str | None = None,
916 + diagnostic: bool = False,
917 + ) -> None:
918 + envelope = self._wrap_envelope(
919 + handler_id,
920 + data,
921 + correlation_id=correlation_id,
922 + )
923 + delivered = False
924 + buffered = False
925 + identity: ConnectionIdentity = (namespace, sid)
926 +
927 + with self.lock:
928 + connected = identity in self.connections
929 + known = identity in self._known_sids or identity in self.buffers
930 +
931 + if connected:
932 + self._debug(
933 + "Emit to namespace=%s sid=%s event=%s eventId=%s correlationId=%s handlerId=%s"
934 + % (
935 + namespace,
936 + sid,
937 + event_type,
938 + envelope.get("eventId"),
939 + envelope.get("correlationId"),
940 + envelope.get("handlerId"),
941 + )
942 + )
943 + await self._run_on_dispatcher_loop(
944 + self.socketio.emit(event_type, envelope, to=sid, namespace=namespace)
945 + )
946 + delivered = True
947 + else:
948 + if not known:
949 + raise ConnectionNotFoundError(sid, namespace=namespace)
950 + with self.lock:
951 + self._buffer_event(
952 + identity,
953 + event_type,
954 + data,
955 + handler_id,
956 + envelope["correlationId"],
957 + )
958 + buffered = True
959 +
960 + if not diagnostic:
961 + await self._publish_diagnostic_event(
962 + lambda: {
963 + "kind": "outbound",
964 + "direction": "emit_to",
965 + "eventType": event_type,
966 + "namespace": namespace,
967 + "sid": sid,
968 + "correlationId": envelope["correlationId"],
969 + "handlerId": envelope["handlerId"],
970 + "timestamp": self._timestamp(),
971 + "delivered": delivered,
972 + "buffered": buffered,
973 + "payloadSummary": self._summarize_payload(data),
974 + }
975 + )
976 +
977 + async def broadcast(
978 + self,
979 + namespace: str,
980 + event_type: str,
981 + data: dict[str, Any],
982 + *,
983 + exclude_sids: str | Iterable[str] | None = None,
984 + handler_id: str | None = None,
985 + correlation_id: str | None = None,
986 + diagnostic: bool = False,
987 + ) -> None:
988 + excluded = self._normalize_sid_filter(exclude_sids)
989 +
990 + targets: list[str] = []
991 + with self.lock:
992 + current_identities = list(self.connections.keys())
993 + for conn_identity in current_identities:
994 + if conn_identity[0] != namespace:
995 + continue
996 + sid = conn_identity[1]
997 + if sid in excluded:
998 + continue
999 + targets.append(sid)
1000 + await self.emit_to(
1001 + namespace,
1002 + sid,
1003 + event_type,
1004 + data,
1005 + handler_id=handler_id,
1006 + correlation_id=correlation_id,
1007 + diagnostic=diagnostic,
1008 + )
1009 +
1010 + if not diagnostic:
1011 + await self._publish_diagnostic_event(
1012 + lambda: {
1013 + "kind": "outbound",
1014 + "direction": "broadcast",
1015 + "eventType": event_type,
1016 + "namespace": namespace,
1017 + "targets": targets[:10],
1018 + "targetCount": len(targets),
1019 + "correlationId": correlation_id,
1020 + "handlerId": handler_id or self._identifier,
1021 + "timestamp": self._timestamp(),
1022 + "payloadSummary": self._summarize_payload(data),
1023 + }
1024 + )
1025 +
1026 + async def _run_lifecycle(self, namespace: str, fn: Callable[[WebSocketHandler], Any]) -> None:
1027 + seen: Set[WebSocketHandler] = set()
1028 + coros: list[Any] = []
1029 + for handler_list in self.handlers.get(namespace, {}).values():
1030 + for handler in handler_list:
1031 + if handler in seen:
1032 + continue
1033 + seen.add(handler)
1034 + coros.append(self._get_handler_worker().execute_inside(fn, handler))
1035 + if coros:
1036 + await asyncio.gather(*coros, return_exceptions=True)
1037 +
1038 + def _buffer_event(
1039 + self,
1040 + identity: ConnectionIdentity,
1041 + event_type: str,
1042 + data: dict[str, Any],
1043 + handler_id: str | None,
1044 + correlation_id: str | None,
1045 + ) -> None:
1046 + namespace, sid = identity
1047 + buffer = self.buffers[identity]
1048 + buffer.append(
1049 + BufferedEvent(
1050 + event_type=event_type,
1051 + data=data,
1052 + handler_id=handler_id,
1053 + correlation_id=correlation_id,
1054 + )
1055 + )
1056 + while len(buffer) > BUFFER_MAX_SIZE:
1057 + dropped = buffer.popleft()
1058 + PrintStyle.warning(
1059 + f"Dropping buffered event '{dropped.event_type}' for namespace={namespace} sid={sid} (overflow)"
1060 + )
1061 + self._debug(
1062 + f"Buffered event namespace={namespace} '{event_type}' sid={sid} (queue length={len(buffer)})"
1063 + )
1064 +
1065 + async def _flush_buffer(self, identity: ConnectionIdentity) -> None:
1066 + self._ensure_dispatcher_loop()
1067 + buffer = self.buffers.get(identity)
1068 + if not buffer:
1069 + return
1070 + namespace, sid = identity
1071 + now = _utcnow()
1072 + delivered = 0
1073 + while buffer:
1074 + event = buffer.popleft()
1075 + if now - event.timestamp > BUFFER_TTL:
1076 + self._debug(
1077 + f"Discarding expired buffered event '{event.event_type}' for sid {sid}"
1078 + )
1079 + continue
1080 + envelope = self._wrap_envelope(
1081 + event.handler_id,
1082 + event.data,
1083 + correlation_id=event.correlation_id,
1084 + )
1085 + self._debug(
1086 + "Flush to sid=%s event=%s eventId=%s correlationId=%s handlerId=%s"
1087 + % (
1088 + sid,
1089 + event.event_type,
1090 + envelope.get("eventId"),
1091 + envelope.get("correlationId"),
1092 + envelope.get("handlerId"),
1093 + )
1094 + )
1095 + await self._run_on_dispatcher_loop(
1096 + self.socketio.emit(
1097 + event.event_type, envelope, to=sid, namespace=namespace
1098 + )
1099 + )
1100 + delivered += 1
1101 + if identity in self.buffers:
1102 + self.buffers.pop(identity, None)
1103 + if delivered:
1104 + PrintStyle.info(
1105 + f"Flushed {delivered} buffered event(s) to namespace={namespace} sid={sid}"
1106 + )
1107 +
1108 + def _build_error_result(
1109 + self,
1110 + *,
1111 + handler_id: str | None = None,
1112 + code: str,
1113 + message: str,
1114 + details: str | None = None,
1115 + correlation_id: str | None = None,
1116 + duration_ms: float | None = None,
1117 + ) -> dict[str, Any]:
1118 + error_payload = {"code": code, "error": message}
1119 + if details:
1120 + error_payload["details"] = details
1121 + result: dict[str, Any] = {
1122 + "handlerId": handler_id or self._identifier,
1123 + "ok": False,
1124 + "error": error_payload,
1125 + }
1126 + if correlation_id is not None:
1127 + result["correlationId"] = correlation_id
1128 + if duration_ms is not None:
1129 + result["durationMs"] = round(duration_ms, 4)
1130 + return result
1131 +
1132 + # Session tracking helpers (single-user defaults)
1133 + def get_sids_for_user(self, user: str | None = None) -> list[str]:
1134 + """Return SIDs for a user; single-user default returns all active SIDs."""
1135 + with self.lock:
1136 + bucket = self._ALL_USERS_BUCKET if user is None else user
1137 + return list(self.user_to_sids.get(bucket, set())) # type: ignore
1138 +
1139 + def get_user_for_sid(self, sid: str) -> str | None:
1140 + """Return user identifier for a SID or None."""
1141 + with self.lock:
1142 + return self.sid_to_user.get(sid) # type: ignore
1143 +
1144 + def set_server_restart_broadcast(self, enabled: bool) -> None:
1145 + """Enable or disable automatic server restart broadcasts."""
1146 +
1147 + self._server_restart_enabled = bool(enabled)
python/helpers/websocket_namespace_discovery.py new
+186
@@ -0,0 +1,186 @@
1 +from __future__ import annotations
2 +
3 +import importlib.util
4 +import inspect
5 +import os
6 +from dataclasses import dataclass
7 +from types import ModuleType
8 +from typing import Iterable
9 +
10 +from python.helpers.files import get_abs_path
11 +from python.helpers.print_style import PrintStyle
12 +from python.helpers.websocket import WebSocketHandler
13 +
14 +
15 +@dataclass(frozen=True)
16 +class NamespaceDiscovery:
17 + namespace: str
18 + handler_classes: tuple[type[WebSocketHandler], ...]
19 + source_files: tuple[str, ...]
20 +
21 +
22 +def _to_namespace(entry_name: str) -> str:
23 + if entry_name == "_default":
24 + return "/"
25 + stripped = entry_name[: -len("_handler")] if entry_name.endswith("_handler") else entry_name
26 + if not stripped:
27 + raise ValueError(f"Invalid handler entry name: {entry_name!r}")
28 + return f"/{stripped}"
29 +
30 +
31 +def _unique_module_name(file_path: str) -> str:
32 + # Use a stable, unique module name derived from the relative path to avoid
33 + # collisions when importing different files with the same basename.
34 + rel_path = os.path.relpath(file_path, get_abs_path("."))
35 + rel_no_ext = os.path.splitext(rel_path)[0]
36 + safe = "".join(ch if ch.isalnum() else "_" for ch in rel_no_ext)
37 + return f"a0_ws_ns_{safe}"
38 +
39 +
40 +def _import_module(file_path: str) -> ModuleType:
41 + abs_path = get_abs_path(file_path)
42 + module_name = _unique_module_name(abs_path)
43 + spec = importlib.util.spec_from_file_location(module_name, abs_path)
44 + if spec is None or spec.loader is None:
45 + raise ImportError(f"Could not load module from {abs_path}")
46 + module = importlib.util.module_from_spec(spec)
47 + spec.loader.exec_module(module)
48 + return module
49 +
50 +
51 +def _get_handler_classes(module: ModuleType) -> list[type[WebSocketHandler]]:
52 + discovered: list[type[WebSocketHandler]] = []
53 + for _name, cls in inspect.getmembers(module, inspect.isclass):
54 + if cls is WebSocketHandler:
55 + continue
56 + if not issubclass(cls, WebSocketHandler):
57 + continue
58 + if cls.__module__ != module.__name__:
59 + continue
60 + discovered.append(cls)
61 + return discovered
62 +
63 +
64 +def discover_websocket_namespaces(
65 + *,
66 + handlers_folder: str = "python/websocket_handlers",
67 + include_root_default: bool = True,
68 +) -> list[NamespaceDiscovery]:
69 + """
70 + Discover websocket namespaces from first-level filesystem entries.
71 +
72 + Supported entries:
73 + - File entry: `*_handler.py` defines an application namespace.
74 + - Folder entry: `<name>/` or `<name>_handler/` defines an application namespace and loads
75 + `*.py` files one level deep (ignores `__init__.py` and ignores deeper nesting).
76 + - Reserved root mapping: `_default.py` maps to `/` when `include_root_default=True`.
77 + """
78 +
79 + abs_folder = get_abs_path(handlers_folder)
80 + entries: list[NamespaceDiscovery] = []
81 +
82 + try:
83 + filenames = sorted(os.listdir(abs_folder))
84 + except FileNotFoundError:
85 + PrintStyle.warning(f"WebSocket handlers folder not found: {abs_folder}")
86 + return []
87 +
88 + for entry in filenames:
89 + entry_path = os.path.join(abs_folder, entry)
90 +
91 + # Folder entries define namespaces and can host multiple handler modules.
92 + if os.path.isdir(entry_path):
93 + if entry.startswith("__"):
94 + continue
95 + namespace = _to_namespace(entry)
96 +
97 + handler_classes: list[type[WebSocketHandler]] = []
98 + source_files: list[str] = []
99 +
100 + try:
101 + child_names = sorted(os.listdir(entry_path))
102 + except FileNotFoundError:
103 + continue
104 +
105 + for child in child_names:
106 + if not child.endswith(".py"):
107 + continue
108 + if child == "__init__.py":
109 + continue
110 + child_path = os.path.join(entry_path, child)
111 + if not os.path.isfile(child_path):
112 + # Ignore deeper nesting.
113 + continue
114 +
115 + module = _import_module(child_path)
116 + discovered = _get_handler_classes(module)
117 + if not discovered:
118 + raise RuntimeError(
119 + f"WebSocket handler module {child_path} defines no WebSocketHandler subclasses"
120 + )
121 + if len(discovered) > 1:
122 + raise RuntimeError(
123 + f"WebSocket handler module {child_path} defines multiple WebSocketHandler subclasses: "
124 + f"{', '.join(sorted(cls.__name__ for cls in discovered))}"
125 + )
126 + handler_classes.append(discovered[0])
127 + source_files.append(child_path)
128 +
129 + if not handler_classes:
130 + PrintStyle.warning(
131 + f"WebSocket handlers folder entry '{entry_path}' is empty; treating namespace '{namespace}' as unregistered"
132 + )
133 + continue
134 +
135 + entries.append(
136 + NamespaceDiscovery(
137 + namespace=namespace,
138 + handler_classes=tuple(handler_classes),
139 + source_files=tuple(source_files),
140 + )
141 + )
142 + continue
143 +
144 + # File entries define namespaces.
145 + if not entry.endswith(".py"):
146 + continue
147 + if entry == "__init__.py":
148 + continue
149 +
150 + if entry == "_default.py":
151 + if not include_root_default:
152 + continue
153 + entry_name = "_default"
154 + else:
155 + if not entry.endswith("_handler.py"):
156 + continue
157 + entry_name = entry[: -len("_handler.py")]
158 +
159 + namespace = _to_namespace(entry_name)
160 + module_path = os.path.join(abs_folder, entry)
161 +
162 + module = _import_module(module_path)
163 + handler_classes = _get_handler_classes(module)
164 + if not handler_classes:
165 + raise RuntimeError(
166 + f"WebSocket handler module {module_path} defines no WebSocketHandler subclasses"
167 + )
168 + if len(handler_classes) > 1:
169 + raise RuntimeError(
170 + f"WebSocket handler module {module_path} defines multiple WebSocketHandler subclasses: "
171 + f"{', '.join(sorted(cls.__name__ for cls in handler_classes))}"
172 + )
173 +
174 + entries.append(
175 + NamespaceDiscovery(
176 + namespace=namespace,
177 + handler_classes=(handler_classes[0],),
178 + source_files=(module_path,),
179 + )
180 + )
181 +
182 + return entries
183 +
184 +
185 +def iter_discovered_namespaces(discoveries: Iterable[NamespaceDiscovery]) -> list[str]:
186 + return [entry.namespace for entry in discoveries]
python/websocket_handlers/_default.py new
+31
@@ -0,0 +1,31 @@
1 +from __future__ import annotations
2 +
3 +from typing import Any
4 +
5 +from python.helpers.websocket import WebSocketHandler, WebSocketResult
6 +
7 +
8 +class RootDefaultHandler(WebSocketHandler):
9 + """Reserved root (`/`) namespace diagnostics-only handler.
10 +
11 + Root is intentionally *not* used for application traffic. This handler exists to support
12 + optional low-risk diagnostics on `/` without making root behave like a global namespace.
13 + """
14 +
15 + @classmethod
16 + def requires_auth(cls) -> bool:
17 + return False
18 +
19 + @classmethod
20 + def requires_csrf(cls) -> bool:
21 + return False
22 +
23 + @classmethod
24 + def get_event_types(cls) -> list[str]:
25 + # Diagnostics-only noop endpoint.
26 + return ["ws_root_echo"]
27 +
28 + async def process_event(
29 + self, event_type: str, data: dict[str, Any], sid: str
30 + ) -> dict[str, Any] | WebSocketResult | None:
31 + return {"ok": True, "namespace": self.namespace, "sid": sid, "echo": data}
python/websocket_handlers/dev_websocket_test_handler.py new
+130
@@ -0,0 +1,130 @@
1 +from __future__ import annotations
2 +
3 +import asyncio
4 +from typing import Any, Dict
5 +
6 +from python.helpers.print_style import PrintStyle
7 +from python.helpers import runtime
8 +from python.helpers.websocket import WebSocketHandler, WebSocketResult
9 +
10 +
11 +class DevWebsocketTestHandler(WebSocketHandler):
12 + """Test harness handler powering the developer WebSocket validation component."""
13 +
14 + @classmethod
15 + def get_event_types(cls) -> list[str]:
16 + return [
17 + "ws_tester_emit",
18 + "ws_tester_request",
19 + "ws_tester_request_delayed",
20 + "ws_tester_trigger_persistence",
21 + "ws_tester_request_all",
22 + "ws_tester_broadcast_demo_trigger",
23 + "ws_event_console_subscribe",
24 + "ws_event_console_unsubscribe",
25 + ]
26 +
27 + async def process_event(
28 + self, event_type: str, data: Dict[str, Any], sid: str
29 + ) -> dict[str, Any] | WebSocketResult | None:
30 + if event_type == "ws_event_console_subscribe":
31 + if not runtime.is_development():
32 + return self.result_error(
33 + code="NOT_AVAILABLE",
34 + message="Event console is available only in development mode",
35 + )
36 + registered = self.manager.register_diagnostic_watcher(self.namespace, sid)
37 + if not registered:
38 + return self.result_error(
39 + code="SUBSCRIBE_FAILED",
40 + message="Unable to subscribe to diagnostics",
41 + )
42 + return self.result_ok(
43 + {"status": "subscribed", "timestamp": data.get("requestedAt")}
44 + )
45 +
46 + if event_type == "ws_event_console_unsubscribe":
47 + self.manager.unregister_diagnostic_watcher(self.namespace, sid)
48 + return self.result_ok({"status": "unsubscribed"})
49 +
50 + if event_type == "ws_tester_emit":
51 + message = data.get("message", "emit")
52 + payload = {
53 + "message": message,
54 + "echo": True,
55 + "timestamp": data.get("timestamp"),
56 + }
57 + await self.broadcast("ws_tester_broadcast", payload)
58 + PrintStyle.info(f"Harness emit broadcasted message='{message}'")
59 + return None
60 +
61 + if event_type == "ws_tester_request":
62 + value = data.get("value")
63 + response = {
64 + "echo": value,
65 + "handler": self.identifier,
66 + "status": "ok",
67 + }
68 + PrintStyle.debug("Harness request responded with echo %s", value)
69 + return self.result_ok(
70 + response,
71 + correlation_id=data.get("correlationId"),
72 + )
73 +
74 + if event_type == "ws_tester_request_delayed":
75 + delay_ms = int(data.get("delay_ms", 0))
76 + await asyncio.sleep(delay_ms / 1000)
77 + PrintStyle.warning(
78 + "Harness delayed request finished after %s ms", delay_ms
79 + )
80 + return self.result_ok(
81 + {
82 + "status": "delayed",
83 + "delay_ms": delay_ms,
84 + "handler": self.identifier,
85 + },
86 + correlation_id=data.get("correlationId"),
87 + )
88 +
89 + if event_type == "ws_tester_trigger_persistence":
90 + phase = data.get("phase", "unknown")
91 + payload = {
92 + "phase": phase,
93 + "handler": self.identifier,
94 + }
95 + await self.emit_to(sid, "ws_tester_persistence", payload)
96 + PrintStyle.info(f"Harness persistence event phase='{phase}' -> {sid}")
97 + return None
98 +
99 + if event_type == "ws_tester_request_all":
100 + marker = data.get("marker")
101 + PrintStyle.debug(
102 + "Harness requestAll invoked by %s marker='%s'", sid, marker
103 + )
104 + exclude_handlers = data.get("excludeHandlers")
105 + aggregated = await self.request_all(
106 + "ws_tester_request",
107 + data,
108 + timeout_ms=2_000,
109 + exclude_handlers=exclude_handlers,
110 + )
111 + return self.result_ok(
112 + {"results": aggregated},
113 + correlation_id=data.get("correlationId"),
114 + )
115 +
116 + if event_type == "ws_tester_broadcast_demo_trigger":
117 + payload = {
118 + "demo": True,
119 + "requested_at": data.get("requested_at"),
120 + }
121 + await self.broadcast("ws_tester_broadcast_demo", payload)
122 + PrintStyle.info("Harness broadcast demo event dispatched")
123 + return None
124 +
125 + PrintStyle.warning(f"Harness received unknown event '{event_type}'")
126 + return self.result_error(
127 + code="HARNESS_UNKNOWN_EVENT",
128 + message="Unhandled event",
129 + details=event_type,
130 + )
python/websocket_handlers/hello_handler.py new
+19
@@ -0,0 +1,19 @@
1 +from __future__ import annotations
2 +
3 +from python.helpers.print_style import PrintStyle
4 +from python.helpers.websocket import WebSocketHandler
5 +
6 +
7 +class HelloHandler(WebSocketHandler):
8 + """Sample handler used for foundational testing."""
9 +
10 + @classmethod
11 + def get_event_types(cls) -> list[str]:
12 + return ["hello_request"]
13 +
14 + async def process_event(self, event_type: str, data: dict, sid: str):
15 + name = data.get("name") or "stranger"
16 + PrintStyle.info(f"hello_request from {sid} ({name})")
17 + return {"message": f"Hello, {name}!", "handler": self.identifier}
18 +
19 +
python/websocket_handlers/state_sync_handler.py new
+72
@@ -0,0 +1,72 @@
1 +from __future__ import annotations
2 +
3 +from python.helpers import runtime
4 +from python.helpers.print_style import PrintStyle
5 +from python.helpers.websocket import WebSocketHandler, WebSocketResult
6 +from python.helpers.state_monitor import get_state_monitor
7 +from python.helpers.state_snapshot import (
8 + StateRequestValidationError,
9 + parse_state_request_payload,
10 +)
11 +
12 +
13 +class StateSyncHandler(WebSocketHandler):
14 + @classmethod
15 + def get_event_types(cls) -> list[str]:
16 + return ["state_request"]
17 +
18 + async def on_connect(self, sid: str) -> None:
19 + monitor = get_state_monitor()
20 + monitor.bind_manager(self.manager, handler_id=self.identifier)
21 + monitor.register_sid(self.namespace, sid)
22 + PrintStyle.info(f"[StateSyncHandler] connect sid={sid}")
23 +
24 + async def on_disconnect(self, sid: str) -> None:
25 + get_state_monitor().unregister_sid(self.namespace, sid)
26 + PrintStyle.info(f"[StateSyncHandler] disconnect sid={sid}")
27 +
28 + async def process_event(self, event_type: str, data: dict, sid: str) -> dict | WebSocketResult | None:
29 + correlation_id = data.get("correlationId")
30 + try:
31 + request = parse_state_request_payload(data)
32 + except StateRequestValidationError as exc:
33 + PrintStyle.warning(
34 + f"[StateSyncHandler] INVALID_REQUEST sid={sid} reason={exc.reason} details={exc.details!r}"
35 + )
36 + return self.result_error(
37 + code="INVALID_REQUEST",
38 + message=str(exc),
39 + correlation_id=correlation_id,
40 + )
41 +
42 + PrintStyle.debug(
43 + f"[StateSyncHandler] state_request sid={sid} context={request.context!r} "
44 + f"log_from={request.log_from} notifications_from={request.notifications_from} timezone={request.timezone!r} "
45 + f"correlation_id={correlation_id}"
46 + )
47 +
48 + # Baseline sequence must be reset on every state_request (new sync period).
49 + # V1 policy: seq_base starts >0 to allow simple gating checks.
50 + seq_base = 1
51 + monitor = get_state_monitor()
52 + monitor.update_projection(
53 + self.namespace,
54 + sid,
55 + request=request,
56 + seq_base=seq_base,
57 + )
58 + # INVARIANT.STATE.INITIAL_SNAPSHOT: schedule a full snapshot quickly after handshake.
59 + monitor.mark_dirty(
60 + self.namespace,
61 + sid,
62 + reason="state_sync_handler.StateSyncHandler.state_request",
63 + )
64 + PrintStyle.debug(f"[StateSyncHandler] state_request accepted sid={sid} seq_base={seq_base}")
65 +
66 + return self.result_ok(
67 + {
68 + "runtime_epoch": runtime.get_runtime_id(),
69 + "seq_base": seq_base,
70 + },
71 + correlation_id=correlation_id,
72 + )
requirements.txt
+6 -3
@@ -4,7 +4,7 @@ browser-use==0.5.11
4 docker==7.1.0
5 duckduckgo-search==6.1.12
6 faiss-cpu==1.11.0
7 -fastmcp==2.3.4
7 +fastmcp==2.13.1
8 fasta2a==0.5.0
9 flask[async]==3.0.3
10 flask-basicauth==0.2.0
@@ -19,7 +19,7 @@ langchain-unstructured[all-docs]==0.1.6
19 openai-whisper==20240930
20 lxml_html_clean==0.3.1
21 markdown==3.7
22 -mcp==1.13.1
22 +mcp==1.22.0
23 newspaper3k==0.2.8
24 paramiko==3.5.0
25 playwright==1.52.0
@@ -47,4 +47,7 @@ html2text>=2024.2.26
47 beautifulsoup4>=4.12.3
48 boto3>=1.35.0
49 exchangelib>=5.4.3
50 -pywinpty==3.0.2; sys_platform == "win32"
\ No newline at end of file
50 +pywinpty==3.0.2; sys_platform == "win32"
51 +python-socketio>=5.14.2
52 +uvicorn>=0.38.0
53 +wsproto>=1.2.0
run_ui.py
+282 -41
@@ -1,23 +1,32 @@
1 -import asyncio
1 from datetime import timedelta
2 import os
3 import secrets
5 -import hashlib
4 import time
5 import socket
6 import struct
7 from functools import wraps
8 import threading
9 +
10 +import uvicorn
11 from flask import Flask, request, Response, session, redirect, url_for, render_template_string
12 from werkzeug.wrappers.response import Response as BaseResponse
13 +
14 import initialize
14 -from python.helpers import files, git, mcp_server, fasta2a_server
15 +from python.helpers import files, git, mcp_server, fasta2a_server, settings as settings_helper
16 from python.helpers.files import get_abs_path
17 from python.helpers import runtime, dotenv, process
18 +from python.helpers.websocket import WebSocketHandler, validate_ws_origin
19 from python.helpers.extract_tools import load_classes_from_folder
20 from python.helpers.api import ApiHandler
21 from python.helpers.print_style import PrintStyle
22 from python.helpers import login
23 +import socketio # type: ignore[import-untyped]
24 +from socketio import ASGIApp, packet
25 +from starlette.applications import Starlette
26 +from starlette.routing import Mount
27 +from uvicorn.middleware.wsgi import WSGIMiddleware
28 +from python.helpers.websocket_manager import WebSocketManager
29 +from python.helpers.websocket_namespace_discovery import discover_websocket_namespaces
30
31 # disable logging
32 import logging
@@ -42,7 +51,25 @@ webapp.config.update(
51 PERMANENT_SESSION_LIFETIME=timedelta(days=1)
52 )
53
45 -lock = threading.Lock()
54 +lock = threading.RLock()
55 +
56 +socketio_server = socketio.AsyncServer(
57 + async_mode="asgi",
58 + namespaces="*",
59 + cors_allowed_origins=lambda _origin, environ: validate_ws_origin(environ)[0],
60 + logger=False,
61 + engineio_logger=False,
62 + ping_interval=25, # explicit default to avoid future lib changes
63 + ping_timeout=20, # explicit default to avoid future lib changes
64 + max_http_buffer_size=50 * 1024 * 1024,
65 +)
66 +
67 +websocket_manager = WebSocketManager(socketio_server, lock)
68 +_settings = settings_helper.get_settings()
69 +settings_helper.set_runtime_settings_snapshot(_settings)
70 +websocket_manager.set_server_restart_broadcast(
71 + _settings.get("websocket_server_restart_enabled", True)
72 +)
73
74 # Set up basic authentication for UI and API but not MCP
75 # basic_auth = BasicAuth(webapp)
@@ -50,9 +77,9 @@ lock = threading.Lock()
77
78 def is_loopback_address(address):
79 loopback_checker = {
53 - socket.AF_INET: lambda x: struct.unpack("!I", socket.inet_aton(x))[0]
54 - >> (32 - 8)
55 - == 127,
80 + socket.AF_INET: lambda x: (
81 + struct.unpack("!I", socket.inet_aton(x))[0] >> (32 - 8)
82 + ) == 127,
83 socket.AF_INET6: lambda x: x == "::1",
84 }
85 address_type = "hostname"
@@ -81,6 +108,7 @@ def is_loopback_address(address):
108 return False
109 return True
110
111 +
112 def requires_api_key(f):
113 @wraps(f)
114 async def decorated(*args, **kwargs):
@@ -128,11 +156,12 @@ def requires_auth(f):
156
157 if session.get('authentication') != user_pass_hash:
158 return redirect(url_for('login_handler'))
131 -
159 +
160 return await f(*args, **kwargs)
161
162 return decorated
163
164 +
165 def csrf_protect(f):
166 @wraps(f)
167 async def decorated(*args, **kwargs):
@@ -146,27 +175,30 @@ def csrf_protect(f):
175
176 return decorated
177
178 +
179 @webapp.route("/login", methods=["GET", "POST"])
180 async def login_handler():
181 error = None
182 if request.method == 'POST':
183 user = dotenv.get_dotenv_value("AUTH_LOGIN")
184 password = dotenv.get_dotenv_value("AUTH_PASSWORD")
155 -
185 +
186 if request.form['username'] == user and request.form['password'] == password:
187 session['authentication'] = login.get_credentials_hash()
188 return redirect(url_for('serve_index'))
189 else:
190 error = 'Invalid Credentials. Please try again.'
161 -
191 +
192 login_page_content = files.read_file("webui/login.html")
193 return render_template_string(login_page_content, error=error)
194
195 +
196 @webapp.route("/logout")
197 async def logout_handler():
198 session.pop('authentication', None)
199 return redirect(url_for('login_handler'))
200
201 +
202 # handle default address, load index
203 @webapp.route("/", methods=["GET"])
204 @requires_auth
@@ -183,34 +215,208 @@ async def serve_index():
215 index = files.replace_placeholders_text(
216 _content=index,
217 version_no=gitinfo["version"],
186 - version_time=gitinfo["commit_time"]
218 + version_time=gitinfo["commit_time"],
219 + runtime_id=runtime.get_runtime_id(),
220 + runtime_is_development=("true" if runtime.is_development() else "false"),
221 )
222 return index
223
224 +
225 +def _build_websocket_handlers_by_namespace(
226 + socketio_server: socketio.AsyncServer,
227 + lock: threading.RLock,
228 +) -> dict[str, list[WebSocketHandler]]:
229 + discoveries = discover_websocket_namespaces(
230 + handlers_folder="python/websocket_handlers",
231 + include_root_default=True,
232 + )
233 +
234 + handlers_by_namespace: dict[str, list[WebSocketHandler]] = {}
235 + for discovery in discoveries:
236 + namespace = discovery.namespace
237 + for handler_cls in discovery.handler_classes:
238 + handler = handler_cls.get_instance(socketio_server, lock)
239 + handlers_by_namespace.setdefault(namespace, []).append(handler)
240 +
241 + return handlers_by_namespace
242 +
243 +
244 +def configure_websocket_namespaces(
245 + *,
246 + webapp: Flask,
247 + socketio_server: socketio.AsyncServer,
248 + websocket_manager: WebSocketManager,
249 + handlers_by_namespace: dict[str, list[WebSocketHandler]],
250 +) -> set[str]:
251 + namespace_map: dict[str, list[WebSocketHandler]] = {
252 + namespace: list(handlers) for namespace, handlers in handlers_by_namespace.items()
253 + }
254 +
255 + # Always include the reserved root namespace. It is unhandled for application events by
256 + # default, but request-style calls must resolve deterministically with NO_HANDLERS.
257 + namespace_map.setdefault("/", [])
258 +
259 + websocket_manager.register_handlers(namespace_map)
260 +
261 + allowed_namespaces = set(namespace_map.keys())
262 + original_handle_connect = socketio_server._handle_connect # type: ignore[attr-defined]
263 +
264 + async def _handle_connect_with_namespace_gatekeeper(eio_sid, namespace, data):
265 + requested = namespace or "/"
266 + if requested not in allowed_namespaces:
267 + await socketio_server._send_packet(
268 + eio_sid,
269 + socketio_server.packet_class(
270 + packet.CONNECT_ERROR,
271 + data={
272 + "message": "UNKNOWN_NAMESPACE",
273 + "data": {"code": "UNKNOWN_NAMESPACE", "namespace": requested},
274 + },
275 + namespace=requested,
276 + ),
277 + )
278 + return
279 + await original_handle_connect(eio_sid, namespace, data)
280 +
281 + socketio_server._handle_connect = _handle_connect_with_namespace_gatekeeper # type: ignore[assignment]
282 +
283 + def _register_namespace_handlers(
284 + namespace: str, namespace_handlers: list[WebSocketHandler]
285 + ) -> None:
286 + # A namespace is the WebSocket equivalent of an API endpoint.
287 + # Security requirements must be consistent within the namespace (no any()-based union).
288 + auth_required = False
289 + csrf_required = False
290 + if namespace_handlers:
291 + auth_required = bool(namespace_handlers[0].requires_auth())
292 + csrf_required = bool(namespace_handlers[0].requires_csrf())
293 + for handler in namespace_handlers[1:]:
294 + if (
295 + bool(handler.requires_auth()) != auth_required
296 + or bool(handler.requires_csrf()) != csrf_required
297 + ):
298 + raise ValueError(
299 + f"WebSocket namespace {namespace!r} has mixed auth/csrf requirements across handlers"
300 + )
301 +
302 + @socketio_server.on("connect", namespace=namespace)
303 + async def _connect( # type: ignore[override]
304 + sid,
305 + environ,
306 + _auth,
307 + _namespace: str = namespace,
308 + _auth_required: bool = auth_required,
309 + _csrf_required: bool = csrf_required,
310 + ):
311 + with webapp.request_context(environ):
312 + origin_ok, origin_reason = validate_ws_origin(environ)
313 + if not origin_ok:
314 + PrintStyle.warning(
315 + f"WebSocket origin validation failed for {_namespace} {sid}: {origin_reason or 'invalid'}"
316 + )
317 + return False
318 +
319 + if _auth_required:
320 + credentials_hash = login.get_credentials_hash()
321 + if credentials_hash:
322 + if session.get("authentication") != credentials_hash:
323 + PrintStyle.warning(
324 + f"WebSocket authentication failed for {_namespace} {sid}: session not valid"
325 + )
326 + return False
327 + else:
328 + PrintStyle.debug(
329 + "WebSocket authentication required but credentials not configured; proceeding"
330 + )
331 +
332 + if _csrf_required:
333 + expected_token = session.get("csrf_token")
334 + if not isinstance(expected_token, str) or not expected_token:
335 + PrintStyle.warning(
336 + f"WebSocket CSRF validation failed for {_namespace} {sid}: csrf_token not initialized"
337 + )
338 + return False
339 +
340 + auth_token = None
341 + if isinstance(_auth, dict):
342 + auth_token = _auth.get("csrf_token") or _auth.get("csrfToken")
343 + if not isinstance(auth_token, str) or not auth_token:
344 + PrintStyle.warning(
345 + f"WebSocket CSRF validation failed for {_namespace} {sid}: missing csrf_token in auth"
346 + )
347 + return False
348 + if auth_token != expected_token:
349 + PrintStyle.warning(
350 + f"WebSocket CSRF validation failed for {_namespace} {sid}: csrf_token mismatch"
351 + )
352 + return False
353 +
354 + cookie_name = f"csrf_token_{runtime.get_runtime_id()}"
355 + cookie_token = request.cookies.get(cookie_name)
356 + if cookie_token != expected_token:
357 + PrintStyle.warning(
358 + f"WebSocket CSRF validation failed for {_namespace} {sid}: csrf cookie mismatch"
359 + )
360 + return False
361 +
362 + user_id = session.get("user_id") or "single_user"
363 + await websocket_manager.handle_connect(_namespace, sid, user_id=user_id)
364 + return True
365 +
366 + @socketio_server.on("disconnect", namespace=namespace)
367 + async def _disconnect(sid, _namespace: str = namespace): # type: ignore[override]
368 + await websocket_manager.handle_disconnect(_namespace, sid)
369 +
370 + def _register_socketio_event(event_type: str) -> None:
371 + @socketio_server.on(event_type, namespace=namespace)
372 + async def _event_handler(
373 + sid,
374 + data,
375 + _event_type: str = event_type,
376 + _namespace: str = namespace,
377 + ):
378 + payload = data or {}
379 + return await websocket_manager.route_event(
380 + _namespace, _event_type, payload, sid
381 + )
382 +
383 + for _event_type in websocket_manager.iter_event_types(namespace):
384 + _register_socketio_event(_event_type)
385 +
386 + @socketio_server.on("*", namespace=namespace)
387 + async def _catch_all(event, sid, data, _namespace: str = namespace):
388 + payload = data or {}
389 + return await websocket_manager.route_event(_namespace, event, payload, sid)
390 +
391 + for namespace, namespace_handlers in namespace_map.items():
392 + _register_namespace_handlers(namespace, namespace_handlers)
393 +
394 + return allowed_namespaces
395 +
396 +
397 def run():
398 PrintStyle().print("Initializing framework...")
399
400 # migrate data before anything else
401 initialize.initialize_migration()
402
196 - # Suppress only request logs but keep the startup messages
197 - from werkzeug.serving import WSGIRequestHandler
198 - from werkzeug.serving import make_server
199 - from werkzeug.middleware.dispatcher import DispatcherMiddleware
200 - from a2wsgi import ASGIMiddleware
403 + # # Suppress only request logs but keep the startup messages
404 + # from werkzeug.serving import WSGIRequestHandler
405 + # from werkzeug.serving import make_server
406 + # from werkzeug.middleware.dispatcher import DispatcherMiddleware
407 + # from a2wsgi import ASGIMiddleware
408
409 PrintStyle().print("Starting server...")
410
204 - class NoRequestLoggingWSGIRequestHandler(WSGIRequestHandler):
205 - def log_request(self, code="-", size="-"):
206 - pass # Override to suppress request logging
411 + # class NoRequestLoggingWSGIRequestHandler(WSGIRequestHandler):
412 + # def log_request(self, code="-", size="-"):
413 + # pass # Override to suppress request logging
414
415 # Get configuration from environment
416 port = runtime.get_web_ui_port()
417 host = (
418 runtime.get_arg("host") or dotenv.get_dotenv_value("WEB_UI_HOST") or "localhost"
419 )
213 - server = None
420
421 def register_api_handler(app, handler: type[ApiHandler]):
422 name = handler.__module__.split(".")[-1]
@@ -235,37 +441,73 @@ def run():
441 methods=handler.get_methods(),
442 )
443
238 - # initialize and register API handlers
444 handlers = load_classes_from_folder("python/api", "*.py", ApiHandler)
445 for handler in handlers:
446 register_api_handler(webapp, handler)
447
243 - # add the webapp, mcp, and a2a to the app
244 - middleware_routes = {
245 - "/mcp": ASGIMiddleware(app=mcp_server.DynamicMcpProxy.get_instance()), # type: ignore
246 - "/a2a": ASGIMiddleware(app=fasta2a_server.DynamicA2AProxy.get_instance()), # type: ignore
247 - }
448 + handlers_by_namespace = _build_websocket_handlers_by_namespace(socketio_server, lock)
449 + configure_websocket_namespaces(
450 + webapp=webapp,
451 + socketio_server=socketio_server,
452 + websocket_manager=websocket_manager,
453 + handlers_by_namespace=handlers_by_namespace,
454 + )
455
249 - app = DispatcherMiddleware(webapp, middleware_routes) # type: ignore
456 + init_a0()
457
251 - PrintStyle().debug(f"Starting server at http://{host}:{port} ...")
458 + wsgi_app = WSGIMiddleware(webapp)
459 + starlette_app = Starlette(
460 + routes=[
461 + Mount("/mcp", app=mcp_server.DynamicMcpProxy.get_instance()),
462 + Mount("/a2a", app=fasta2a_server.DynamicA2AProxy.get_instance()),
463 + Mount("/", app=wsgi_app),
464 + ]
465 + )
466 +
467 + asgi_app = ASGIApp(socketio_server, other_asgi_app=starlette_app)
468
253 - server = make_server(
469 + def flush_and_shutdown_callback() -> None:
470 + """
471 + TODO(dev): add cleanup + flush-to-disk logic here.
472 + """
473 + return
474 + flush_ran = False
475 +
476 + def _run_flush(reason: str) -> None:
477 + nonlocal flush_ran
478 + if flush_ran:
479 + return
480 + flush_ran = True
481 + try:
482 + flush_and_shutdown_callback()
483 + except Exception as e:
484 + PrintStyle.warning(f"Shutdown flush failed ({reason}): {e}")
485 +
486 + config = uvicorn.Config(
487 + asgi_app,
488 host=host,
489 port=port,
256 - app=app,
257 - request_handler=NoRequestLoggingWSGIRequestHandler,
258 - threaded=True,
490 + log_level="error",
491 + access_log=_settings.get("uvicorn_access_logs_enabled", False),
492 + ws="wsproto",
493 )
260 - process.set_server(server)
261 - server.log_startup()
494 + server = uvicorn.Server(config)
495
263 - # Start init_a0 in a background thread when server starts
264 - # threading.Thread(target=init_a0, daemon=True).start()
265 - init_a0()
496 + class _UvicornServerWrapper:
497 + def __init__(self, server: uvicorn.Server):
498 + self._server = server
499
267 - # run the server
268 - server.serve_forever()
500 + def shutdown(self) -> None:
501 + _run_flush("shutdown")
502 + self._server.should_exit = True
503 +
504 + process.set_server(_UvicornServerWrapper(server))
505 +
506 + PrintStyle().debug(f"Starting server at http://{host}:{port} ...")
507 + try:
508 + server.run()
509 + finally:
510 + _run_flush("server_exit")
511
512
513 def init_a0():
@@ -281,9 +523,8 @@ def init_a0():
523 initialize.initialize_preload()
524
525
284 -
526 # run the internal server
527 if __name__ == "__main__":
528 runtime.initialize()
529 dotenv.load_dotenv()
289 - run()
\ No newline at end of file
530 + run()
tests/test_http_auth_csrf.py new
+124
@@ -0,0 +1,124 @@
1 +from __future__ import annotations
2 +
3 +from flask import Flask, Response
4 +
5 +import pytest
6 +
7 +from python.helpers import runtime
8 +
9 +
10 +def _make_app() -> Flask:
11 + app = Flask("test_http_auth_csrf")
12 + app.secret_key = "test-secret"
13 +
14 + @app.get("/login")
15 + def login_handler():
16 + return Response("login", status=200)
17 +
18 + return app
19 +
20 +
21 +def _set_session(client, **values) -> None:
22 + with client.session_transaction() as sess:
23 + for key, value in values.items():
24 + sess[key] = value
25 +
26 +
27 +def _set_csrf_cookie(client, token: str) -> None:
28 + cookie_name = f"csrf_token_{runtime.get_runtime_id()}"
29 + client.set_cookie(cookie_name, token)
30 +
31 +
32 +def test_http_auth_enforced_when_configured(monkeypatch) -> None:
33 + from run_ui import csrf_protect, requires_auth
34 +
35 + monkeypatch.setattr("python.helpers.login.get_credentials_hash", lambda: "hash")
36 +
37 + app = _make_app()
38 +
39 + @app.get("/secure")
40 + @requires_auth
41 + @csrf_protect
42 + async def secure():
43 + return Response("ok", status=200)
44 +
45 + client = app.test_client()
46 + response = client.get("/secure")
47 + assert response.status_code == 302
48 +
49 +
50 +def test_http_csrf_required_even_when_auth_not_configured(monkeypatch) -> None:
51 + from run_ui import csrf_protect, requires_auth
52 +
53 + monkeypatch.setattr("python.helpers.login.get_credentials_hash", lambda: None)
54 +
55 + app = _make_app()
56 +
57 + @app.get("/secure")
58 + @requires_auth
59 + @csrf_protect
60 + async def secure():
61 + return Response("ok", status=200)
62 +
63 + client = app.test_client()
64 + _set_session(client, csrf_token="csrf-1")
65 + response = client.get("/secure")
66 + assert response.status_code == 403
67 +
68 +
69 +def test_http_csrf_rejects_missing_token(monkeypatch) -> None:
70 + from run_ui import csrf_protect, requires_auth
71 +
72 + monkeypatch.setattr("python.helpers.login.get_credentials_hash", lambda: "hash")
73 +
74 + app = _make_app()
75 +
76 + @app.get("/secure")
77 + @requires_auth
78 + @csrf_protect
79 + async def secure():
80 + return Response("ok", status=200)
81 +
82 + client = app.test_client()
83 + _set_session(client, authentication="hash", csrf_token="csrf-2")
84 + response = client.get("/secure")
85 + assert response.status_code == 403
86 +
87 +
88 +def test_http_csrf_accepts_valid_header_without_cookie(monkeypatch) -> None:
89 + from run_ui import csrf_protect, requires_auth
90 +
91 + monkeypatch.setattr("python.helpers.login.get_credentials_hash", lambda: "hash")
92 +
93 + app = _make_app()
94 +
95 + @app.get("/secure")
96 + @requires_auth
97 + @csrf_protect
98 + async def secure():
99 + return Response("ok", status=200)
100 +
101 + client = app.test_client()
102 + _set_session(client, authentication="hash", csrf_token="csrf-3")
103 + response = client.get("/secure", headers={"X-CSRF-Token": "csrf-3"})
104 + assert response.status_code == 200
105 +
106 +
107 +def test_http_csrf_accepts_valid_cookie(monkeypatch) -> None:
108 + from run_ui import csrf_protect, requires_auth
109 +
110 + monkeypatch.setattr("python.helpers.login.get_credentials_hash", lambda: "hash")
111 +
112 + app = _make_app()
113 +
114 + @app.get("/secure")
115 + @requires_auth
116 + @csrf_protect
117 + async def secure():
118 + return Response("ok", status=200)
119 +
120 + client = app.test_client()
121 + _set_session(client, authentication="hash", csrf_token="csrf-4")
122 + _set_csrf_cookie(client, "csrf-4")
123 + response = client.get("/secure")
124 + assert response.status_code == 200
tests/test_multi_tab_isolation.py new
+158
@@ -0,0 +1,158 @@
1 +import asyncio
2 +import sys
3 +from pathlib import Path
4 +
5 +import pytest
6 +
7 +PROJECT_ROOT = Path(__file__).resolve().parents[1]
8 +if str(PROJECT_ROOT) not in sys.path:
9 + sys.path.insert(0, str(PROJECT_ROOT))
10 +
11 +
12 +@pytest.mark.asyncio
13 +async def test_state_monitor_per_sid_isolation_independent_snapshots_seq_and_cursors(monkeypatch):
14 + import python.helpers.state_monitor as state_monitor_module
15 + from python.helpers.state_monitor import StateMonitor
16 + from python.helpers.state_snapshot import StateRequestV1
17 +
18 + snapshot_calls: list[dict[str, object]] = []
19 + emitted: list[dict[str, object]] = []
20 +
21 + namespace = "/state_sync"
22 +
23 + async def fake_build_snapshot_from_request(*, request):
24 + context = request.context
25 + log_from = request.log_from
26 + notifications_from = request.notifications_from
27 + timezone = request.timezone
28 + snapshot_calls.append(
29 + {
30 + "context": context,
31 + "log_from": log_from,
32 + "notifications_from": notifications_from,
33 + "timezone": timezone,
34 + }
35 + )
36 + # Return poll-shaped keys that StateMonitor expects to advance cursors from.
37 + return {
38 + "deselect_chat": False,
39 + "context": context or "",
40 + "contexts": [],
41 + "tasks": [],
42 + "logs": [],
43 + "log_guid": "log-guid",
44 + "log_version": int(log_from) + 1,
45 + "log_progress": "",
46 + "log_progress_active": False,
47 + "paused": False,
48 + "notifications": [],
49 + "notifications_guid": "notifications-guid",
50 + "notifications_version": int(notifications_from) + 1,
51 + }
52 +
53 + class FakeManager:
54 + def __init__(self, loop):
55 + self._dispatcher_loop = loop
56 +
57 + async def emit_to(self, namespace, sid, event_type, payload, *, handler_id=None):
58 + emitted.append(
59 + {
60 + "namespace": namespace,
61 + "sid": sid,
62 + "event_type": event_type,
63 + "payload": payload,
64 + "handler_id": handler_id,
65 + }
66 + )
67 +
68 + monkeypatch.setattr(
69 + state_monitor_module,
70 + "build_snapshot_from_request",
71 + fake_build_snapshot_from_request,
72 + )
73 +
74 + monitor = StateMonitor(debounce_seconds=60.0)
75 + loop = asyncio.get_running_loop()
76 + monitor.bind_manager(FakeManager(loop), handler_id="test.handler")
77 +
78 + monitor.register_sid(namespace, "sid-a")
79 + monitor.register_sid(namespace, "sid-b")
80 +
81 + monitor.update_projection(
82 + namespace,
83 + "sid-a",
84 + request=StateRequestV1(context="ctx-a", log_from=0, notifications_from=0, timezone="UTC"),
85 + seq_base=10,
86 + )
87 + monitor.update_projection(
88 + namespace,
89 + "sid-b",
90 + request=StateRequestV1(
91 + context="ctx-b",
92 + log_from=40,
93 + notifications_from=7,
94 + timezone="Europe/Berlin",
95 + ),
96 + seq_base=100,
97 + )
98 +
99 + # Flush pushes directly to avoid relying on debounce scheduling.
100 + await monitor._flush_push((namespace, "sid-a"))
101 + await monitor._flush_push((namespace, "sid-b"))
102 +
103 + assert snapshot_calls == [
104 + {"context": "ctx-a", "log_from": 0, "notifications_from": 0, "timezone": "UTC"},
105 + {"context": "ctx-b", "log_from": 40, "notifications_from": 7, "timezone": "Europe/Berlin"},
106 + ]
107 +
108 + assert len(emitted) == 2
109 + assert {entry["sid"] for entry in emitted} == {"sid-a", "sid-b"}
110 + assert all(entry["event_type"] == "state_push" for entry in emitted)
111 + assert all(entry["handler_id"] == "test.handler" for entry in emitted)
112 + assert all(entry["namespace"] == namespace for entry in emitted)
113 +
114 + payload_a = next(entry["payload"] for entry in emitted if entry["sid"] == "sid-a")
115 + payload_b = next(entry["payload"] for entry in emitted if entry["sid"] == "sid-b")
116 +
117 + assert payload_a["seq"] == 11 # seq_base=10 -> first push increments to 11
118 + assert payload_b["seq"] == 101 # seq_base=100 -> first push increments to 101
119 +
120 + assert payload_a["snapshot"]["context"] == "ctx-a"
121 + assert payload_b["snapshot"]["context"] == "ctx-b"
122 +
123 + # Verify per-sid cursor advancement is independent.
124 + assert monitor._projections[(namespace, "sid-a")].request.log_from == 1
125 + assert monitor._projections[(namespace, "sid-a")].request.notifications_from == 1
126 + assert monitor._projections[(namespace, "sid-b")].request.log_from == 41
127 + assert monitor._projections[(namespace, "sid-b")].request.notifications_from == 8
128 +
129 +
130 +@pytest.mark.asyncio
131 +async def test_state_monitor_mark_dirty_for_context_scopes_to_active_context():
132 + from python.helpers.state_monitor import StateMonitor
133 + from python.helpers.state_snapshot import StateRequestV1
134 +
135 + monitor = StateMonitor(debounce_seconds=60.0)
136 + namespace = "/state_sync"
137 + monitor.register_sid(namespace, "sid-a")
138 + monitor.register_sid(namespace, "sid-b")
139 +
140 + monitor.update_projection(
141 + namespace,
142 + "sid-a",
143 + request=StateRequestV1(context="ctx-a", log_from=0, notifications_from=0, timezone="UTC"),
144 + seq_base=10,
145 + )
146 + monitor.update_projection(
147 + namespace,
148 + "sid-b",
149 + request=StateRequestV1(context="ctx-b", log_from=0, notifications_from=0, timezone="UTC"),
150 + seq_base=10,
151 + )
152 +
153 + monitor.mark_dirty_for_context("ctx-a")
154 + assert (namespace, "sid-a") in monitor._debounce_handles
155 + assert (namespace, "sid-b") not in monitor._debounce_handles
156 +
157 + monitor.unregister_sid(namespace, "sid-a")
158 + monitor.unregister_sid(namespace, "sid-b")
tests/test_persist_chat_log_ids.py new
+18
@@ -0,0 +1,18 @@
1 +from __future__ import annotations
2 +
3 +
4 +def test_deserialize_log_preserves_item_id() -> None:
5 + from python.helpers.log import Log
6 + from python.helpers.persist_chat import _deserialize_log, _serialize_log
7 +
8 + log = Log()
9 + log.log(type="user", heading="User message", content="hello", id="msg-123")
10 + log.log(type="assistant", heading="Assistant", content="hi")
11 +
12 + serialized = _serialize_log(log)
13 + restored = _deserialize_log(serialized)
14 +
15 + assert restored.logs[0].type == "user"
16 + assert restored.logs[0].id == "msg-123"
17 + assert restored.logs[1].type == "assistant"
18 + assert restored.logs[1].id is None
tests/test_run_ui_config.py new
+16
@@ -0,0 +1,16 @@
1 +import sys
2 +from pathlib import Path
3 +
4 +PROJECT_ROOT = Path(__file__).resolve().parents[1]
5 +if str(PROJECT_ROOT) not in sys.path:
6 + sys.path.insert(0, str(PROJECT_ROOT))
7 +
8 +import run_ui
9 +
10 +
11 +def test_socketio_engine_configuration_defaults():
12 + server = run_ui.socketio_server.eio
13 +
14 + assert server.ping_interval == 25
15 + assert server.ping_timeout == 20
16 + assert server.max_http_buffer_size == 50 * 1024 * 1024
tests/test_settings_developer_sections.py new
+30
@@ -0,0 +1,30 @@
1 +import sys
2 +from pathlib import Path
3 +
4 +
5 +PROJECT_ROOT = Path(__file__).resolve().parents[1]
6 +if str(PROJECT_ROOT) not in sys.path:
7 + sys.path.insert(0, str(PROJECT_ROOT))
8 +
9 +
10 +
11 +def test_websocket_harness_entrypoint_is_present_in_developer_settings_template():
12 + dev_template_path = (
13 + PROJECT_ROOT
14 + / "webui"
15 + / "components"
16 + / "settings"
17 + / "developer"
18 + / "dev.html"
19 + )
20 + content = dev_template_path.read_text(encoding="utf-8")
21 + assert "websocket-tester.html" in content
22 + assert "websocket-event-console.html" in content
23 + assert "!$store.settingsStore.additional?.is_dockerized" in content
24 +
25 +
26 +def test_websocket_harness_template_is_gated_by_runtime():
27 + template_path = PROJECT_ROOT / "webui" / "components" / "settings" / "developer" / "websocket-tester.html"
28 + content = template_path.read_text(encoding="utf-8")
29 + assert "window.runtimeInfo?.isDevelopment" in content
30 + assert "$store.root?.isDevelopment" not in content
tests/test_snapshot_parity.py new
+77
@@ -0,0 +1,77 @@
1 +import sys
2 +import threading
3 +from pathlib import Path
4 +
5 +import pytest
6 +from flask import Flask
7 +
8 +PROJECT_ROOT = Path(__file__).resolve().parents[1]
9 +if str(PROJECT_ROOT) not in sys.path:
10 + sys.path.insert(0, str(PROJECT_ROOT))
11 +
12 +from agent import AgentContext
13 +from initialize import initialize_agent
14 +from python.api.poll import Poll
15 +
16 +
17 +@pytest.mark.asyncio
18 +async def test_snapshot_builder_matches_poll_output_for_null_context():
19 + app = Flask("snapshot-parity-test")
20 + app.secret_key = "test-secret"
21 + lock = threading.RLock()
22 +
23 + poll = Poll(app, lock)
24 + poll_payload = await poll.process(
25 + {
26 + "context": None,
27 + "log_from": 0,
28 + "notifications_from": 0,
29 + "timezone": "UTC",
30 + },
31 + None, # Poll.process does not access the flask Request object.
32 + )
33 +
34 + from python.helpers import state_snapshot as snapshot
35 +
36 + builder_payload = await snapshot.build_snapshot(
37 + context=None,
38 + log_from=0,
39 + notifications_from=0,
40 + timezone="UTC",
41 + )
42 +
43 + assert builder_payload == poll_payload
44 +
45 +
46 +@pytest.mark.asyncio
47 +async def test_snapshot_builder_active_context_includes_incremental_logs():
48 + ctxid = "ctx-snapshot-parity"
49 + ctx = AgentContext(config=initialize_agent(), id=ctxid, set_current=False)
50 + try:
51 + ctx.log.log(type="user", heading="hi", content="hello")
52 + first = await Poll(Flask("parity-active"), threading.RLock()).process(
53 + {
54 + "context": ctxid,
55 + "log_from": 0,
56 + "notifications_from": 0,
57 + "timezone": "UTC",
58 + },
59 + None,
60 + )
61 + assert first["context"] == ctxid
62 + assert first["logs"]
63 + assert first["log_version"] == len(ctx.log.updates)
64 +
65 + from python.helpers import state_snapshot as snapshot
66 +
67 + second = await snapshot.build_snapshot(
68 + context=ctxid,
69 + log_from=first["log_version"],
70 + notifications_from=0,
71 + timezone="UTC",
72 + )
73 + assert second["context"] == ctxid
74 + assert second["logs"] == []
75 + assert second["log_version"] == first["log_version"]
76 + finally:
77 + AgentContext.remove(ctxid)
tests/test_snapshot_schema_v1.py new
+110
@@ -0,0 +1,110 @@
1 +import sys
2 +import threading
3 +from pathlib import Path
4 +
5 +import pytest
6 +from flask import Flask
7 +
8 +PROJECT_ROOT = Path(__file__).resolve().parents[1]
9 +if str(PROJECT_ROOT) not in sys.path:
10 + sys.path.insert(0, str(PROJECT_ROOT))
11 +
12 +from python.api.poll import Poll
13 +
14 +
15 +EXPECTED_SNAPSHOT_KEYS = {
16 + "deselect_chat",
17 + "context",
18 + "contexts",
19 + "tasks",
20 + "logs",
21 + "log_guid",
22 + "log_version",
23 + "log_progress",
24 + "log_progress_active",
25 + "paused",
26 + "notifications",
27 + "notifications_guid",
28 + "notifications_version",
29 +}
30 +
31 +
32 +@pytest.mark.asyncio
33 +async def test_poll_snapshot_matches_contract_schema_key_set_null_context():
34 + app = Flask("poll-snapshot-schema-test")
35 + app.secret_key = "test-secret"
36 + lock = threading.RLock()
37 +
38 + poll = Poll(app, lock)
39 + payload = await poll.process(
40 + {
41 + "context": None,
42 + "log_from": 0,
43 + "notifications_from": 0,
44 + "timezone": "UTC",
45 + },
46 + None, # Poll.process does not access the flask Request object.
47 + )
48 +
49 + assert set(payload.keys()) == EXPECTED_SNAPSHOT_KEYS
50 + assert payload["deselect_chat"] is False
51 + assert payload["context"] == ""
52 + assert payload["logs"] == []
53 + assert payload["log_guid"] == ""
54 + assert payload["log_version"] == 0
55 + assert payload["log_progress"] == 0
56 + assert payload["log_progress_active"] is False
57 + assert payload["paused"] is False
58 +
59 +
60 +@pytest.mark.asyncio
61 +async def test_snapshot_builder_produces_contract_schema_key_set_and_defaults():
62 + from python.helpers import state_snapshot as snapshot
63 +
64 + payload = await snapshot.build_snapshot(
65 + context=None,
66 + log_from=0,
67 + notifications_from=0,
68 + timezone="UTC",
69 + )
70 +
71 + snapshot.validate_snapshot_schema_v1(payload)
72 + assert set(payload.keys()) == EXPECTED_SNAPSHOT_KEYS
73 + assert payload["deselect_chat"] is False
74 + assert payload["context"] == ""
75 + assert payload["logs"] == []
76 + assert payload["log_guid"] == ""
77 + assert payload["log_version"] == 0
78 + assert payload["log_progress"] == 0
79 + assert payload["log_progress_active"] is False
80 + assert payload["paused"] is False
81 + assert isinstance(payload["contexts"], list)
82 + assert isinstance(payload["tasks"], list)
83 + assert isinstance(payload["notifications"], list)
84 + assert isinstance(payload["notifications_guid"], str)
85 + assert isinstance(payload["notifications_version"], int)
86 + assert payload["notifications_version"] >= 0
87 +
88 +
89 +def test_snapshot_schema_rejects_unexpected_top_level_keys():
90 + from python.helpers import state_snapshot as snapshot
91 +
92 + payload = {
93 + "deselect_chat": False,
94 + "context": "",
95 + "contexts": [],
96 + "tasks": [],
97 + "logs": [],
98 + "log_guid": "",
99 + "log_version": 0,
100 + "log_progress": 0,
101 + "log_progress_active": False,
102 + "paused": False,
103 + "notifications": [],
104 + "notifications_guid": "guid",
105 + "notifications_version": 0,
106 + "api_key": "should-not-be-here",
107 + }
108 +
109 + with pytest.raises(ValueError):
110 + snapshot.validate_snapshot_schema_v1(payload)
tests/test_socketio_library_semantics.py new
+117
@@ -0,0 +1,117 @@
1 +import asyncio
2 +import contextlib
3 +import socket
4 +from typing import Any, AsyncIterator
5 +
6 +import pytest
7 +
8 +
9 +@contextlib.asynccontextmanager
10 +async def _run_asgi_app(app: Any) -> AsyncIterator[str]:
11 + import uvicorn
12 +
13 + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
14 + sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
15 + sock.bind(("127.0.0.1", 0))
16 + sock.listen(128)
17 +
18 + port = sock.getsockname()[1]
19 +
20 + config = uvicorn.Config(
21 + app,
22 + host="127.0.0.1",
23 + port=port,
24 + log_level="warning",
25 + access_log=False,
26 + lifespan="off",
27 + )
28 + server = uvicorn.Server(config)
29 + server.install_signal_handlers = lambda: None # type: ignore[method-assign]
30 +
31 + task = asyncio.create_task(server.serve(sockets=[sock]))
32 + try:
33 + while not server.started:
34 + await asyncio.sleep(0.01)
35 + yield f"http://127.0.0.1:{port}"
36 + finally:
37 + server.should_exit = True
38 + try:
39 + await asyncio.wait_for(task, timeout=5)
40 + finally:
41 + sock.close()
42 +
43 +
44 +@pytest.mark.asyncio
45 +async def test_socketio_wildcard_handler_only_runs_for_unhandled_events() -> None:
46 + import socketio
47 +
48 + handled_calls: list[tuple[str, Any]] = []
49 + wildcard_calls: list[str] = []
50 +
51 + sio = socketio.AsyncServer(async_mode="asgi", cors_allowed_origins="*")
52 +
53 + @sio.on("handled", namespace="/ns")
54 + async def _handled(sid: str, data: Any) -> dict[str, Any]:
55 + handled_calls.append((sid, data))
56 + return {"path": "handled"}
57 +
58 + @sio.on("*", namespace="/ns")
59 + async def _wildcard(event: str, sid: str, data: Any) -> dict[str, Any]:
60 + wildcard_calls.append(event)
61 + return {"path": "wildcard", "event": event}
62 +
63 + app = socketio.ASGIApp(sio)
64 +
65 + async with _run_asgi_app(app) as base_url:
66 + client = socketio.AsyncClient()
67 + await client.connect(base_url, namespaces=["/ns"])
68 + try:
69 + res = await client.call("handled", {"x": 1}, namespace="/ns", timeout=2)
70 + assert res == {"path": "handled"}
71 + assert wildcard_calls == []
72 +
73 + res2 = await client.call("unhandled_event", {"x": 2}, namespace="/ns", timeout=2)
74 + assert res2 == {"path": "wildcard", "event": "unhandled_event"}
75 + assert wildcard_calls == ["unhandled_event"]
76 + finally:
77 + await client.disconnect()
78 +
79 +
80 +@pytest.mark.asyncio
81 +async def test_socketio_handler_return_values_ack_only_when_client_requests_ack() -> None:
82 + import socketio
83 +
84 + sio = socketio.AsyncServer(async_mode="asgi", cors_allowed_origins="*")
85 + sent_packets: list[Any] = []
86 +
87 + original_send_packet = sio._send_packet
88 +
89 + async def _record_send_packet(eio_sid: str, pkt: Any) -> None:
90 + sent_packets.append(pkt)
91 + await original_send_packet(eio_sid, pkt)
92 +
93 + sio._send_packet = _record_send_packet # type: ignore[assignment]
94 +
95 + @sio.on("returns_value", namespace="/ns")
96 + async def _returns_value(_sid: str, _data: Any) -> dict[str, Any]:
97 + return {"ok": True}
98 +
99 + app = socketio.ASGIApp(sio)
100 +
101 + async with _run_asgi_app(app) as base_url:
102 + client = socketio.AsyncClient()
103 + await client.connect(base_url, namespaces=["/ns"])
104 + try:
105 + sent_packets.clear()
106 + await client.emit("returns_value", {"x": 1}, namespace="/ns")
107 + await asyncio.sleep(0.05)
108 + ack_packets = [p for p in sent_packets if getattr(p, "packet_type", None) in (3, 6)]
109 + assert ack_packets == []
110 +
111 + sent_packets.clear()
112 + res = await client.call("returns_value", {"x": 2}, namespace="/ns", timeout=2)
113 + assert res == {"ok": True}
114 + ack_packets = [p for p in sent_packets if getattr(p, "packet_type", None) in (3, 6)]
115 + assert len(ack_packets) >= 1
116 + finally:
117 + await client.disconnect()
tests/test_socketio_unknown_namespace.py new
+103
@@ -0,0 +1,103 @@
1 +import asyncio
2 +import contextlib
3 +import socket
4 +from typing import Any, AsyncIterator
5 +
6 +import pytest
7 +
8 +
9 +@contextlib.asynccontextmanager
10 +async def _run_asgi_app(app: Any) -> AsyncIterator[str]:
11 + import uvicorn
12 +
13 + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
14 + sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
15 + sock.bind(("127.0.0.1", 0))
16 + sock.listen(128)
17 +
18 + port = sock.getsockname()[1]
19 +
20 + config = uvicorn.Config(
21 + app,
22 + host="127.0.0.1",
23 + port=port,
24 + log_level="warning",
25 + access_log=False,
26 + lifespan="off",
27 + )
28 + server = uvicorn.Server(config)
29 + server.install_signal_handlers = lambda: None # type: ignore[method-assign]
30 +
31 + task = asyncio.create_task(server.serve(sockets=[sock]))
32 + try:
33 + while not server.started:
34 + await asyncio.sleep(0.01)
35 + yield f"http://127.0.0.1:{port}"
36 + finally:
37 + server.should_exit = True
38 + try:
39 + await asyncio.wait_for(task, timeout=5)
40 + finally:
41 + sock.close()
42 +
43 +
44 +@pytest.mark.asyncio
45 +async def test_unknown_namespace_connect_error_can_be_made_deterministic() -> None:
46 + """
47 + Library-semantics test: demonstrate a deterministic connect_error payload shape for
48 + unknown namespaces using a server-side allowlist gatekeeper.
49 + """
50 +
51 + import socketio
52 + from socketio import packet
53 +
54 + sio = socketio.AsyncServer(async_mode="asgi", cors_allowed_origins="*", namespaces="*")
55 +
56 + allowed_namespaces = {"/known", "/"}
57 +
58 + original_handle_connect = sio._handle_connect
59 +
60 + async def _gatekeeper_handle_connect(eio_sid: str, namespace: str | None, data: Any) -> None:
61 + namespace = namespace or "/"
62 + if namespace not in allowed_namespaces:
63 + await sio._send_packet(
64 + eio_sid,
65 + sio.packet_class(
66 + packet.CONNECT_ERROR,
67 + data={
68 + "message": "UNKNOWN_NAMESPACE",
69 + "data": {"code": "UNKNOWN_NAMESPACE", "namespace": namespace},
70 + },
71 + namespace=namespace,
72 + ),
73 + )
74 + return
75 +
76 + await original_handle_connect(eio_sid, namespace, data)
77 +
78 + sio._handle_connect = _gatekeeper_handle_connect # type: ignore[assignment]
79 +
80 + app = socketio.ASGIApp(sio)
81 +
82 + async with _run_asgi_app(app) as base_url:
83 + client = socketio.AsyncClient()
84 + connect_error_fut: asyncio.Future[Any] = asyncio.get_running_loop().create_future()
85 +
86 + async def _on_connect_error(data: Any) -> None:
87 + if not connect_error_fut.done():
88 + connect_error_fut.set_result(data)
89 +
90 + client.on("connect_error", _on_connect_error, namespace="/unknown")
91 +
92 + try:
93 + with pytest.raises(socketio.exceptions.ConnectionError):
94 + await client.connect(base_url, namespaces=["/unknown"])
95 +
96 + err = await asyncio.wait_for(connect_error_fut, timeout=2)
97 + assert err["message"] == "UNKNOWN_NAMESPACE"
98 + assert err["data"] == {"code": "UNKNOWN_NAMESPACE", "namespace": "/unknown"}
99 + finally:
100 + try:
101 + await client.disconnect()
102 + except Exception:
103 + pass
tests/test_state_monitor.py new
+103
@@ -0,0 +1,103 @@
1 +import sys
2 +from pathlib import Path
3 +
4 +import pytest
5 +
6 +PROJECT_ROOT = Path(__file__).resolve().parents[1]
7 +if str(PROJECT_ROOT) not in sys.path:
8 + sys.path.insert(0, str(PROJECT_ROOT))
9 +
10 +
11 +@pytest.mark.asyncio
12 +async def test_state_monitor_debounce_coalesces_without_postponing_and_cleanup_cancels_pending():
13 + from python.helpers.state_monitor import StateMonitor
14 + from python.helpers.state_snapshot import StateRequestV1
15 +
16 + namespace = "/state_sync"
17 + monitor = StateMonitor(debounce_seconds=10.0)
18 + monitor.register_sid(namespace, "sid-1")
19 + monitor.bind_manager(type("FakeManager", (), {"_dispatcher_loop": None})())
20 + monitor.update_projection(
21 + namespace,
22 + "sid-1",
23 + request=StateRequestV1(context=None, log_from=0, notifications_from=0, timezone="UTC"),
24 + seq_base=1,
25 + )
26 +
27 + monitor.mark_dirty(namespace, "sid-1")
28 + first = monitor._debounce_handles[(namespace, "sid-1")]
29 +
30 + monitor.mark_dirty(namespace, "sid-1")
31 + second = monitor._debounce_handles[(namespace, "sid-1")]
32 +
33 + # Throttled coalescing: subsequent dirties keep the scheduled push instead of postponing it.
34 + assert first is second
35 + assert not second.cancelled()
36 +
37 + monitor.unregister_sid(namespace, "sid-1")
38 + assert second.cancelled()
39 + assert (namespace, "sid-1") not in monitor._debounce_handles
40 +
41 +
42 +@pytest.mark.asyncio
43 +async def test_state_monitor_namespace_identity_prevents_cross_namespace_state_push(monkeypatch) -> None:
44 + import asyncio
45 + from unittest.mock import AsyncMock
46 +
47 + from python.helpers.state_monitor import StateMonitor
48 + from python.helpers.state_snapshot import StateRequestV1
49 +
50 + loop = asyncio.get_running_loop()
51 + push_ready = asyncio.Event()
52 + captured: list[tuple[str, str]] = []
53 +
54 + async def _emit_to(namespace: str, sid: str, event_type: str, _payload: object, **_kwargs):
55 + if event_type == "state_push":
56 + captured.append((namespace, sid))
57 + push_ready.set()
58 +
59 + class FakeManager:
60 + def __init__(self):
61 + self._dispatcher_loop = loop
62 + self.emit_to = AsyncMock(side_effect=_emit_to)
63 +
64 + monitor = StateMonitor(debounce_seconds=0.0)
65 + manager = FakeManager()
66 + monitor.bind_manager(manager, handler_id="tester")
67 +
68 + sid = "shared-sid"
69 + ns_a = "/a"
70 + ns_b = "/b"
71 + monitor.register_sid(ns_a, sid)
72 + monitor.register_sid(ns_b, sid)
73 + monitor.update_projection(
74 + ns_a,
75 + sid,
76 + request=StateRequestV1(context=None, log_from=0, notifications_from=0, timezone="UTC"),
77 + seq_base=1,
78 + )
79 + monitor.update_projection(
80 + ns_b,
81 + sid,
82 + request=StateRequestV1(context=None, log_from=0, notifications_from=0, timezone="UTC"),
83 + seq_base=1,
84 + )
85 +
86 + async def _fake_snapshot(**_kwargs):
87 + return {
88 + "log_version": 0,
89 + "notifications_version": 0,
90 + "logs": [],
91 + "contexts": [],
92 + "tasks": [],
93 + "notifications": [],
94 + }
95 +
96 + # Patch build_snapshot used by StateMonitor so this test stays lightweight.
97 + monkeypatch.setattr("python.helpers.state_monitor.build_snapshot_from_request", _fake_snapshot)
98 +
99 + monitor.mark_dirty(ns_a, sid, reason="test")
100 + await asyncio.wait_for(push_ready.wait(), timeout=1.0)
101 +
102 + assert captured
103 + assert all(ns == ns_a for ns, _ in captured)
tests/test_state_sync_handler.py new
+168
@@ -0,0 +1,168 @@
1 +import sys
2 +import threading
3 +from pathlib import Path
4 +
5 +import pytest
6 +import asyncio
7 +import time
8 +
9 +PROJECT_ROOT = Path(__file__).resolve().parents[1]
10 +if str(PROJECT_ROOT) not in sys.path:
11 + sys.path.insert(0, str(PROJECT_ROOT))
12 +
13 +from python.helpers.websocket_manager import WebSocketManager
14 +
15 +NAMESPACE = "/state_sync"
16 +
17 +
18 +class FakeSocketIOServer:
19 + def __init__(self) -> None:
20 + from unittest.mock import AsyncMock
21 +
22 + self.emit = AsyncMock()
23 + self.disconnect = AsyncMock()
24 +
25 +
26 +async def _create_manager() -> WebSocketManager:
27 + socketio = FakeSocketIOServer()
28 + manager = WebSocketManager(socketio, threading.RLock())
29 +
30 + from python.websocket_handlers.state_sync_handler import StateSyncHandler
31 + from python.helpers.state_monitor import _reset_state_monitor_for_testing
32 +
33 + _reset_state_monitor_for_testing()
34 + StateSyncHandler._reset_instance_for_testing()
35 + handler = StateSyncHandler.get_instance(socketio, threading.RLock())
36 + manager.register_handlers({NAMESPACE: [handler]})
37 + await manager.handle_connect(NAMESPACE, "sid-1")
38 + return manager
39 +
40 +
41 +async def _create_manager_with_socketio() -> tuple[WebSocketManager, FakeSocketIOServer]:
42 + socketio = FakeSocketIOServer()
43 + manager = WebSocketManager(socketio, threading.RLock())
44 +
45 + from python.websocket_handlers.state_sync_handler import StateSyncHandler
46 + from python.helpers.state_monitor import _reset_state_monitor_for_testing
47 +
48 + _reset_state_monitor_for_testing()
49 + StateSyncHandler._reset_instance_for_testing()
50 + handler = StateSyncHandler.get_instance(socketio, threading.RLock())
51 + manager.register_handlers({NAMESPACE: [handler]})
52 + await manager.handle_connect(NAMESPACE, "sid-1")
53 + return manager, socketio
54 +
55 +
56 +@pytest.mark.asyncio
57 +async def test_state_request_success_returns_wire_level_shape_and_contract_payload():
58 + manager = await _create_manager()
59 +
60 + response = await manager.route_event(
61 + NAMESPACE,
62 + "state_request",
63 + {
64 + "correlationId": "client-1",
65 + "ts": "2025-12-28T00:00:00.000Z",
66 + "data": {
67 + "context": None,
68 + "log_from": 0,
69 + "notifications_from": 0,
70 + "timezone": "UTC",
71 + },
72 + },
73 + "sid-1",
74 + )
75 +
76 + assert response["correlationId"] == "client-1"
77 + assert isinstance(response.get("results"), list)
78 + assert response["results"]
79 +
80 + first = response["results"][0]
81 + assert first["ok"] is True
82 + assert first["correlationId"] == "client-1"
83 + assert isinstance(first.get("data"), dict)
84 + assert set(first["data"].keys()) >= {"runtime_epoch", "seq_base"}
85 + assert isinstance(first["data"]["runtime_epoch"], str) and first["data"]["runtime_epoch"]
86 + assert isinstance(first["data"]["seq_base"], int)
87 +
88 +
89 +@pytest.mark.asyncio
90 +async def test_state_request_invalid_payload_returns_invalid_request_error():
91 + manager = await _create_manager()
92 +
93 + response = await manager.route_event(
94 + NAMESPACE,
95 + "state_request",
96 + {
97 + "correlationId": "client-2",
98 + "ts": "2025-12-28T00:00:00.000Z",
99 + "data": {
100 + "context": None,
101 + "log_from": -1,
102 + "notifications_from": 0,
103 + "timezone": "UTC",
104 + },
105 + },
106 + "sid-1",
107 + )
108 +
109 + assert response["correlationId"] == "client-2"
110 + assert response["results"]
111 + first = response["results"][0]
112 + assert first["ok"] is False
113 + assert first["error"]["code"] == "INVALID_REQUEST"
114 +
115 +
116 +@pytest.mark.asyncio
117 +async def test_state_push_gating_and_initial_snapshot_delivery():
118 + from python.helpers.state_monitor import get_state_monitor
119 + from python.helpers.state_snapshot import validate_snapshot_schema_v1
120 +
121 + manager, socketio = await _create_manager_with_socketio()
122 +
123 + push_ready = asyncio.Event()
124 + captured: dict[str, object] = {}
125 +
126 + async def _emit(event_type, envelope, **_kwargs):
127 + if event_type == "state_push":
128 + captured["envelope"] = envelope
129 + push_ready.set()
130 +
131 + socketio.emit.side_effect = _emit
132 +
133 + # INVARIANT.STATE.GATING: no push before a successful state_request.
134 + get_state_monitor().mark_dirty(NAMESPACE, "sid-1")
135 + await asyncio.sleep(0.2)
136 + assert not push_ready.is_set()
137 +
138 + start = time.monotonic()
139 + await manager.route_event(
140 + NAMESPACE,
141 + "state_request",
142 + {
143 + "correlationId": "client-gating",
144 + "ts": "2025-12-28T00:00:00.000Z",
145 + "data": {
146 + "context": None,
147 + "log_from": 0,
148 + "notifications_from": 0,
149 + "timezone": "UTC",
150 + },
151 + },
152 + "sid-1",
153 + )
154 +
155 + await asyncio.wait_for(push_ready.wait(), timeout=1.0)
156 + assert (time.monotonic() - start) <= 1.0
157 +
158 + envelope = captured.get("envelope")
159 + assert isinstance(envelope, dict)
160 + data = envelope.get("data")
161 + assert isinstance(data, dict)
162 + assert set(data.keys()) >= {"runtime_epoch", "seq", "snapshot"}
163 + assert isinstance(data["runtime_epoch"], str) and data["runtime_epoch"]
164 + assert isinstance(data["seq"], int)
165 + assert isinstance(data["snapshot"], dict)
166 + validate_snapshot_schema_v1(data["snapshot"])
167 +
168 + await manager.handle_disconnect(NAMESPACE, "sid-1")
tests/test_state_sync_welcome_screen.py new
+82
@@ -0,0 +1,82 @@
1 +import asyncio
2 +import sys
3 +import threading
4 +import time
5 +from pathlib import Path
6 +
7 +import pytest
8 +
9 +PROJECT_ROOT = Path(__file__).resolve().parents[1]
10 +if str(PROJECT_ROOT) not in sys.path:
11 + sys.path.insert(0, str(PROJECT_ROOT))
12 +
13 +from python.helpers.websocket_manager import WebSocketManager
14 +
15 +NAMESPACE = "/state_sync"
16 +
17 +
18 +class FakeSocketIOServer:
19 + def __init__(self) -> None:
20 + from unittest.mock import AsyncMock
21 +
22 + self.emit = AsyncMock()
23 + self.disconnect = AsyncMock()
24 +
25 +
26 +@pytest.mark.asyncio
27 +async def test_state_sync_handshake_and_initial_snapshot_work_with_no_selected_context() -> None:
28 + """
29 + Regression for Welcome screen: the UI has no selected context, so `state_request.context`
30 + is null. We must still handshake and receive an initial `state_push` quickly (no hang).
31 + """
32 +
33 + from python.helpers.state_snapshot import validate_snapshot_schema_v1
34 + from python.helpers.state_monitor import _reset_state_monitor_for_testing
35 + from python.websocket_handlers.state_sync_handler import StateSyncHandler
36 +
37 + socketio = FakeSocketIOServer()
38 + manager = WebSocketManager(socketio, threading.RLock())
39 +
40 + _reset_state_monitor_for_testing()
41 + StateSyncHandler._reset_instance_for_testing()
42 + handler = StateSyncHandler.get_instance(socketio, threading.RLock())
43 + manager.register_handlers({NAMESPACE: [handler]})
44 + await manager.handle_connect(NAMESPACE, "sid-1")
45 +
46 + push_ready = asyncio.Event()
47 + captured: dict[str, object] = {}
48 +
49 + async def _emit(event_type, envelope, **_kwargs):
50 + if event_type == "state_push":
51 + captured["envelope"] = envelope
52 + push_ready.set()
53 +
54 + socketio.emit.side_effect = _emit
55 +
56 + start = time.monotonic()
57 + await manager.route_event(
58 + NAMESPACE,
59 + "state_request",
60 + {
61 + "correlationId": "client-welcome",
62 + "ts": "2026-01-05T00:00:00.000Z",
63 + "data": {
64 + "context": None, # welcome screen (no selected chat)
65 + "log_from": 0,
66 + "notifications_from": 0,
67 + "timezone": "UTC",
68 + },
69 + },
70 + "sid-1",
71 + )
72 +
73 + await asyncio.wait_for(push_ready.wait(), timeout=1.0)
74 + assert (time.monotonic() - start) <= 1.0
75 +
76 + envelope = captured.get("envelope")
77 + assert isinstance(envelope, dict)
78 + data = envelope.get("data")
79 + assert isinstance(data, dict)
80 + assert set(data.keys()) >= {"runtime_epoch", "seq", "snapshot"}
81 + assert isinstance(data["snapshot"], dict)
82 + validate_snapshot_schema_v1(data["snapshot"])
tests/test_websocket_client_api_surface.py new
+41
@@ -0,0 +1,41 @@
1 +import re
2 +import sys
3 +from pathlib import Path
4 +
5 +
6 +PROJECT_ROOT = Path(__file__).resolve().parents[1]
7 +if str(PROJECT_ROOT) not in sys.path:
8 + sys.path.insert(0, str(PROJECT_ROOT))
9 +
10 +
11 +def _get_named_exports(source: str) -> set[str]:
12 + exports: set[str] = set()
13 +
14 + exports.update(re.findall(r"^export\s+function\s+([A-Za-z0-9_]+)\s*\(", source, flags=re.M))
15 + exports.update(re.findall(r"^export\s+const\s+([A-Za-z0-9_]+)\s*=", source, flags=re.M))
16 + exports.update(re.findall(r"^export\s+class\s+([A-Za-z0-9_]+)\s*[\{:]", source, flags=re.M))
17 +
18 + for m in re.findall(r"^export\s*\{([^}]+)\}\s*;?", source, flags=re.M):
19 + for item in m.split(","):
20 + item = item.strip()
21 + if not item:
22 + continue
23 + # Handle: `foo as bar`
24 + parts = item.split()
25 + if len(parts) >= 3 and parts[-2] == "as":
26 + exports.add(parts[-1])
27 + else:
28 + exports.add(parts[0])
29 +
30 + return exports
31 +
32 +
33 +def test_websocket_js_exports_minimal_namespaced_api_surface() -> None:
34 + source = (PROJECT_ROOT / "webui" / "js" / "websocket.js").read_text(encoding="utf-8")
35 + exports = _get_named_exports(source)
36 +
37 + assert "createNamespacedClient" in exports
38 + assert "getNamespacedClient" in exports
39 +
40 + assert "broadcast" not in exports
41 + assert "requestAll" not in exports
tests/test_websocket_csrf.py new
+51
@@ -0,0 +1,51 @@
1 +import sys
2 +from pathlib import Path
3 +
4 +PROJECT_ROOT = Path(__file__).resolve().parents[1]
5 +if str(PROJECT_ROOT) not in sys.path:
6 + sys.path.insert(0, str(PROJECT_ROOT))
7 +
8 +from python.helpers.websocket import validate_ws_origin
9 +
10 +
11 +def test_validate_ws_origin_allows_same_origin_with_explicit_port():
12 + ok, reason = validate_ws_origin(
13 + {
14 + "HTTP_ORIGIN": "http://localhost:5000",
15 + "HTTP_HOST": "localhost:5000",
16 + }
17 + )
18 + assert ok is True
19 + assert reason is None
20 +
21 +
22 +def test_validate_ws_origin_allows_default_https_port_without_explicit_port():
23 + ok, reason = validate_ws_origin(
24 + {
25 + "HTTP_ORIGIN": "https://example.com",
26 + "HTTP_HOST": "example.com",
27 + }
28 + )
29 + assert ok is True
30 + assert reason is None
31 +
32 +
33 +def test_validate_ws_origin_rejects_missing_origin():
34 + ok, reason = validate_ws_origin(
35 + {
36 + "HTTP_HOST": "localhost:5000",
37 + }
38 + )
39 + assert ok is False
40 + assert reason == "missing_origin"
41 +
42 +
43 +def test_validate_ws_origin_rejects_cross_origin():
44 + ok, reason = validate_ws_origin(
45 + {
46 + "HTTP_ORIGIN": "http://evil.test",
47 + "HTTP_HOST": "localhost:5000",
48 + }
49 + )
50 + assert ok is False
51 + assert reason == "origin_host_mismatch"
tests/test_websocket_handlers.py new
+176
@@ -0,0 +1,176 @@
1 +import sys
2 +import threading
3 +from pathlib import Path
4 +
5 +import pytest
6 +
7 +PROJECT_ROOT = Path(__file__).resolve().parents[1]
8 +if str(PROJECT_ROOT) not in sys.path:
9 + sys.path.insert(0, str(PROJECT_ROOT))
10 +
11 +from python.helpers.websocket import (
12 + WebSocketHandler,
13 + WebSocketResult,
14 + SingletonInstantiationError,
15 +)
16 +
17 +
18 +class _FakeSocketIO:
19 + async def emit(self, *_args, **_kwargs): # pragma: no cover - helper stub
20 + return None
21 +
22 + async def disconnect(self, *_args, **_kwargs): # pragma: no cover - helper stub
23 + return None
24 +
25 +
26 +class _TestHandler(WebSocketHandler):
27 + @classmethod
28 + def get_event_types(cls) -> list[str]:
29 + return ["test_event"]
30 +
31 + async def process_event(self, event_type: str, data: dict, sid: str) -> None:
32 + return None
33 +
34 +
35 +def _make_handler() -> _TestHandler:
36 + _TestHandler._reset_instance_for_testing()
37 + return _TestHandler.get_instance(_FakeSocketIO(), threading.RLock())
38 +
39 +
40 +def test_websocket_result_ok_clones_payload():
41 + payload = {"value": 1}
42 + result = WebSocketResult.ok(payload)
43 +
44 + assert result.as_result(
45 + handler_id="handler",
46 + fallback_correlation_id="corr",
47 + )["data"] == payload
48 +
49 + payload["value"] = 2
50 + assert result.as_result(
51 + handler_id="handler",
52 + fallback_correlation_id="corr",
53 + )["data"] == {"value": 1}
54 +
55 +
56 +def test_websocket_result_error_contains_metadata():
57 + result = WebSocketResult.error(
58 + code="E_TEST",
59 + message="failure",
60 + details="additional",
61 + correlation_id="corr",
62 + duration_ms=12.5,
63 + )
64 +
65 + as_payload = result.as_result(handler_id="handler", fallback_correlation_id=None)
66 + assert as_payload["ok"] is False
67 + assert as_payload["error"] == {
68 + "code": "E_TEST",
69 + "error": "failure",
70 + "details": "additional",
71 + }
72 + assert as_payload["correlationId"] == "corr"
73 + assert as_payload["durationMs"] == pytest.approx(12.5, rel=1e-3)
74 +
75 +
76 +def test_websocket_result_applies_fallback_correlation_and_duration():
77 + result = WebSocketResult.ok(duration_ms=5.4321)
78 + payload = result.as_result(
79 + handler_id="handler",
80 + fallback_correlation_id="corr-fallback",
81 + )
82 + assert payload["correlationId"] == "corr-fallback"
83 + assert payload["durationMs"] == pytest.approx(5.4321, rel=1e-3)
84 +
85 +
86 +def test_handler_result_helpers_return_websocket_result_instances():
87 + handler = _make_handler()
88 +
89 + ok_result = handler.result_ok({"foo": "bar"}, correlation_id="cid")
90 + assert isinstance(ok_result, WebSocketResult)
91 + ok_payload = ok_result.as_result(
92 + handler_id="handler",
93 + fallback_correlation_id=None,
94 + )
95 + assert ok_payload["ok"] is True
96 + assert ok_payload["data"] == {"foo": "bar"}
97 + assert ok_payload["correlationId"] == "cid"
98 +
99 + err_result = handler.result_error(
100 + code="E_BAD",
101 + message="boom",
102 + details="missing",
103 + correlation_id="err",
104 + )
105 + assert isinstance(err_result, WebSocketResult)
106 + err_payload = err_result.as_result(
107 + handler_id="handler",
108 + fallback_correlation_id=None,
109 + )
110 + assert err_payload["ok"] is False
111 + assert err_payload["error"] == {
112 + "code": "E_BAD",
113 + "error": "boom",
114 + "details": "missing",
115 + }
116 + assert err_payload["correlationId"] == "err"
117 +
118 +
119 +def test_result_error_requires_error_payload():
120 + with pytest.raises(ValueError):
121 + WebSocketResult(ok=False)
122 +
123 + with pytest.raises(ValueError):
124 + WebSocketResult.error(code="", message="boom")
125 +
126 +
127 +def test_handler_direct_instantiation_disallowed():
128 + with pytest.raises(SingletonInstantiationError):
129 + _TestHandler(_FakeSocketIO(), threading.RLock())
130 +
131 +
132 +def test_get_instance_returns_singleton():
133 + _TestHandler._reset_instance_for_testing()
134 + socketio = _FakeSocketIO()
135 + lock = threading.RLock()
136 + first = _TestHandler.get_instance(socketio, lock)
137 + second = _TestHandler.get_instance(None, None)
138 + assert first is second
139 +
140 +
141 +@pytest.mark.asyncio
142 +async def test_state_sync_handler_registers_and_routes_state_request():
143 + from python.helpers.websocket_manager import WebSocketManager
144 + from python.websocket_handlers.state_sync_handler import StateSyncHandler
145 + from python.helpers.state_monitor import _reset_state_monitor_for_testing
146 +
147 + _reset_state_monitor_for_testing()
148 + StateSyncHandler._reset_instance_for_testing()
149 +
150 + socketio = _FakeSocketIO()
151 + lock = threading.RLock()
152 + manager = WebSocketManager(socketio, lock)
153 + handler = StateSyncHandler.get_instance(socketio, lock)
154 + namespace = "/state_sync"
155 + manager.register_handlers({namespace: [handler]})
156 + await manager.handle_connect(namespace, "sid-1")
157 +
158 + response = await manager.route_event(
159 + namespace,
160 + "state_request",
161 + {
162 + "correlationId": "smoke-1",
163 + "ts": "2025-12-28T00:00:00.000Z",
164 + "data": {
165 + "context": None,
166 + "log_from": 0,
167 + "notifications_from": 0,
168 + "timezone": "UTC",
169 + },
170 + },
171 + "sid-1",
172 + )
173 +
174 + assert response["correlationId"] == "smoke-1"
175 + assert response["results"] and response["results"][0]["ok"] is True
176 + await manager.handle_disconnect(namespace, "sid-1")
tests/test_websocket_harness.py new
+173
@@ -0,0 +1,173 @@
1 +import sys
2 +import threading
3 +from pathlib import Path
4 +from typing import Any
5 +
6 +import pytest
7 +
8 +PROJECT_ROOT = Path(__file__).resolve().parents[1]
9 +if str(PROJECT_ROOT) not in sys.path:
10 + sys.path.insert(0, str(PROJECT_ROOT))
11 +
12 +from python.helpers.websocket_manager import WebSocketManager
13 +from python.websocket_handlers.dev_websocket_test_handler import (
14 + DevWebsocketTestHandler,
15 +)
16 +
17 +NAMESPACE = "/dev_websocket_test"
18 +
19 +
20 +class FakeSocketIOServer:
21 + def __init__(self) -> None:
22 + from unittest.mock import AsyncMock
23 +
24 + self.emit = AsyncMock()
25 + self.disconnect = AsyncMock()
26 +
27 +
28 +async def _create_manager() -> tuple[WebSocketManager, DevWebsocketTestHandler, FakeSocketIOServer]:
29 + socketio = FakeSocketIOServer()
30 + manager = WebSocketManager(socketio, threading.RLock())
31 + DevWebsocketTestHandler._reset_instance_for_testing()
32 + handler = DevWebsocketTestHandler.get_instance(socketio, threading.RLock())
33 + manager.register_handlers({NAMESPACE: [handler]})
34 + await manager.handle_connect(NAMESPACE, "sid-primary")
35 + return manager, handler, socketio
36 +
37 +
38 +@pytest.mark.asyncio
39 +async def test_harness_emit_broadcasts_to_active_connections():
40 + manager, _handler, socketio = await _create_manager()
41 +
42 + await manager.route_event(
43 + NAMESPACE,
44 + "ws_tester_emit",
45 + {"message": "emit-check", "timestamp": "2025-10-29T12:00:00Z"},
46 + "sid-primary",
47 + )
48 +
49 + socketio.emit.assert_awaited()
50 + emit_calls = [(call.args, call.kwargs) for call in socketio.emit.await_args_list]
51 + match = next((c for c in emit_calls if c[0] and c[0][0] == "ws_tester_broadcast"), None)
52 + assert match is not None
53 + args, kwargs = match
54 + envelope = args[1]
55 + assert envelope["handlerId"].endswith("DevWebsocketTestHandler")
56 + assert envelope["data"]["message"] == "emit-check"
57 + assert kwargs == {"to": "sid-primary", "namespace": NAMESPACE}
58 +
59 +
60 +@pytest.mark.asyncio
61 +async def test_harness_request_returns_per_handler_result():
62 + manager, _handler, _socketio = await _create_manager()
63 +
64 + response = await manager.route_event(
65 + NAMESPACE,
66 + "ws_tester_request",
67 + {"value": 42},
68 + "sid-primary",
69 + )
70 +
71 + assert isinstance(response, dict)
72 + assert response["results"]
73 + first = response["results"][0]
74 + assert first["ok"] is True
75 + assert first["data"]["echo"] == 42
76 + assert response["correlationId"]
77 + assert first["handlerId"].endswith("DevWebsocketTestHandler")
78 + assert first["correlationId"] == response["correlationId"]
79 +
80 +
81 +@pytest.mark.asyncio
82 +async def test_harness_request_delayed_waits_for_sleep(monkeypatch):
83 + manager, _handler, _socketio = await _create_manager()
84 +
85 + calls: list[float] = []
86 +
87 + async def _fake_sleep(delay: float) -> None: # pragma: no cover - helper
88 + calls.append(delay)
89 +
90 + monkeypatch.setattr(
91 + "python.websocket_handlers.dev_websocket_test_handler.asyncio.sleep",
92 + _fake_sleep,
93 + )
94 +
95 + await manager.route_event(
96 + NAMESPACE,
97 + "ws_tester_request_delayed",
98 + {"delay_ms": 1500},
99 + "sid-primary",
100 + )
101 +
102 + assert calls == [1.5]
103 +
104 +
105 +@pytest.mark.asyncio
106 +async def test_harness_persistence_emit_targets_requesting_sid():
107 + manager, _handler, socketio = await _create_manager()
108 +
109 + await manager.route_event(
110 + NAMESPACE,
111 + "ws_tester_trigger_persistence",
112 + {"phase": "after"},
113 + "sid-primary",
114 + )
115 +
116 + socketio.emit.assert_awaited()
117 + emit_calls = [(call.args, call.kwargs) for call in socketio.emit.await_args_list]
118 + match = next((c for c in emit_calls if c[0] and c[0][0] == "ws_tester_persistence"), None)
119 + assert match is not None
120 + args, kwargs = match
121 + payload = args[1]
122 + assert payload["handlerId"] == _handler.identifier
123 + assert payload["data"] == {"phase": "after", "handler": _handler.identifier}
124 + assert kwargs == {"to": "sid-primary", "namespace": NAMESPACE}
125 +
126 +
127 +@pytest.mark.asyncio
128 +async def test_harness_request_all_aggregates_all_connections():
129 + manager, _handler, _socketio = await _create_manager()
130 + await manager.handle_connect(NAMESPACE, "sid-secondary")
131 +
132 + response = await manager.route_event(
133 + NAMESPACE,
134 + "ws_tester_request_all",
135 + {"marker": "aggregate"},
136 + "sid-primary",
137 + )
138 +
139 + assert response["results"] and response["results"][0]["ok"] is True
140 + data = response["results"][0]["data"]
141 + aggregated = data.get("results") or data.get("result")
142 + assert isinstance(aggregated, list)
143 + by_sid: dict[str, Any] = {entry["sid"]: entry["results"] for entry in aggregated}
144 + assert set(by_sid.keys()) == {"sid-primary", "sid-secondary"}
145 + for results in by_sid.values():
146 + assert results and results[0]["ok"] is True
147 + payload = results[0]["data"]
148 + assert payload["handler"].endswith("DevWebsocketTestHandler")
149 + assert results[0]["handlerId"].endswith("DevWebsocketTestHandler")
150 + assert results[0]["correlationId"] == response["results"][0]["correlationId"]
151 + assert response["correlationId"]
152 +
153 +
154 +@pytest.mark.asyncio
155 +async def test_harness_request_all_respects_exclude_handlers():
156 + manager, handler, _socketio = await _create_manager()
157 + await manager.handle_connect(NAMESPACE, "sid-secondary")
158 +
159 + response = await manager.route_event(
160 + NAMESPACE,
161 + "ws_tester_request_all",
162 + {
163 + "marker": "exclude",
164 + "excludeHandlers": [handler.identifier],
165 + },
166 + "sid-primary",
167 + )
168 +
169 + assert response["correlationId"]
170 + first = response["results"][0]
171 + assert first["ok"] is False
172 + assert first["error"]["code"] == "INVALID_FILTER"
173 + assert "excludeHandlers" in first["error"]["error"]
tests/test_websocket_manager.py new
+853
@@ -0,0 +1,853 @@
1 +import asyncio
2 +import sys
3 +import threading
4 +import time
5 +from pathlib import Path
6 +from typing import Any
7 +from unittest.mock import AsyncMock, patch
8 +
9 +import pytest
10 +
11 +PROJECT_ROOT = Path(__file__).resolve().parents[1]
12 +if str(PROJECT_ROOT) not in sys.path:
13 + sys.path.insert(0, str(PROJECT_ROOT))
14 +
15 +from python.helpers.websocket import ConnectionNotFoundError, WebSocketHandler, WebSocketResult
16 +from python.helpers.websocket_manager import (
17 + WebSocketManager,
18 + BUFFER_TTL,
19 + DIAGNOSTIC_EVENT,
20 + LIFECYCLE_CONNECT_EVENT,
21 + LIFECYCLE_DISCONNECT_EVENT,
22 +)
23 +
24 +NAMESPACE = "/test"
25 +
26 +
27 +class FakeSocketIOServer:
28 + def __init__(self):
29 + self.emit = AsyncMock()
30 + self.disconnect = AsyncMock()
31 +
32 +
33 +class DummyHandler(WebSocketHandler):
34 + def __init__(self, socketio, lock, results):
35 + super().__init__(socketio, lock)
36 + self.results = results
37 +
38 + @classmethod
39 + def get_event_types(cls) -> list[str]:
40 + return ["dummy"]
41 +
42 + async def process_event(self, event_type: str, data: dict[str, Any], sid: str):
43 + response = {"sid": sid, "data": data}
44 + self.results.append(response)
45 + return response
46 +
47 +
48 +@pytest.mark.asyncio
49 +async def test_connect_disconnect_updates_registry():
50 + socketio = FakeSocketIOServer()
51 + manager = WebSocketManager(socketio, threading.RLock())
52 +
53 + await manager.handle_connect(NAMESPACE, "abc")
54 + assert (NAMESPACE, "abc") in manager.connections
55 +
56 + await manager.handle_disconnect(NAMESPACE, "abc")
57 + assert (NAMESPACE, "abc") not in manager.connections
58 +
59 +
60 +@pytest.mark.asyncio
61 +async def test_server_restart_broadcast_emitted_when_enabled():
62 + socketio = FakeSocketIOServer()
63 + manager = WebSocketManager(socketio, threading.RLock())
64 + manager.set_server_restart_broadcast(True)
65 +
66 + await manager.handle_connect(NAMESPACE, "sid-restart")
67 +
68 + socketio.emit.assert_awaited()
69 + args, kwargs = socketio.emit.await_args_list[0]
70 + assert args[0] == "server_restart"
71 + envelope = args[1]
72 + assert envelope["handlerId"] == manager._identifier # noqa: SLF001
73 + assert envelope["data"]["runtimeId"]
74 + assert kwargs == {"to": "sid-restart", "namespace": NAMESPACE}
75 +
76 +
77 +@pytest.mark.asyncio
78 +async def test_server_restart_broadcast_skipped_when_disabled():
79 + socketio = FakeSocketIOServer()
80 + manager = WebSocketManager(socketio, threading.RLock())
81 + manager.set_server_restart_broadcast(False)
82 +
83 + await manager.handle_connect(NAMESPACE, "sid-no-restart")
84 +
85 + assert socketio.emit.await_count == 0
86 +
87 +
88 +@pytest.mark.asyncio
89 +async def test_broadcast_performance_smoke(monkeypatch):
90 + socketio = FakeSocketIOServer()
91 + manager = WebSocketManager(socketio, threading.RLock())
92 +
93 + for idx in range(50):
94 + await manager.handle_connect(NAMESPACE, f"sid-{idx}")
95 +
96 + import time
97 +
98 + start = time.perf_counter()
99 + await manager.broadcast(NAMESPACE, "perf_event", {"ok": True})
100 + duration_ms = (time.perf_counter() - start) * 1000
101 +
102 + assert socketio.emit.await_count == 50
103 + assert duration_ms < 300
104 +
105 +
106 +@pytest.mark.asyncio
107 +async def test_route_event_invokes_handler_and_ack():
108 + socketio = FakeSocketIOServer()
109 + manager = WebSocketManager(socketio, threading.RLock())
110 +
111 + results = []
112 + DummyHandler._reset_instance_for_testing()
113 + handler = DummyHandler.get_instance(socketio, threading.RLock(), results)
114 + manager.register_handlers({NAMESPACE: [handler]})
115 + await manager.handle_connect(NAMESPACE, "sid-1")
116 +
117 + response = await manager.route_event(NAMESPACE, "dummy", {"foo": "bar"}, "sid-1")
118 +
119 + assert results[0]["sid"] == "sid-1"
120 + assert results[0]["data"]["foo"] == "bar"
121 + assert "correlationId" in results[0]["data"]
122 +
123 + assert isinstance(response, dict)
124 + assert "correlationId" in response
125 + assert isinstance(response["results"], list)
126 + entry = response["results"][0]
127 + assert entry["ok"] is True
128 + assert entry["data"]["sid"] == "sid-1"
129 + assert entry["data"]["data"]["foo"] == "bar"
130 +
131 +
132 +@pytest.mark.asyncio
133 +async def test_route_event_no_handler_returns_standard_error():
134 + socketio = FakeSocketIOServer()
135 + manager = WebSocketManager(socketio, threading.RLock())
136 + await manager.handle_connect(NAMESPACE, "sid-1")
137 +
138 + response = await manager.route_event(NAMESPACE, "missing", {}, "sid-1")
139 +
140 + assert len(response["results"]) == 1
141 + result = response["results"][0]
142 + assert result["handlerId"].endswith("WebSocketManager")
143 + assert result["ok"] is False
144 + assert result["error"]["code"] == "NO_HANDLERS"
145 + assert (
146 + result["error"]["error"]
147 + == f"No handler for namespace '{NAMESPACE}' event 'missing'"
148 + )
149 +
150 +
151 +@pytest.mark.asyncio
152 +async def test_route_event_all_returns_empty_when_no_connections():
153 + socketio = FakeSocketIOServer()
154 + manager = WebSocketManager(socketio, threading.RLock())
155 +
156 + results = await manager.route_event_all(NAMESPACE, "event", {}, timeout_ms=1000)
157 +
158 + assert results == []
159 +
160 +
161 +@pytest.mark.asyncio
162 +async def test_route_event_all_aggregates_results():
163 + socketio = FakeSocketIOServer()
164 + manager = WebSocketManager(socketio, threading.RLock())
165 +
166 + class EchoHandler(WebSocketHandler):
167 + @classmethod
168 + def get_event_types(cls) -> list[str]:
169 + return ["multi"]
170 +
171 + async def process_event(self, event_type: str, data: dict[str, Any], sid: str):
172 + return {"sid": sid, "echo": data}
173 +
174 + EchoHandler._reset_instance_for_testing()
175 + handler = EchoHandler.get_instance(socketio, threading.RLock())
176 + manager.register_handlers({NAMESPACE: [handler]})
177 +
178 + await manager.handle_connect(NAMESPACE, "sid-1")
179 + await manager.handle_connect(NAMESPACE, "sid-2")
180 +
181 + aggregated = await manager.route_event_all(
182 + NAMESPACE, "multi", {"value": 42}, timeout_ms=1000
183 + )
184 +
185 + assert len(aggregated) == 2
186 + by_sid = {entry["sid"]: entry for entry in aggregated}
187 + assert by_sid["sid-1"]["results"][0]["ok"] is True
188 + payload_sid1 = by_sid["sid-1"]["results"][0]["data"]
189 + assert payload_sid1["sid"] == "sid-1"
190 + assert payload_sid1["echo"]["value"] == 42
191 + assert "correlationId" in payload_sid1["echo"]
192 + assert by_sid["sid-2"]["results"][0]["ok"] is True
193 + payload_sid2 = by_sid["sid-2"]["results"][0]["data"]
194 + assert payload_sid2["sid"] == "sid-2"
195 + assert payload_sid2["echo"]["value"] == 42
196 + assert by_sid["sid-1"]["correlationId"]
197 +
198 +
199 +@pytest.mark.asyncio
200 +async def test_route_event_all_timeout_marks_error():
201 + socketio = FakeSocketIOServer()
202 + manager = WebSocketManager(socketio, threading.RLock())
203 +
204 + class SlowHandler(WebSocketHandler):
205 + @classmethod
206 + def get_event_types(cls) -> list[str]:
207 + return ["slow"]
208 +
209 + async def process_event(self, event_type: str, data: dict[str, Any], sid: str):
210 + await asyncio.sleep(0.2)
211 + return {"status": "done"}
212 +
213 + SlowHandler._reset_instance_for_testing()
214 + handler = SlowHandler.get_instance(socketio, threading.RLock())
215 + manager.register_handlers({NAMESPACE: [handler]})
216 + await manager.handle_connect(NAMESPACE, "sid-1")
217 +
218 + aggregated = await manager.route_event_all(NAMESPACE, "slow", {}, timeout_ms=50)
219 +
220 + assert len(aggregated) == 1
221 + first_entry = aggregated[0]
222 + result = first_entry["results"][0]
223 + assert result["ok"] is False
224 + assert result["error"] == {"code": "TIMEOUT", "error": "Request timeout"}
225 + assert first_entry["correlationId"]
226 +
227 +
228 +@pytest.mark.asyncio
229 +async def test_route_event_exception_standardizes_error_payload():
230 + socketio = FakeSocketIOServer()
231 + manager = WebSocketManager(socketio, threading.RLock())
232 +
233 + class FailingHandler(WebSocketHandler):
234 + @classmethod
235 + def get_event_types(cls) -> list[str]:
236 + return ["boom"]
237 +
238 + async def process_event(self, event_type: str, data: dict[str, Any], sid: str):
239 + raise RuntimeError("kaboom")
240 +
241 + FailingHandler._reset_instance_for_testing()
242 + handler = FailingHandler.get_instance(socketio, threading.RLock())
243 + manager.register_handlers({NAMESPACE: [handler]})
244 + await manager.handle_connect(NAMESPACE, "sid-1")
245 +
246 + response = await manager.route_event(NAMESPACE, "boom", {}, "sid-1")
247 +
248 + assert len(response["results"]) == 1
249 + result = response["results"][0]
250 + assert result["handlerId"].endswith("FailingHandler")
251 + assert result["ok"] is False
252 + assert result["error"]["code"] == "HANDLER_ERROR"
253 + assert result["error"]["error"] == "Internal server error"
254 + assert "details" in result["error"]
255 +
256 +
257 +@pytest.mark.asyncio
258 +async def test_route_event_offloads_blocking_handlers():
259 + socketio = FakeSocketIOServer()
260 + manager = WebSocketManager(socketio, threading.RLock())
261 +
262 + class BlockingHandler(WebSocketHandler):
263 + @classmethod
264 + def get_event_types(cls) -> list[str]:
265 + return ["block"]
266 +
267 + async def process_event(self, event_type: str, data: dict[str, Any], sid: str):
268 + time.sleep(0.2)
269 + return {"status": "done"}
270 +
271 + BlockingHandler._reset_instance_for_testing()
272 + handler = BlockingHandler.get_instance(socketio, threading.RLock())
273 + manager.register_handlers({NAMESPACE: [handler]})
274 + await manager.handle_connect(NAMESPACE, "sid-1")
275 +
276 + route_task = asyncio.create_task(
277 + manager.route_event(NAMESPACE, "block", {}, "sid-1")
278 + )
279 + await asyncio.sleep(0)
280 +
281 + t0 = time.perf_counter()
282 + await asyncio.sleep(0.05)
283 + elapsed = time.perf_counter() - t0
284 + assert elapsed < 0.15
285 +
286 + response = await route_task
287 + assert response["results"]
288 +
289 +
290 +@pytest.mark.asyncio
291 +async def test_route_event_unwraps_ts_data_envelope_and_preserves_correlation_id():
292 + socketio = FakeSocketIOServer()
293 + manager = WebSocketManager(socketio, threading.RLock())
294 +
295 + results: list[dict[str, Any]] = []
296 + DummyHandler._reset_instance_for_testing()
297 + handler = DummyHandler.get_instance(socketio, threading.RLock(), results)
298 + manager.register_handlers({NAMESPACE: [handler]})
299 + await manager.handle_connect(NAMESPACE, "sid-1")
300 +
301 + response = await manager.route_event(
302 + NAMESPACE,
303 + "dummy",
304 + {
305 + "correlationId": "client-1",
306 + "ts": "2025-10-29T12:00:00.000Z",
307 + "data": {"value": 123},
308 + },
309 + "sid-1",
310 + )
311 +
312 + assert response["correlationId"] == "client-1"
313 + assert len(results) == 1
314 + handler_payload = results[0]["data"]
315 + assert handler_payload["value"] == 123
316 + assert handler_payload["correlationId"] == "client-1"
317 + assert "ts" not in handler_payload
318 + assert "data" not in handler_payload
319 +
320 +
321 +@pytest.mark.asyncio
322 +async def test_emit_to_unknown_sid_raises_error():
323 + socketio = FakeSocketIOServer()
324 + manager = WebSocketManager(socketio, threading.RLock())
325 +
326 + with pytest.raises(ConnectionNotFoundError):
327 + await manager.emit_to(NAMESPACE, "unknown", "event", {})
328 +
329 +
330 +@pytest.mark.asyncio
331 +async def test_emit_to_known_disconnected_sid_buffers():
332 + socketio = FakeSocketIOServer()
333 + manager = WebSocketManager(socketio, threading.RLock())
334 + await manager.handle_connect(NAMESPACE, "sid-1")
335 + await manager.handle_disconnect(NAMESPACE, "sid-1")
336 +
337 + await manager.emit_to(
338 + NAMESPACE, "sid-1", "event", {"a": 1}, correlation_id="corr-1"
339 + )
340 +
341 + assert (NAMESPACE, "sid-1") in manager.buffers
342 + buffered = list(manager.buffers[(NAMESPACE, "sid-1")])
343 + assert len(buffered) == 1
344 + assert buffered[0].event_type == "event"
345 + assert buffered[0].data == {"a": 1}
346 + assert buffered[0].correlation_id == "corr-1"
347 +
348 +
349 +@pytest.mark.asyncio
350 +async def test_buffer_overflow_drops_oldest(monkeypatch):
351 + socketio = FakeSocketIOServer()
352 + manager = WebSocketManager(socketio, threading.RLock())
353 +
354 + await manager.handle_connect(NAMESPACE, "offline")
355 + await manager.handle_disconnect(NAMESPACE, "offline")
356 +
357 + monkeypatch.setattr("python.helpers.websocket_manager.BUFFER_MAX_SIZE", 2)
358 +
359 + await manager.emit_to(NAMESPACE, "offline", "event", {"idx": 0})
360 + await manager.emit_to(NAMESPACE, "offline", "event", {"idx": 1})
361 + await manager.emit_to(NAMESPACE, "offline", "event", {"idx": 2})
362 +
363 + buffer = manager.buffers[(NAMESPACE, "offline")]
364 + assert len(buffer) == 2
365 + assert buffer[0].data["idx"] == 1
366 + assert buffer[1].data["idx"] == 2
367 +
368 +
369 +@pytest.mark.asyncio
370 +async def test_expired_buffer_entries_are_discarded(monkeypatch):
371 + socketio = FakeSocketIOServer()
372 + manager = WebSocketManager(socketio, threading.RLock())
373 +
374 + await manager.handle_connect(NAMESPACE, "sid-expired")
375 + await manager.handle_disconnect(NAMESPACE, "sid-expired")
376 +
377 + from datetime import timedelta, timezone, datetime
378 +
379 + past = datetime.now(timezone.utc) - (BUFFER_TTL + timedelta(seconds=5))
380 + future = past + BUFFER_TTL + timedelta(seconds=10)
381 +
382 + await manager.emit_to(NAMESPACE, "sid-expired", "event", {"a": 1})
383 + manager.buffers[(NAMESPACE, "sid-expired")][0].timestamp = past
384 +
385 + socketio.emit.reset_mock()
386 +
387 + monkeypatch.setattr(
388 + "python.helpers.websocket_manager._utcnow",
389 + lambda: future,
390 + )
391 + await manager.handle_connect(NAMESPACE, "sid-expired")
392 +
393 + assert socketio.emit.await_count == 0
394 + assert (NAMESPACE, "sid-expired") not in manager.buffers
395 +
396 +
397 +@pytest.mark.asyncio
398 +async def test_flush_buffer_delivers_and_logs(monkeypatch):
399 + socketio = FakeSocketIOServer()
400 + manager = WebSocketManager(socketio, threading.RLock())
401 + await manager.handle_connect(NAMESPACE, "sid-1")
402 + await manager.handle_disconnect(NAMESPACE, "sid-1")
403 +
404 + await manager.emit_to(NAMESPACE, "sid-1", "event", {"a": 1})
405 +
406 + await manager.handle_connect(NAMESPACE, "sid-1")
407 +
408 + assert len(socketio.emit.await_args_list) == 1
409 + awaited_call = socketio.emit.await_args_list[0]
410 + assert awaited_call.args[0] == "event"
411 + envelope = awaited_call.args[1]
412 + assert envelope["data"] == {"a": 1}
413 + assert "eventId" in envelope and "handlerId" in envelope and "ts" in envelope
414 + assert awaited_call.kwargs == {"to": "sid-1", "namespace": NAMESPACE}
415 + assert (NAMESPACE, "sid-1") not in manager.buffers
416 +
417 +
418 +@pytest.mark.asyncio
419 +async def test_broadcast_excludes_multiple_sids():
420 + socketio = FakeSocketIOServer()
421 + manager = WebSocketManager(socketio, threading.RLock())
422 +
423 + for sid in ("sid-1", "sid-2", "sid-3"):
424 + await manager.handle_connect(NAMESPACE, sid)
425 +
426 + await manager.broadcast(
427 + NAMESPACE,
428 + "event",
429 + {"foo": "bar"},
430 + exclude_sids={"sid-1", "sid-3"},
431 + handler_id="custom.broadcast",
432 + correlation_id="corr-b",
433 + )
434 +
435 + assert len(socketio.emit.await_args_list) == 1
436 + awaited_call = socketio.emit.await_args_list[0]
437 + assert awaited_call.args[0] == "event"
438 + envelope = awaited_call.args[1]
439 + assert envelope["data"] == {"foo": "bar"}
440 + assert envelope["handlerId"] == "custom.broadcast"
441 + assert envelope["correlationId"] == "corr-b"
442 + assert "eventId" in envelope and "ts" in envelope
443 + assert awaited_call.kwargs == {"to": "sid-2", "namespace": NAMESPACE}
444 +
445 +
446 +@pytest.mark.asyncio
447 +async def test_emit_to_wraps_envelope_with_metadata():
448 + socketio = FakeSocketIOServer()
449 + manager = WebSocketManager(socketio, threading.RLock())
450 + await manager.handle_connect(NAMESPACE, "sid-meta")
451 +
452 + await manager.emit_to(
453 + NAMESPACE,
454 + "sid-meta",
455 + "meta_event",
456 + {"payload": True},
457 + handler_id="custom.handler",
458 + correlation_id="corr-meta",
459 + )
460 +
461 + socketio.emit.assert_awaited_once()
462 + args, kwargs = socketio.emit.await_args_list[0]
463 + assert args[0] == "meta_event"
464 + envelope = args[1]
465 + assert envelope["handlerId"] == "custom.handler"
466 + assert envelope["correlationId"] == "corr-meta"
467 + assert envelope["data"] == {"payload": True}
468 + assert kwargs == {"to": "sid-meta", "namespace": NAMESPACE}
469 +
470 +
471 +@pytest.mark.asyncio
472 +async def test_timestamps_are_timezone_aware():
473 + socketio = FakeSocketIOServer()
474 + manager = WebSocketManager(socketio, threading.RLock())
475 +
476 + await manager.handle_connect(NAMESPACE, "sid-utc")
477 + info = manager.connections[(NAMESPACE, "sid-utc")]
478 +
479 + assert info.connected_at.tzinfo is not None
480 + assert info.last_activity.tzinfo is not None
481 +
482 + with patch("python.helpers.websocket_manager._utcnow") as mocked_now:
483 + mocked_now.return_value = info.last_activity
484 + await manager.route_event(NAMESPACE, "unknown", {}, "sid-utc")
485 + assert info.last_activity.tzinfo is not None
486 +
487 +class DuplicateHandler(WebSocketHandler):
488 + @classmethod
489 + def get_event_types(cls) -> list[str]:
490 + return ["dup_event"]
491 +
492 + async def process_event(self, event_type: str, data: dict[str, Any], sid: str):
493 + return {"handledBy": self.identifier}
494 +
495 +
496 +class AnotherDuplicateHandler(WebSocketHandler):
497 + @classmethod
498 + def get_event_types(cls) -> list[str]:
499 + return ["dup_event"]
500 +
501 + async def process_event(self, event_type: str, data: dict[str, Any], sid: str):
502 + return {"handledBy": self.identifier}
503 +
504 +
505 +def test_register_handlers_warns_on_duplicates(monkeypatch):
506 + socketio = FakeSocketIOServer()
507 + manager = WebSocketManager(socketio, threading.RLock())
508 +
509 + warnings: list[str] = []
510 +
511 + def capture_warning(message: str) -> None:
512 + warnings.append(message)
513 +
514 + monkeypatch.setattr(
515 + "python.helpers.print_style.PrintStyle.warning", staticmethod(capture_warning)
516 + )
517 +
518 + DuplicateHandler._reset_instance_for_testing()
519 + AnotherDuplicateHandler._reset_instance_for_testing()
520 + handler_a = DuplicateHandler.get_instance(socketio, threading.RLock())
521 + handler_b = AnotherDuplicateHandler.get_instance(socketio, threading.RLock())
522 +
523 + manager.register_handlers({NAMESPACE: [handler_a, handler_b]})
524 +
525 + assert any("Duplicate handler registration" in msg for msg in warnings)
526 +
527 +
528 +class NonDictHandler(WebSocketHandler):
529 + @classmethod
530 + def get_event_types(cls) -> list[str]:
531 + return ["non_dict"]
532 +
533 + async def process_event(self, event_type: str, data: dict[str, Any], sid: str):
534 + return "raw-value"
535 +
536 +
537 +@pytest.mark.asyncio
538 +async def test_route_event_standardizes_success_payload():
539 + socketio = FakeSocketIOServer()
540 + manager = WebSocketManager(socketio, threading.RLock())
541 +
542 + NonDictHandler._reset_instance_for_testing()
543 + handler = NonDictHandler.get_instance(socketio, threading.RLock())
544 + manager.register_handlers({NAMESPACE: [handler]})
545 +
546 + response = await manager.route_event(NAMESPACE, "non_dict", {}, "sid-123")
547 +
548 + assert len(response["results"]) == 1
549 + assert response["results"][0]["ok"] is True
550 + assert response["results"][0]["data"] == {"result": "raw-value"}
551 +
552 +
553 +class ErrorHandler(WebSocketHandler):
554 + @classmethod
555 + def get_event_types(cls) -> list[str]:
556 + return ["boom"]
557 +
558 + async def process_event(self, event_type: str, data: dict[str, Any], sid: str):
559 + raise RuntimeError("BOOM")
560 +
561 +
562 +class ResultHandler(WebSocketHandler):
563 + @classmethod
564 + def get_event_types(cls) -> list[str]: # pragma: no cover - simple declaration
565 + return ["result_event", "result_error"]
566 +
567 + async def process_event(self, event_type: str, data: dict[str, Any], sid: str):
568 + if event_type == "result_event":
569 + return WebSocketResult.ok({"sid": sid}, correlation_id="explicit", duration_ms=1.234)
570 + return WebSocketResult.error(
571 + code="E_RESULT",
572 + message="boom",
573 + details="test",
574 + )
575 +
576 +
577 +@pytest.mark.asyncio
578 +async def test_route_event_standardizes_error_payload():
579 + socketio = FakeSocketIOServer()
580 + manager = WebSocketManager(socketio, threading.RLock())
581 +
582 + ErrorHandler._reset_instance_for_testing()
583 + handler = ErrorHandler.get_instance(socketio, threading.RLock())
584 + manager.register_handlers({NAMESPACE: [handler]})
585 +
586 + response = await manager.route_event(NAMESPACE, "boom", {}, "sid-123")
587 +
588 + assert len(response["results"]) == 1
589 + payload = response["results"][0]
590 + assert payload["ok"] is False
591 + assert payload["error"]["code"] == "HANDLER_ERROR"
592 + assert payload["error"]["error"] == "Internal server error"
593 +
594 +
595 +@pytest.mark.asyncio
596 +async def test_route_event_accepts_websocket_result_instances():
597 + socketio = FakeSocketIOServer()
598 + manager = WebSocketManager(socketio, threading.RLock())
599 +
600 + ResultHandler._reset_instance_for_testing()
601 + handler = ResultHandler.get_instance(socketio, threading.RLock())
602 + manager.register_handlers({NAMESPACE: [handler]})
603 +
604 + response = await manager.route_event(NAMESPACE, "result_event", {}, "sid-123")
605 +
606 + assert response["results"]
607 + payload = response["results"][0]
608 + assert payload["ok"] is True
609 + assert payload["data"] == {"sid": "sid-123"}
610 + assert payload["correlationId"] == "explicit"
611 + assert payload["durationMs"] == pytest.approx(1.234, rel=1e-3)
612 +
613 +
614 +@pytest.mark.asyncio
615 +async def test_route_event_preserves_websocket_result_errors():
616 + socketio = FakeSocketIOServer()
617 + manager = WebSocketManager(socketio, threading.RLock())
618 +
619 + ResultHandler._reset_instance_for_testing()
620 + handler = ResultHandler.get_instance(socketio, threading.RLock())
621 + manager.register_handlers({NAMESPACE: [handler]})
622 +
623 + response = await manager.route_event(NAMESPACE, "result_error", {}, "sid-123")
624 +
625 + payload = response["results"][0]
626 + assert payload["ok"] is False
627 + assert payload["error"] == {"code": "E_RESULT", "error": "boom", "details": "test"}
628 +
629 +
630 +class AlphaFilterHandler(WebSocketHandler):
631 + @classmethod
632 + def get_event_types(cls) -> list[str]:
633 + return ["filter_event"]
634 +
635 + async def process_event(self, event_type: str, data: dict[str, Any], sid: str):
636 + return {"handledBy": self.identifier, "sid": sid}
637 +
638 +
639 +class BetaFilterHandler(WebSocketHandler):
640 + @classmethod
641 + def get_event_types(cls) -> list[str]:
642 + return ["filter_event"]
643 +
644 + async def process_event(self, event_type: str, data: dict[str, Any], sid: str):
645 + return {"handledBy": self.identifier, "sid": sid}
646 +
647 +
648 +@pytest.mark.asyncio
649 +async def test_route_event_include_handlers_filters_results():
650 + socketio = FakeSocketIOServer()
651 + manager = WebSocketManager(socketio, threading.RLock())
652 +
653 + AlphaFilterHandler._reset_instance_for_testing()
654 + BetaFilterHandler._reset_instance_for_testing()
655 + alpha = AlphaFilterHandler.get_instance(socketio, threading.RLock())
656 + beta = BetaFilterHandler.get_instance(socketio, threading.RLock())
657 + manager.register_handlers({NAMESPACE: [alpha, beta]})
658 + await manager.handle_connect(NAMESPACE, "sid-filter")
659 +
660 + response = await manager.route_event(
661 + NAMESPACE,
662 + "filter_event",
663 + {
664 + "includeHandlers": [alpha.identifier],
665 + "payload": True,
666 + },
667 + "sid-filter",
668 + )
669 +
670 + assert response["correlationId"]
671 + results = response["results"]
672 + assert len(results) == 1
673 + assert results[0]["handlerId"] == alpha.identifier
674 + assert results[0]["data"]["handledBy"] == alpha.identifier
675 +
676 +
677 +@pytest.mark.asyncio
678 +async def test_route_event_rejects_exclude_handlers_without_permission():
679 + socketio = FakeSocketIOServer()
680 + manager = WebSocketManager(socketio, threading.RLock())
681 +
682 + AlphaFilterHandler._reset_instance_for_testing()
683 + handler = AlphaFilterHandler.get_instance(socketio, threading.RLock())
684 + manager.register_handlers({NAMESPACE: [handler]})
685 + await manager.handle_connect(NAMESPACE, "sid-exclude")
686 +
687 + response = await manager.route_event(
688 + NAMESPACE,
689 + "filter_event",
690 + {"excludeHandlers": [handler.identifier]},
691 + "sid-exclude",
692 + )
693 +
694 + result = response["results"][0]
695 + assert result["error"]["code"] == "INVALID_FILTER"
696 + assert "excludeHandlers" in result["error"]["error"]
697 +
698 +
699 +@pytest.mark.asyncio
700 +async def test_route_event_all_respects_exclude_handlers():
701 + socketio = FakeSocketIOServer()
702 + manager = WebSocketManager(socketio, threading.RLock())
703 +
704 + AlphaFilterHandler._reset_instance_for_testing()
705 + BetaFilterHandler._reset_instance_for_testing()
706 + alpha = AlphaFilterHandler.get_instance(socketio, threading.RLock())
707 + beta = BetaFilterHandler.get_instance(socketio, threading.RLock())
708 + manager.register_handlers({NAMESPACE: [alpha, beta]})
709 +
710 + await manager.handle_connect(NAMESPACE, "sid-a")
711 + await manager.handle_connect(NAMESPACE, "sid-b")
712 +
713 + aggregated = await manager.route_event_all(
714 + NAMESPACE,
715 + "filter_event",
716 + {"excludeHandlers": [beta.identifier]},
717 + handler_id="test.manager",
718 + )
719 +
720 + assert aggregated
721 + for entry in aggregated:
722 + assert entry["correlationId"]
723 + assert entry["results"]
724 + assert all(result["handlerId"] == alpha.identifier for result in entry["results"])
725 +
726 +
727 +@pytest.mark.asyncio
728 +async def test_route_event_preserves_correlation_id():
729 + socketio = FakeSocketIOServer()
730 + manager = WebSocketManager(socketio, threading.RLock())
731 +
732 + results = []
733 + DummyHandler._reset_instance_for_testing()
734 + handler = DummyHandler.get_instance(socketio, threading.RLock(), results)
735 + manager.register_handlers({NAMESPACE: [handler]})
736 + await manager.handle_connect(NAMESPACE, "sid-correlation")
737 +
738 + response = await manager.route_event(
739 + NAMESPACE,
740 + "dummy",
741 + {"foo": "bar", "correlationId": "manual-correlation"},
742 + "sid-correlation",
743 + )
744 +
745 + assert response["correlationId"] == "manual-correlation"
746 + result = response["results"][0]
747 + assert result["correlationId"] == "manual-correlation"
748 +
749 +
750 +@pytest.mark.asyncio
751 +async def test_request_preserves_explicit_correlation_id():
752 + socketio = FakeSocketIOServer()
753 + manager = WebSocketManager(socketio, threading.RLock())
754 +
755 + DummyHandler._reset_instance_for_testing()
756 + handler = DummyHandler.get_instance(socketio, threading.RLock(), [])
757 + manager.register_handlers({NAMESPACE: [handler]})
758 + await manager.handle_connect(NAMESPACE, "sid-request")
759 +
760 + response = await manager.request_for_sid(
761 + namespace=NAMESPACE,
762 + sid="sid-request",
763 + event_type="dummy",
764 + data={"payload": True, "correlationId": "req-correlation"},
765 + handler_id="tester",
766 + )
767 +
768 + assert response["correlationId"] == "req-correlation"
769 + result = response["results"][0]
770 + assert result["correlationId"] == "req-correlation"
771 +
772 +
773 +@pytest.mark.asyncio
774 +async def test_request_all_entries_include_correlation_id():
775 + socketio = FakeSocketIOServer()
776 + manager = WebSocketManager(socketio, threading.RLock())
777 +
778 + DummyHandler._reset_instance_for_testing()
779 + handler = DummyHandler.get_instance(socketio, threading.RLock(), [])
780 + manager.register_handlers({NAMESPACE: [handler]})
781 +
782 + await manager.handle_connect(NAMESPACE, "sid-1")
783 + await manager.handle_connect(NAMESPACE, "sid-2")
784 +
785 + aggregated = await manager.route_event_all(
786 + NAMESPACE,
787 + "dummy",
788 + {"value": 1, "correlationId": "agg-correlation"},
789 + )
790 +
791 + assert aggregated
792 + for entry in aggregated:
793 + assert entry["correlationId"] == "agg-correlation"
794 + assert entry["results"]
795 + assert entry["results"][0]["correlationId"] == "agg-correlation"
796 +
797 +
798 +def test_debug_logging_respects_runtime_flag(monkeypatch):
799 + socketio = FakeSocketIOServer()
800 + manager = WebSocketManager(socketio, threading.RLock())
801 +
802 + logs: list[str] = []
803 +
804 + def capture(message: str) -> None:
805 + logs.append(message)
806 +
807 + monkeypatch.setattr("python.helpers.print_style.PrintStyle.debug", staticmethod(capture))
808 + monkeypatch.setattr("python.helpers.websocket_manager.runtime.is_development", lambda: False)
809 +
810 + manager._debug("should-not-log") # noqa: SLF001
811 + assert logs == []
812 +
813 + monkeypatch.setattr("python.helpers.websocket_manager.runtime.is_development", lambda: True)
814 + manager._debug("should-log") # noqa: SLF001
815 + assert logs == ["should-log"]
816 +
817 +
818 +@pytest.mark.asyncio
819 +async def test_diagnostic_event_emitted_for_inbound():
820 + socketio = FakeSocketIOServer()
821 + manager = WebSocketManager(socketio, threading.RLock())
822 +
823 + results: list[dict[str, Any]] = []
824 + DummyHandler._reset_instance_for_testing()
825 + handler = DummyHandler.get_instance(socketio, threading.RLock(), results)
826 + manager.register_handlers({NAMESPACE: [handler]})
827 +
828 + await manager.handle_connect(NAMESPACE, "observer")
829 + assert manager.register_diagnostic_watcher(NAMESPACE, "observer") is True
830 + await manager.handle_connect(NAMESPACE, "sid-client")
831 +
832 + await manager.route_event(NAMESPACE, "dummy", {"payload": "value"}, "sid-client")
833 +
834 + emitted_events = [call.args[0] for call in socketio.emit.await_args_list]
835 + assert DIAGNOSTIC_EVENT in emitted_events
836 +
837 +
838 +@pytest.mark.asyncio
839 +async def test_lifecycle_events_broadcast(monkeypatch):
840 + socketio = FakeSocketIOServer()
841 + manager = WebSocketManager(socketio, threading.RLock())
842 +
843 + broadcast_mock = AsyncMock()
844 + monkeypatch.setattr(manager, "broadcast", broadcast_mock)
845 +
846 + await manager.handle_connect(NAMESPACE, "sid-life")
847 + await asyncio.sleep(0)
848 + await manager.handle_disconnect(NAMESPACE, "sid-life")
849 + await asyncio.sleep(0)
850 +
851 + events = [call.args[1] for call in broadcast_mock.await_args_list]
852 + assert LIFECYCLE_CONNECT_EVENT in events
853 + assert LIFECYCLE_DISCONNECT_EVENT in events
tests/test_websocket_namespace_discovery.py new
+225
@@ -0,0 +1,225 @@
1 +import asyncio
2 +import contextlib
3 +import socket
4 +import sys
5 +from pathlib import Path
6 +from typing import Any, AsyncIterator
7 +
8 +import pytest
9 +
10 +PROJECT_ROOT = Path(__file__).resolve().parents[1]
11 +if str(PROJECT_ROOT) not in sys.path:
12 + sys.path.insert(0, str(PROJECT_ROOT))
13 +
14 +
15 +@contextlib.asynccontextmanager
16 +async def _run_asgi_app(app: Any) -> AsyncIterator[str]:
17 + import uvicorn
18 +
19 + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
20 + sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
21 + sock.bind(("127.0.0.1", 0))
22 + sock.listen(128)
23 +
24 + port = sock.getsockname()[1]
25 +
26 + config = uvicorn.Config(
27 + app,
28 + host="127.0.0.1",
29 + port=port,
30 + log_level="warning",
31 + access_log=False,
32 + lifespan="off",
33 + )
34 + server = uvicorn.Server(config)
35 + server.install_signal_handlers = lambda: None # type: ignore[method-assign]
36 +
37 + task = asyncio.create_task(server.serve(sockets=[sock]))
38 + try:
39 + while not server.started:
40 + await asyncio.sleep(0.01)
41 + yield f"http://127.0.0.1:{port}"
42 + finally:
43 + server.should_exit = True
44 + try:
45 + await asyncio.wait_for(task, timeout=5)
46 + finally:
47 + sock.close()
48 +
49 +
50 +def _write_handler_module(path: Path, class_name: str, event_type: str) -> None:
51 + path.write_text(
52 + "\n".join(
53 + [
54 + "from __future__ import annotations",
55 + "",
56 + "from typing import Any",
57 + "",
58 + "from python.helpers.websocket import WebSocketHandler",
59 + "",
60 + f"class {class_name}(WebSocketHandler):",
61 + " @classmethod",
62 + " def requires_auth(cls) -> bool:",
63 + " return False",
64 + "",
65 + " @classmethod",
66 + " def requires_csrf(cls) -> bool:",
67 + " return False",
68 + "",
69 + " @classmethod",
70 + " def get_event_types(cls) -> list[str]:",
71 + f" return ['{event_type}']",
72 + "",
73 + " async def process_event(self, event_type: str, data: dict[str, Any], sid: str):",
74 + " return {'ok': True}",
75 + "",
76 + ]
77 + ),
78 + encoding="utf-8",
79 + )
80 +
81 +
82 +def test_discovery_supports_folder_entries_and_ignores_deeper_nesting(tmp_path: Path) -> None:
83 + from python.helpers.websocket_namespace_discovery import discover_websocket_namespaces
84 +
85 + folder = tmp_path / "orders"
86 + folder.mkdir()
87 + _write_handler_module(folder / "orders.py", "OrdersHandler", "orders_request")
88 +
89 + # Deeper nesting must be ignored (and must not be imported).
90 + nested = folder / "nested"
91 + nested.mkdir()
92 + (nested / "boom.py").write_text("raise RuntimeError('should-not-import')\n", encoding="utf-8")
93 +
94 + discoveries = discover_websocket_namespaces(handlers_folder=str(tmp_path), include_root_default=False)
95 + by_ns = {d.namespace: d for d in discoveries}
96 +
97 + assert "/orders" in by_ns
98 + entry = by_ns["/orders"]
99 + assert [cls.__name__ for cls in entry.handler_classes] == ["OrdersHandler"]
100 +
101 +
102 +def test_discovery_folder_suffix_handler_stripped(tmp_path: Path) -> None:
103 + from python.helpers.websocket_namespace_discovery import discover_websocket_namespaces
104 +
105 + folder = tmp_path / "sales_handler"
106 + folder.mkdir()
107 + _write_handler_module(folder / "main.py", "SalesHandler", "sales_request")
108 +
109 + discoveries = discover_websocket_namespaces(handlers_folder=str(tmp_path), include_root_default=False)
110 + namespaces = {d.namespace for d in discoveries}
111 + assert "/sales" in namespaces
112 +
113 +
114 +def test_discovery_empty_folder_warns_and_treats_namespace_unregistered(tmp_path: Path, monkeypatch) -> None:
115 + from flask import Flask
116 + import socketio
117 +
118 + from python.helpers.websocket_manager import WebSocketManager
119 + from python.helpers.websocket_namespace_discovery import discover_websocket_namespaces
120 + from run_ui import configure_websocket_namespaces
121 +
122 + empty = tmp_path / "empty"
123 + empty.mkdir()
124 + (empty / "__init__.py").write_text("# init\n", encoding="utf-8")
125 +
126 + warnings: list[str] = []
127 +
128 + def _warn(message: str) -> None:
129 + warnings.append(message)
130 +
131 + monkeypatch.setattr("python.helpers.print_style.PrintStyle.warning", staticmethod(_warn))
132 +
133 + discoveries = discover_websocket_namespaces(handlers_folder=str(tmp_path), include_root_default=False)
134 + assert "/empty" not in {d.namespace for d in discoveries}
135 + assert any("empty" in msg.lower() for msg in warnings)
136 +
137 + # Integration check: treat as unregistered -> UNKNOWN_NAMESPACE connect_error.
138 + app = Flask("test_empty_folder_unregistered")
139 + app.secret_key = "test-secret"
140 + sio = socketio.AsyncServer(async_mode="asgi", cors_allowed_origins="*", namespaces="*")
141 + lock = __import__("threading").RLock()
142 + manager = WebSocketManager(sio, lock)
143 +
144 + handlers_by_namespace: dict[str, list[Any]] = {}
145 + for discovery in discoveries:
146 + handlers_by_namespace[discovery.namespace] = [
147 + cls.get_instance(sio, lock) for cls in discovery.handler_classes
148 + ]
149 +
150 + configure_websocket_namespaces(
151 + webapp=app,
152 + socketio_server=sio,
153 + websocket_manager=manager,
154 + handlers_by_namespace=handlers_by_namespace,
155 + )
156 +
157 + asgi_app = socketio.ASGIApp(sio)
158 + async def _run() -> None:
159 + async with _run_asgi_app(asgi_app) as base_url:
160 + client = socketio.AsyncClient()
161 + connect_error_fut: asyncio.Future[Any] = asyncio.get_running_loop().create_future()
162 +
163 + async def _on_connect_error(data: Any) -> None:
164 + if not connect_error_fut.done():
165 + connect_error_fut.set_result(data)
166 +
167 + client.on("connect_error", _on_connect_error, namespace="/empty")
168 + try:
169 + with pytest.raises(socketio.exceptions.ConnectionError):
170 + await client.connect(base_url, namespaces=["/empty"])
171 + err = await asyncio.wait_for(connect_error_fut, timeout=2)
172 + assert err["message"] == "UNKNOWN_NAMESPACE"
173 + assert err["data"]["namespace"] == "/empty"
174 + finally:
175 + try:
176 + await client.disconnect()
177 + except Exception:
178 + pass
179 +
180 + asyncio.run(_run())
181 +
182 +
183 +def test_discovery_invalid_modules_fail_fast_with_descriptive_errors(tmp_path: Path) -> None:
184 + from python.helpers.websocket_namespace_discovery import discover_websocket_namespaces
185 +
186 + # 0 handlers in a *_handler.py module
187 + (tmp_path / "bad_handler.py").write_text(
188 + "class NotAHandler:\n pass\n", encoding="utf-8"
189 + )
190 + with pytest.raises(RuntimeError) as excinfo:
191 + discover_websocket_namespaces(handlers_folder=str(tmp_path), include_root_default=False)
192 + assert "defines no WebSocketHandler subclasses" in str(excinfo.value)
193 +
194 + # 2+ handlers in a *_handler.py module
195 + tmp_path.joinpath("bad_handler.py").unlink()
196 + (tmp_path / "two_handler.py").write_text(
197 + "\n".join(
198 + [
199 + "from python.helpers.websocket import WebSocketHandler",
200 + "class A(WebSocketHandler):",
201 + " @classmethod",
202 + " def requires_auth(cls): return False",
203 + " @classmethod",
204 + " def requires_csrf(cls): return False",
205 + " @classmethod",
206 + " def get_event_types(cls): return ['two_a']",
207 + " async def process_event(self, event_type, data, sid): return {'ok': True}",
208 + "class B(WebSocketHandler):",
209 + " @classmethod",
210 + " def requires_auth(cls): return False",
211 + " @classmethod",
212 + " def requires_csrf(cls): return False",
213 + " @classmethod",
214 + " def get_event_types(cls): return ['two_b']",
215 + " async def process_event(self, event_type, data, sid): return {'ok': True}",
216 + "",
217 + ]
218 + ),
219 + encoding="utf-8",
220 + )
221 + with pytest.raises(RuntimeError) as excinfo2:
222 + discover_websocket_namespaces(handlers_folder=str(tmp_path), include_root_default=False)
223 + message = str(excinfo2.value)
224 + assert "defines multiple WebSocketHandler subclasses" in message
225 + assert "A" in message and "B" in message
tests/test_websocket_namespace_security.py new
+464
@@ -0,0 +1,464 @@
1 +import asyncio
2 +import contextlib
3 +import socket
4 +import sys
5 +import threading
6 +from pathlib import Path
7 +from typing import Any, AsyncIterator
8 +
9 +import pytest
10 +
11 +PROJECT_ROOT = Path(__file__).resolve().parents[1]
12 +if str(PROJECT_ROOT) not in sys.path:
13 + sys.path.insert(0, str(PROJECT_ROOT))
14 +
15 +
16 +@contextlib.asynccontextmanager
17 +async def _run_asgi_app(app: Any) -> AsyncIterator[str]:
18 + import uvicorn
19 +
20 + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
21 + sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
22 + sock.bind(("127.0.0.1", 0))
23 + sock.listen(128)
24 +
25 + port = sock.getsockname()[1]
26 +
27 + config = uvicorn.Config(
28 + app,
29 + host="127.0.0.1",
30 + port=port,
31 + log_level="warning",
32 + access_log=False,
33 + lifespan="off",
34 + )
35 + server = uvicorn.Server(config)
36 + server.install_signal_handlers = lambda: None # type: ignore[method-assign]
37 +
38 + task = asyncio.create_task(server.serve(sockets=[sock]))
39 + try:
40 + while not server.started:
41 + await asyncio.sleep(0.01)
42 + yield f"http://127.0.0.1:{port}"
43 + finally:
44 + server.should_exit = True
45 + try:
46 + await asyncio.wait_for(task, timeout=5)
47 + finally:
48 + sock.close()
49 +
50 +
51 +def _make_session_cookie(app: Any, data: dict[str, Any]) -> str:
52 + from flask.sessions import SecureCookieSessionInterface
53 +
54 + serializer = SecureCookieSessionInterface().get_signing_serializer(app)
55 + assert serializer is not None
56 + return serializer.dumps(data)
57 +
58 +
59 +@pytest.mark.asyncio
60 +async def test_connect_security_is_computed_per_namespace_and_enforced(monkeypatch) -> None:
61 + from flask import Flask
62 + import socketio
63 +
64 + from python.helpers.websocket import WebSocketHandler
65 + from python.helpers.websocket_manager import WebSocketManager
66 + from python.helpers import runtime
67 + from run_ui import configure_websocket_namespaces
68 +
69 + class OpenHandler(WebSocketHandler):
70 + @classmethod
71 + def requires_auth(cls) -> bool:
72 + return False
73 +
74 + @classmethod
75 + def requires_csrf(cls) -> bool:
76 + return False
77 +
78 + @classmethod
79 + def get_event_types(cls) -> list[str]:
80 + return ["open_ping"]
81 +
82 + async def process_event(self, event_type: str, data: dict[str, Any], sid: str) -> dict[str, Any]:
83 + return {"ok": True}
84 +
85 + class SecureHandler(WebSocketHandler):
86 + @classmethod
87 + def get_event_types(cls) -> list[str]:
88 + return ["secure_ping"]
89 +
90 + async def process_event(self, event_type: str, data: dict[str, Any], sid: str) -> dict[str, Any]:
91 + return {"ok": True}
92 +
93 + OpenHandler._reset_instance_for_testing()
94 + SecureHandler._reset_instance_for_testing()
95 +
96 + monkeypatch.setattr("python.helpers.login.get_credentials_hash", lambda: "hash")
97 +
98 + webapp = Flask("test_websocket_namespace_security")
99 + webapp.secret_key = "test-secret"
100 +
101 + sio = socketio.AsyncServer(async_mode="asgi", cors_allowed_origins="*", namespaces="*")
102 + lock = threading.RLock()
103 + manager = WebSocketManager(sio, lock)
104 + handlers_by_namespace = {
105 + "/open": [OpenHandler.get_instance(sio, lock)],
106 + "/secure": [SecureHandler.get_instance(sio, lock)],
107 + }
108 +
109 + configure_websocket_namespaces(
110 + webapp=webapp,
111 + socketio_server=sio,
112 + websocket_manager=manager,
113 + handlers_by_namespace=handlers_by_namespace,
114 + )
115 +
116 + asgi_app = socketio.ASGIApp(sio)
117 +
118 + async with _run_asgi_app(asgi_app) as base_url:
119 + # Open namespace should not require auth/csrf (but Origin validation is always enforced).
120 + open_client = socketio.AsyncClient()
121 + await open_client.connect(
122 + base_url,
123 + namespaces=["/open"],
124 + headers={"Origin": base_url},
125 + wait_timeout=2,
126 + )
127 + try:
128 + res = await open_client.call("open_ping", {}, namespace="/open", timeout=2)
129 + assert isinstance(res, dict)
130 + assert res.get("results")
131 + res_unhandled = await open_client.call("unhandled_event", {"x": 1}, namespace="/open", timeout=2)
132 + assert res_unhandled["results"]
133 + assert res_unhandled["results"][0]["ok"] is False
134 + assert res_unhandled["results"][0]["error"]["code"] == "NO_HANDLERS"
135 + finally:
136 + await open_client.disconnect()
137 +
138 + # Secure namespace rejects without valid session+csrf when credentials are configured.
139 + secure_client = socketio.AsyncClient()
140 + with pytest.raises(socketio.exceptions.ConnectionError):
141 + await secure_client.connect(
142 + base_url,
143 + namespaces=["/secure"],
144 + headers={"Origin": base_url},
145 + wait_timeout=2,
146 + )
147 + await secure_client.disconnect()
148 +
149 + # Secure namespace accepts valid session + auth csrf_token + runtime-scoped csrf cookie.
150 + csrf_token = "csrf-1"
151 + session_cookie = _make_session_cookie(
152 + webapp,
153 + {
154 + "authentication": "hash",
155 + "csrf_token": csrf_token,
156 + "user_id": "u1",
157 + },
158 + )
159 + session_cookie_name = webapp.config.get("SESSION_COOKIE_NAME", "session")
160 + csrf_cookie_name = f"csrf_token_{runtime.get_runtime_id()}"
161 + cookie_header = f"{session_cookie_name}={session_cookie}; {csrf_cookie_name}={csrf_token}"
162 +
163 + secure_client_ok = socketio.AsyncClient()
164 + await secure_client_ok.connect(
165 + base_url,
166 + namespaces=["/secure"],
167 + headers={"Origin": base_url, "Cookie": cookie_header},
168 + auth={"csrf_token": csrf_token},
169 + wait_timeout=2,
170 + )
171 + try:
172 + res2 = await secure_client_ok.call("secure_ping", {}, namespace="/secure", timeout=2)
173 + assert isinstance(res2, dict)
174 + assert res2.get("results")
175 + finally:
176 + await secure_client_ok.disconnect()
177 +
178 +
179 +@pytest.mark.asyncio
180 +async def test_unknown_namespace_rejected_with_deterministic_connect_error_payload() -> None:
181 + from flask import Flask
182 + import socketio
183 +
184 + from python.helpers.websocket import WebSocketHandler
185 + from python.helpers.websocket_manager import WebSocketManager
186 + from run_ui import configure_websocket_namespaces
187 +
188 + class OpenHandler(WebSocketHandler):
189 + @classmethod
190 + def requires_auth(cls) -> bool:
191 + return False
192 +
193 + @classmethod
194 + def requires_csrf(cls) -> bool:
195 + return False
196 +
197 + @classmethod
198 + def get_event_types(cls) -> list[str]:
199 + return ["open_ping"]
200 +
201 + async def process_event(self, event_type: str, data: dict[str, Any], sid: str) -> dict[str, Any]:
202 + return {"ok": True}
203 +
204 + OpenHandler._reset_instance_for_testing()
205 +
206 + webapp = Flask("test_unknown_namespace_rejection")
207 + webapp.secret_key = "test-secret"
208 +
209 + sio = socketio.AsyncServer(async_mode="asgi", cors_allowed_origins="*", namespaces="*")
210 + lock = threading.RLock()
211 + manager = WebSocketManager(sio, lock)
212 +
213 + configure_websocket_namespaces(
214 + webapp=webapp,
215 + socketio_server=sio,
216 + websocket_manager=manager,
217 + handlers_by_namespace={"/open": [OpenHandler.get_instance(sio, lock)]},
218 + )
219 +
220 + asgi_app = socketio.ASGIApp(sio)
221 +
222 + async with _run_asgi_app(asgi_app) as base_url:
223 + client = socketio.AsyncClient()
224 + connect_error_fut: asyncio.Future[Any] = asyncio.get_running_loop().create_future()
225 +
226 + async def _on_connect_error(data: Any) -> None:
227 + if not connect_error_fut.done():
228 + connect_error_fut.set_result(data)
229 +
230 + client.on("connect_error", _on_connect_error, namespace="/unknown")
231 +
232 + try:
233 + with pytest.raises(socketio.exceptions.ConnectionError):
234 + await client.connect(base_url, namespaces=["/unknown"])
235 +
236 + err = await asyncio.wait_for(connect_error_fut, timeout=2)
237 + assert err["message"] == "UNKNOWN_NAMESPACE"
238 + assert err["data"] == {"code": "UNKNOWN_NAMESPACE", "namespace": "/unknown"}
239 + finally:
240 + try:
241 + await client.disconnect()
242 + except Exception:
243 + pass
244 +
245 +
246 +@pytest.mark.asyncio
247 +async def test_secure_namespace_rejects_missing_auth_even_with_valid_csrf(monkeypatch) -> None:
248 + from flask import Flask
249 + import socketio
250 +
251 + from python.helpers.websocket import WebSocketHandler
252 + from python.helpers.websocket_manager import WebSocketManager
253 + from python.helpers import runtime
254 + from run_ui import configure_websocket_namespaces
255 +
256 + class SecureHandler(WebSocketHandler):
257 + @classmethod
258 + def get_event_types(cls) -> list[str]:
259 + return ["secure_ping"]
260 +
261 + async def process_event(self, event_type: str, data: dict[str, Any], sid: str) -> dict[str, Any]:
262 + return {"ok": True}
263 +
264 + SecureHandler._reset_instance_for_testing()
265 +
266 + monkeypatch.setattr("python.helpers.login.get_credentials_hash", lambda: "hash")
267 +
268 + webapp = Flask("test_ws_secure_missing_auth")
269 + webapp.secret_key = "test-secret"
270 +
271 + sio = socketio.AsyncServer(async_mode="asgi", cors_allowed_origins="*", namespaces="*")
272 + lock = threading.RLock()
273 + manager = WebSocketManager(sio, lock)
274 + handlers_by_namespace = {
275 + "/secure": [SecureHandler.get_instance(sio, lock)],
276 + }
277 +
278 + configure_websocket_namespaces(
279 + webapp=webapp,
280 + socketio_server=sio,
281 + websocket_manager=manager,
282 + handlers_by_namespace=handlers_by_namespace,
283 + )
284 +
285 + asgi_app = socketio.ASGIApp(sio)
286 +
287 + async with _run_asgi_app(asgi_app) as base_url:
288 + csrf_token = "csrf-auth-missing"
289 + session_cookie = _make_session_cookie(
290 + webapp,
291 + {
292 + "csrf_token": csrf_token,
293 + "user_id": "u1",
294 + },
295 + )
296 + session_cookie_name = webapp.config.get("SESSION_COOKIE_NAME", "session")
297 + csrf_cookie_name = f"csrf_token_{runtime.get_runtime_id()}"
298 + cookie_header = f"{session_cookie_name}={session_cookie}; {csrf_cookie_name}={csrf_token}"
299 +
300 + client = socketio.AsyncClient()
301 + with pytest.raises(socketio.exceptions.ConnectionError):
302 + await client.connect(
303 + base_url,
304 + namespaces=["/secure"],
305 + headers={"Origin": base_url, "Cookie": cookie_header},
306 + auth={"csrf_token": csrf_token},
307 + wait_timeout=2,
308 + )
309 + await client.disconnect()
310 +
311 +
312 +@pytest.mark.asyncio
313 +async def test_secure_namespace_rejects_invalid_csrf_cookie(monkeypatch) -> None:
314 + from flask import Flask
315 + import socketio
316 +
317 + from python.helpers.websocket import WebSocketHandler
318 + from python.helpers.websocket_manager import WebSocketManager
319 + from python.helpers import runtime
320 + from run_ui import configure_websocket_namespaces
321 +
322 + class SecureHandler(WebSocketHandler):
323 + @classmethod
324 + def get_event_types(cls) -> list[str]:
325 + return ["secure_ping"]
326 +
327 + async def process_event(self, event_type: str, data: dict[str, Any], sid: str) -> dict[str, Any]:
328 + return {"ok": True}
329 +
330 + SecureHandler._reset_instance_for_testing()
331 +
332 + monkeypatch.setattr("python.helpers.login.get_credentials_hash", lambda: "hash")
333 +
334 + webapp = Flask("test_ws_secure_invalid_csrf")
335 + webapp.secret_key = "test-secret"
336 +
337 + sio = socketio.AsyncServer(async_mode="asgi", cors_allowed_origins="*", namespaces="*")
338 + lock = threading.RLock()
339 + manager = WebSocketManager(sio, lock)
340 + handlers_by_namespace = {
341 + "/secure": [SecureHandler.get_instance(sio, lock)],
342 + }
343 +
344 + configure_websocket_namespaces(
345 + webapp=webapp,
346 + socketio_server=sio,
347 + websocket_manager=manager,
348 + handlers_by_namespace=handlers_by_namespace,
349 + )
350 +
351 + asgi_app = socketio.ASGIApp(sio)
352 +
353 + async with _run_asgi_app(asgi_app) as base_url:
354 + csrf_token = "csrf-good"
355 + session_cookie = _make_session_cookie(
356 + webapp,
357 + {
358 + "authentication": "hash",
359 + "csrf_token": csrf_token,
360 + "user_id": "u1",
361 + },
362 + )
363 + session_cookie_name = webapp.config.get("SESSION_COOKIE_NAME", "session")
364 + csrf_cookie_name = f"csrf_token_{runtime.get_runtime_id()}"
365 + cookie_header = f"{session_cookie_name}={session_cookie}; {csrf_cookie_name}=csrf-bad"
366 +
367 + client = socketio.AsyncClient()
368 + with pytest.raises(socketio.exceptions.ConnectionError):
369 + await client.connect(
370 + base_url,
371 + namespaces=["/secure"],
372 + headers={"Origin": base_url, "Cookie": cookie_header},
373 + auth={"csrf_token": csrf_token},
374 + wait_timeout=2,
375 + )
376 + await client.disconnect()
377 +
378 +
379 +@pytest.mark.asyncio
380 +async def test_csrf_required_without_auth_is_enforced(monkeypatch) -> None:
381 + from flask import Flask
382 + import socketio
383 +
384 + from python.helpers.websocket import WebSocketHandler
385 + from python.helpers.websocket_manager import WebSocketManager
386 + from python.helpers import runtime
387 + from run_ui import configure_websocket_namespaces
388 +
389 + class CsrfOnlyHandler(WebSocketHandler):
390 + @classmethod
391 + def requires_auth(cls) -> bool:
392 + return False
393 +
394 + @classmethod
395 + def requires_csrf(cls) -> bool:
396 + return True
397 +
398 + @classmethod
399 + def get_event_types(cls) -> list[str]:
400 + return ["csrf_only_ping"]
401 +
402 + async def process_event(self, event_type: str, data: dict[str, Any], sid: str) -> dict[str, Any]:
403 + return {"ok": True}
404 +
405 + CsrfOnlyHandler._reset_instance_for_testing()
406 +
407 + monkeypatch.setattr("python.helpers.login.get_credentials_hash", lambda: None)
408 +
409 + webapp = Flask("test_ws_csrf_only")
410 + webapp.secret_key = "test-secret"
411 +
412 + sio = socketio.AsyncServer(async_mode="asgi", cors_allowed_origins="*", namespaces="*")
413 + lock = threading.RLock()
414 + manager = WebSocketManager(sio, lock)
415 + handlers_by_namespace = {
416 + "/csrf_only": [CsrfOnlyHandler.get_instance(sio, lock)],
417 + }
418 +
419 + configure_websocket_namespaces(
420 + webapp=webapp,
421 + socketio_server=sio,
422 + websocket_manager=manager,
423 + handlers_by_namespace=handlers_by_namespace,
424 + )
425 +
426 + asgi_app = socketio.ASGIApp(sio)
427 +
428 + async with _run_asgi_app(asgi_app) as base_url:
429 + client = socketio.AsyncClient()
430 + with pytest.raises(socketio.exceptions.ConnectionError):
431 + await client.connect(
432 + base_url,
433 + namespaces=["/csrf_only"],
434 + headers={"Origin": base_url},
435 + wait_timeout=2,
436 + )
437 + await client.disconnect()
438 +
439 + csrf_token = "csrf-only"
440 + session_cookie = _make_session_cookie(
441 + webapp,
442 + {
443 + "csrf_token": csrf_token,
444 + "user_id": "u1",
445 + },
446 + )
447 + session_cookie_name = webapp.config.get("SESSION_COOKIE_NAME", "session")
448 + csrf_cookie_name = f"csrf_token_{runtime.get_runtime_id()}"
449 + cookie_header = f"{session_cookie_name}={session_cookie}; {csrf_cookie_name}={csrf_token}"
450 +
451 + client_ok = socketio.AsyncClient()
452 + await client_ok.connect(
453 + base_url,
454 + namespaces=["/csrf_only"],
455 + headers={"Origin": base_url, "Cookie": cookie_header},
456 + auth={"csrf_token": csrf_token},
457 + wait_timeout=2,
458 + )
459 + try:
460 + res = await client_ok.call("csrf_only_ping", {}, namespace="/csrf_only", timeout=2)
461 + assert isinstance(res, dict)
462 + assert res.get("results")
463 + finally:
464 + await client_ok.disconnect()
tests/test_websocket_namespaces.py new
+497
@@ -0,0 +1,497 @@
1 +import asyncio
2 +import contextlib
3 +import socket
4 +import sys
5 +import threading
6 +from pathlib import Path
7 +from typing import Any, AsyncIterator
8 +from unittest.mock import AsyncMock
9 +
10 +import pytest
11 +
12 +PROJECT_ROOT = Path(__file__).resolve().parents[1]
13 +if str(PROJECT_ROOT) not in sys.path:
14 + sys.path.insert(0, str(PROJECT_ROOT))
15 +
16 +from python.helpers.state_monitor import StateMonitor
17 +from python.helpers.websocket_manager import WebSocketManager
18 +
19 +
20 +class FakeSocketIOServer:
21 + def __init__(self) -> None:
22 + self.emit = AsyncMock()
23 + self.disconnect = AsyncMock()
24 +
25 +
26 +@contextlib.asynccontextmanager
27 +async def _run_asgi_app(app: Any) -> AsyncIterator[str]:
28 + import uvicorn
29 +
30 + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
31 + sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
32 + sock.bind(("127.0.0.1", 0))
33 + sock.listen(128)
34 +
35 + port = sock.getsockname()[1]
36 +
37 + config = uvicorn.Config(
38 + app,
39 + host="127.0.0.1",
40 + port=port,
41 + log_level="warning",
42 + access_log=False,
43 + lifespan="off",
44 + )
45 + server = uvicorn.Server(config)
46 + server.install_signal_handlers = lambda: None # type: ignore[method-assign]
47 +
48 + task = asyncio.create_task(server.serve(sockets=[sock]))
49 + try:
50 + while not server.started:
51 + await asyncio.sleep(0.01)
52 + yield f"http://127.0.0.1:{port}"
53 + finally:
54 + server.should_exit = True
55 + try:
56 + await asyncio.wait_for(task, timeout=5)
57 + finally:
58 + sock.close()
59 +
60 +
61 +@pytest.mark.asyncio
62 +async def test_manager_identity_is_namespace_and_sid_allows_same_sid_across_namespaces() -> None:
63 + socketio = FakeSocketIOServer()
64 + manager = WebSocketManager(socketio, threading.RLock())
65 + # Avoid flakiness from lifecycle broadcasts scheduled via asyncio.create_task.
66 + manager._schedule_lifecycle_broadcast = lambda *_args, **_kwargs: None # type: ignore[assignment]
67 +
68 + sid = "shared-sid"
69 + ns_a = "/a"
70 + ns_b = "/b"
71 +
72 + await manager.handle_connect(ns_a, sid)
73 + await manager.handle_connect(ns_b, sid)
74 +
75 + assert (ns_a, sid) in manager.connections
76 + assert (ns_b, sid) in manager.connections
77 +
78 + await manager.handle_disconnect(ns_a, sid)
79 + assert (ns_a, sid) not in manager.connections
80 + assert (ns_b, sid) in manager.connections
81 +
82 + await manager.emit_to(ns_a, sid, "test_event", {"value": 1}, correlation_id="corr-1")
83 +
84 + assert (ns_a, sid) in manager.buffers
85 + assert (ns_b, sid) not in manager.buffers
86 + assert socketio.emit.await_count == 0
87 +
88 +
89 +def test_state_monitor_tracks_two_identities_for_same_sid_across_namespaces() -> None:
90 + monitor = StateMonitor()
91 + sid = "shared-sid"
92 + monitor.register_sid("/a", sid)
93 + monitor.register_sid("/b", sid)
94 +
95 + debug = monitor._debug_state()
96 + assert ("/a", sid) in debug["identities"]
97 + assert ("/b", sid) in debug["identities"]
98 +
99 +
100 +@pytest.mark.asyncio
101 +async def test_namespace_isolation_state_sync_vs_dev_websocket_test() -> None:
102 + """
103 + CONTRACT.INVARIANT.NS.ISOLATION: no cross-namespace delivery for application events.
104 +
105 + Acceptance proof for `/state_sync` vs `/dev_websocket_test` namespaces.
106 + """
107 +
108 + from flask import Flask
109 + import socketio
110 +
111 + from python.helpers.websocket import WebSocketHandler
112 + from python.helpers.websocket_manager import WebSocketManager
113 + from run_ui import configure_websocket_namespaces
114 +
115 + class StateHandler(WebSocketHandler):
116 + @classmethod
117 + def requires_auth(cls) -> bool:
118 + return False
119 +
120 + @classmethod
121 + def requires_csrf(cls) -> bool:
122 + return False
123 +
124 + @classmethod
125 + def get_event_types(cls) -> list[str]:
126 + return ["state_request"]
127 +
128 + async def process_event(self, event_type: str, data: dict[str, Any], sid: str):
129 + await self.emit_to(sid, "state_push", {"source": "state_sync"})
130 + return {"ok": True}
131 +
132 + class DevHandler(WebSocketHandler):
133 + @classmethod
134 + def requires_auth(cls) -> bool:
135 + return False
136 +
137 + @classmethod
138 + def requires_csrf(cls) -> bool:
139 + return False
140 +
141 + @classmethod
142 + def get_event_types(cls) -> list[str]:
143 + return ["ws_tester_emit"]
144 +
145 + async def process_event(self, event_type: str, data: dict[str, Any], sid: str):
146 + await self.broadcast("ws_tester_broadcast", {"source": "dev_websocket_test"})
147 + return None
148 +
149 + StateHandler._reset_instance_for_testing()
150 + DevHandler._reset_instance_for_testing()
151 +
152 + webapp = Flask("test_namespace_isolation")
153 + webapp.secret_key = "test-secret"
154 +
155 + sio = socketio.AsyncServer(async_mode="asgi", cors_allowed_origins="*", namespaces="*")
156 + lock = threading.RLock()
157 + manager = WebSocketManager(sio, lock)
158 +
159 + configure_websocket_namespaces(
160 + webapp=webapp,
161 + socketio_server=sio,
162 + websocket_manager=manager,
163 + handlers_by_namespace={
164 + "/state_sync": [StateHandler.get_instance(sio, lock)],
165 + "/dev_websocket_test": [DevHandler.get_instance(sio, lock)],
166 + },
167 + )
168 +
169 + asgi_app = socketio.ASGIApp(sio)
170 +
171 + async with _run_asgi_app(asgi_app) as base_url:
172 + client = socketio.AsyncClient()
173 +
174 + state_push_state = asyncio.Event()
175 + state_push_dev = asyncio.Event()
176 + tester_broadcast_dev = asyncio.Event()
177 + tester_broadcast_state = asyncio.Event()
178 +
179 + async def _on_state_push_state(_payload: Any) -> None:
180 + state_push_state.set()
181 +
182 + async def _on_state_push_dev(_payload: Any) -> None:
183 + state_push_dev.set()
184 +
185 + async def _on_tester_broadcast_dev(_payload: Any) -> None:
186 + tester_broadcast_dev.set()
187 +
188 + async def _on_tester_broadcast_state(_payload: Any) -> None:
189 + tester_broadcast_state.set()
190 +
191 + client.on("state_push", _on_state_push_state, namespace="/state_sync")
192 + client.on("state_push", _on_state_push_dev, namespace="/dev_websocket_test")
193 + client.on("ws_tester_broadcast", _on_tester_broadcast_dev, namespace="/dev_websocket_test")
194 + client.on("ws_tester_broadcast", _on_tester_broadcast_state, namespace="/state_sync")
195 +
196 + await client.connect(
197 + base_url,
198 + namespaces=["/state_sync", "/dev_websocket_test"],
199 + headers={"Origin": base_url},
200 + wait_timeout=2,
201 + )
202 + try:
203 + await client.call("state_request", {"context": None}, namespace="/state_sync", timeout=2)
204 + await asyncio.wait_for(state_push_state.wait(), timeout=2)
205 + await asyncio.sleep(0.05)
206 + assert state_push_dev.is_set() is False
207 +
208 + await client.emit("ws_tester_emit", {"message": "hi"}, namespace="/dev_websocket_test")
209 + await asyncio.wait_for(tester_broadcast_dev.wait(), timeout=2)
210 + await asyncio.sleep(0.05)
211 + assert tester_broadcast_state.is_set() is False
212 + finally:
213 + await client.disconnect()
214 +
215 +
216 +@pytest.mark.asyncio
217 +async def test_diagnostics_include_source_namespace_and_deliver_on_dev_namespace_only() -> None:
218 + """
219 + CONTRACT.Diagnostics: dev console diagnostics are delivered on `/dev_websocket_test`,
220 + but must include `sourceNamespace` identifying the origin namespace.
221 + """
222 +
223 + from python.helpers.websocket import WebSocketHandler
224 + from python.helpers.websocket_manager import DIAGNOSTIC_EVENT, WebSocketManager
225 +
226 + class DummyHandler(WebSocketHandler):
227 + @classmethod
228 + def get_event_types(cls) -> list[str]:
229 + return ["dummy_event"]
230 +
231 + async def process_event(self, event_type: str, data: dict[str, Any], sid: str):
232 + return {"ok": True}
233 +
234 + DummyHandler._reset_instance_for_testing()
235 +
236 + socketio = FakeSocketIOServer()
237 + manager = WebSocketManager(socketio, threading.RLock())
238 + manager._schedule_lifecycle_broadcast = lambda *_args, **_kwargs: None # type: ignore[assignment]
239 +
240 + ns_state = "/state_sync"
241 + ns_dev = "/dev_websocket_test"
242 +
243 + handler = DummyHandler.get_instance(socketio, threading.RLock())
244 + manager.register_handlers({ns_state: [handler]})
245 +
246 + await manager.handle_connect(ns_dev, "sid-watcher")
247 + await manager.handle_connect(ns_state, "sid-client")
248 + assert manager.register_diagnostic_watcher(ns_dev, "sid-watcher") is True
249 +
250 + socketio.emit.reset_mock()
251 +
252 + await manager.route_event(ns_state, "dummy_event", {"payload": True}, "sid-client")
253 +
254 + calls = [(call.args, call.kwargs) for call in socketio.emit.await_args_list]
255 + diagnostic = next((c for c in calls if c[0] and c[0][0] == DIAGNOSTIC_EVENT), None)
256 + assert diagnostic is not None
257 +
258 + args, kwargs = diagnostic
259 + envelope = args[1]
260 + assert kwargs == {"to": "sid-watcher", "namespace": ns_dev}
261 + assert envelope["data"]["sourceNamespace"] == ns_state
262 +
263 +
264 +def test_namespace_discovery_maps_core_handlers_to_expected_namespaces() -> None:
265 + """
266 + US1 regression: ensure discovery assigns core handlers to their dedicated namespaces
267 + (no cross-registration).
268 + """
269 +
270 + from python.helpers.websocket_namespace_discovery import discover_websocket_namespaces
271 +
272 + discoveries = discover_websocket_namespaces(
273 + handlers_folder="python/websocket_handlers",
274 + include_root_default=True,
275 + )
276 + by_namespace = {entry.namespace: entry for entry in discoveries}
277 +
278 + assert "/state_sync" in by_namespace
279 + assert "/dev_websocket_test" in by_namespace
280 +
281 + state_cls_names = [cls.__name__ for cls in by_namespace["/state_sync"].handler_classes]
282 + dev_cls_names = [cls.__name__ for cls in by_namespace["/dev_websocket_test"].handler_classes]
283 +
284 + assert state_cls_names == ["StateSyncHandler"]
285 + assert dev_cls_names == ["DevWebsocketTestHandler"]
286 +
287 +
288 +def test_run_ui_builds_namespace_handler_map_without_cross_registration() -> None:
289 + from run_ui import _build_websocket_handlers_by_namespace
290 +
291 + handlers_by_namespace = _build_websocket_handlers_by_namespace(object(), threading.RLock())
292 +
293 + assert "/state_sync" in handlers_by_namespace
294 + assert "/dev_websocket_test" in handlers_by_namespace
295 +
296 + assert all(
297 + handler.__class__.__name__ != "DevWebsocketTestHandler"
298 + for handler in handlers_by_namespace["/state_sync"]
299 + )
300 + assert all(
301 + handler.__class__.__name__ != "StateSyncHandler"
302 + for handler in handlers_by_namespace["/dev_websocket_test"]
303 + )
304 +
305 +
306 +@pytest.mark.asyncio
307 +async def test_route_event_dispatches_only_within_connected_namespace_and_results_are_scoped() -> None:
308 + """
309 + CONTRACT.NS.ROUTING: inbound routing is restricted to handlers in the connected namespace.
310 + """
311 +
312 + from python.helpers.websocket import WebSocketHandler
313 +
314 + socketio = FakeSocketIOServer()
315 + manager = WebSocketManager(socketio, threading.RLock())
316 + manager._schedule_lifecycle_broadcast = lambda *_args, **_kwargs: None # type: ignore[assignment]
317 +
318 + ns_state = "/state_sync"
319 + ns_dev = "/dev_websocket_test"
320 +
321 + calls: list[str] = []
322 +
323 + class StatePingHandler(WebSocketHandler):
324 + @classmethod
325 + def get_event_types(cls) -> list[str]:
326 + return ["route_test"]
327 +
328 + async def process_event(self, event_type: str, data: dict[str, Any], sid: str):
329 + calls.append(f"state:{sid}")
330 + return {"ns": "state"}
331 +
332 + class DevPingHandler(WebSocketHandler):
333 + @classmethod
334 + def get_event_types(cls) -> list[str]:
335 + return ["route_test"]
336 +
337 + async def process_event(self, event_type: str, data: dict[str, Any], sid: str):
338 + calls.append(f"dev:{sid}")
339 + return {"ns": "dev"}
340 +
341 + StatePingHandler._reset_instance_for_testing()
342 + DevPingHandler._reset_instance_for_testing()
343 +
344 + state_handler = StatePingHandler.get_instance(socketio, threading.RLock())
345 + dev_handler = DevPingHandler.get_instance(socketio, threading.RLock())
346 +
347 + manager.register_handlers({ns_state: [state_handler], ns_dev: [dev_handler]})
348 + await manager.handle_connect(ns_state, "sid-state")
349 + await manager.handle_connect(ns_dev, "sid-dev")
350 +
351 + res_state = await manager.route_event(ns_state, "route_test", {"x": 1}, "sid-state")
352 + assert {item["handlerId"] for item in res_state["results"]} == {state_handler.identifier}
353 + assert res_state["results"][0]["data"]["ns"] == "state"
354 +
355 + res_dev = await manager.route_event(ns_dev, "route_test", {"x": 2}, "sid-dev")
356 + assert {item["handlerId"] for item in res_dev["results"]} == {dev_handler.identifier}
357 + assert res_dev["results"][0]["data"]["ns"] == "dev"
358 +
359 + assert calls == ["state:sid-state", "dev:sid-dev"]
360 +
361 +
362 +@pytest.mark.asyncio
363 +async def test_lifecycle_broadcasts_deliver_only_within_the_namespace() -> None:
364 + """
365 + CONTRACT.NS.DELIVERY: lifecycle broadcasts are namespace-scoped.
366 + """
367 +
368 + from python.helpers.websocket_manager import (
369 + LIFECYCLE_CONNECT_EVENT,
370 + LIFECYCLE_DISCONNECT_EVENT,
371 + )
372 +
373 + socketio = FakeSocketIOServer()
374 + manager = WebSocketManager(socketio, threading.RLock())
375 +
376 + ns_state = "/state_sync"
377 + ns_dev = "/dev_websocket_test"
378 +
379 + # Connect events should broadcast only within their namespace.
380 + await manager.handle_connect(ns_state, "sid-state-1")
381 + await asyncio.sleep(0)
382 + state_connect_calls = [
383 + call
384 + for call in socketio.emit.await_args_list
385 + if call.args and call.args[0] == LIFECYCLE_CONNECT_EVENT
386 + ]
387 + assert state_connect_calls
388 + assert all(call.kwargs.get("namespace") == ns_state for call in state_connect_calls)
389 +
390 + socketio.emit.reset_mock()
391 + await manager.handle_connect(ns_dev, "sid-dev-1")
392 + await asyncio.sleep(0)
393 + dev_connect_calls = [
394 + call
395 + for call in socketio.emit.await_args_list
396 + if call.args and call.args[0] == LIFECYCLE_CONNECT_EVENT
397 + ]
398 + assert dev_connect_calls
399 + assert all(call.kwargs.get("namespace") == ns_dev for call in dev_connect_calls)
400 +
401 + # Disconnect broadcasts go to remaining peers in that namespace only.
402 + socketio.emit.reset_mock()
403 + await manager.handle_connect(ns_state, "sid-state-2")
404 + await manager.handle_connect(ns_dev, "sid-dev-2")
405 + socketio.emit.reset_mock()
406 +
407 + await manager.handle_disconnect(ns_state, "sid-state-2")
408 + await asyncio.sleep(0)
409 + state_disconnect_calls = [
410 + call
411 + for call in socketio.emit.await_args_list
412 + if call.args and call.args[0] == LIFECYCLE_DISCONNECT_EVENT
413 + ]
414 + assert state_disconnect_calls
415 + assert all(call.kwargs.get("namespace") == ns_state for call in state_disconnect_calls)
416 + assert all(call.kwargs.get("to") == "sid-state-1" for call in state_disconnect_calls)
417 +
418 +
419 +@pytest.mark.asyncio
420 +async def test_request_semantics_no_handlers_and_timeouts_are_namespace_scoped_and_order_insensitive() -> None:
421 + """
422 + CONTRACT.REQUEST.RESULTS + CONTRACT.REQUEST.RESULTS.ORDERING + CONTRACT.NS.ROUTING.
423 + """
424 +
425 + from python.helpers.websocket import WebSocketHandler
426 +
427 + socketio = FakeSocketIOServer()
428 + manager = WebSocketManager(socketio, threading.RLock())
429 + manager._schedule_lifecycle_broadcast = lambda *_args, **_kwargs: None # type: ignore[assignment]
430 +
431 + ns_state = "/state_sync"
432 + ns_dev = "/dev_websocket_test"
433 +
434 + class Alpha(WebSocketHandler):
435 + @classmethod
436 + def get_event_types(cls) -> list[str]:
437 + return ["multi", "slow"]
438 +
439 + async def process_event(self, event_type: str, data: dict[str, Any], sid: str):
440 + if event_type == "slow":
441 + await asyncio.sleep(0.2)
442 + return {"alpha": True}
443 + return {"alpha": True}
444 +
445 + class Beta(WebSocketHandler):
446 + @classmethod
447 + def get_event_types(cls) -> list[str]:
448 + return ["multi"]
449 +
450 + async def process_event(self, event_type: str, data: dict[str, Any], sid: str):
451 + return {"beta": True}
452 +
453 + Alpha._reset_instance_for_testing()
454 + Beta._reset_instance_for_testing()
455 + alpha = Alpha.get_instance(socketio, threading.RLock())
456 + beta = Beta.get_instance(socketio, threading.RLock())
457 +
458 + manager.register_handlers({ns_state: [alpha, beta]})
459 + await manager.handle_connect(ns_state, "sid-a")
460 + await manager.handle_connect(ns_state, "sid-b")
461 + await manager.handle_connect(ns_dev, "sid-dev")
462 +
463 + # Unknown event name -> NO_HANDLERS (no hang), scoped to the namespace.
464 + no_handler = await manager.route_event(ns_dev, "missing_event", {"x": 1}, "sid-dev")
465 + assert no_handler["results"][0]["ok"] is False
466 + assert no_handler["results"][0]["error"]["code"] == "NO_HANDLERS"
467 + assert ns_dev in no_handler["results"][0]["error"]["error"]
468 +
469 + # Unknown event name in a namespace that *does* have other handlers -> NO_HANDLERS.
470 + unhandled_in_state = await manager.route_event(ns_state, "unknown_event", {"x": 1}, "sid-a")
471 + assert unhandled_in_state["results"][0]["ok"] is False
472 + assert unhandled_in_state["results"][0]["error"]["code"] == "NO_HANDLERS"
473 + assert ns_state in unhandled_in_state["results"][0]["error"]["error"]
474 +
475 + # Known event name in the wrong namespace -> NO_HANDLERS (no cross-namespace fallback).
476 + wrong_namespace = await manager.route_event(ns_dev, "multi", {"x": 1}, "sid-dev")
477 + assert wrong_namespace["results"][0]["ok"] is False
478 + assert wrong_namespace["results"][0]["error"]["code"] == "NO_HANDLERS"
479 + assert ns_dev in wrong_namespace["results"][0]["error"]["error"]
480 +
481 + # Order-insensitive results[]: both handlers must be present regardless of ordering.
482 + multi = await manager.route_event(ns_state, "multi", {"x": 1}, "sid-a")
483 + handler_ids = {item["handlerId"] for item in multi["results"]}
484 + assert handler_ids == {alpha.identifier, beta.identifier}
485 +
486 + # Timeout results are represented as TIMEOUT items and scoped to the namespace.
487 + aggregated = await manager.route_event_all(ns_state, "slow", {"x": 1}, timeout_ms=50)
488 + assert len(aggregated) == 2 # only state namespace connections
489 + assert {entry["sid"] for entry in aggregated} == {"sid-a", "sid-b"}
490 + for entry in aggregated:
491 + assert entry["results"]
492 + assert entry["results"][0]["ok"] is False
493 + assert entry["results"][0]["error"]["code"] == "TIMEOUT"
494 +
495 + # Allow the underlying slow route_event coroutines to complete so pytest's event loop
496 + # teardown does not cancel them mid-flight (avoids noisy InvalidStateError callbacks).
497 + await asyncio.sleep(0.3)
tests/test_websocket_namespaces_integration.py new
+113
@@ -0,0 +1,113 @@
1 +import asyncio
2 +import contextlib
3 +import socket
4 +from typing import Any, AsyncIterator
5 +
6 +import pytest
7 +
8 +
9 +@contextlib.asynccontextmanager
10 +async def _run_asgi_app(app: Any) -> AsyncIterator[str]:
11 + import uvicorn
12 +
13 + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
14 + sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
15 + sock.bind(("127.0.0.1", 0))
16 + sock.listen(128)
17 +
18 + port = sock.getsockname()[1]
19 +
20 + config = uvicorn.Config(
21 + app,
22 + host="127.0.0.1",
23 + port=port,
24 + log_level="warning",
25 + access_log=False,
26 + lifespan="off",
27 + )
28 + server = uvicorn.Server(config)
29 + server.install_signal_handlers = lambda: None # type: ignore[method-assign]
30 +
31 + task = asyncio.create_task(server.serve(sockets=[sock]))
32 + try:
33 + while not server.started:
34 + await asyncio.sleep(0.01)
35 + yield f"http://127.0.0.1:{port}"
36 + finally:
37 + server.should_exit = True
38 + try:
39 + await asyncio.wait_for(task, timeout=5)
40 + finally:
41 + sock.close()
42 +
43 +
44 +@pytest.mark.asyncio
45 +async def test_unregistered_namespace_connection_fails_with_unknown_namespace_connect_error() -> None:
46 + """
47 + US5 integration: unregistered namespace connections fail deterministically with a structured
48 + connect_error payload (UNKNOWN_NAMESPACE), independent of python-socketio defaults.
49 + """
50 +
51 + from flask import Flask
52 + import socketio
53 +
54 + from python.helpers.websocket import WebSocketHandler
55 + from python.helpers.websocket_manager import WebSocketManager
56 + from run_ui import configure_websocket_namespaces
57 +
58 + class OpenHandler(WebSocketHandler):
59 + @classmethod
60 + def requires_auth(cls) -> bool:
61 + return False
62 +
63 + @classmethod
64 + def requires_csrf(cls) -> bool:
65 + return False
66 +
67 + @classmethod
68 + def get_event_types(cls) -> list[str]:
69 + return ["open_ping"]
70 +
71 + async def process_event(self, event_type: str, data: dict[str, Any], sid: str):
72 + return {"ok": True}
73 +
74 + OpenHandler._reset_instance_for_testing()
75 +
76 + webapp = Flask("test_ws_namespaces_integration")
77 + webapp.secret_key = "test-secret"
78 +
79 + sio = socketio.AsyncServer(async_mode="asgi", cors_allowed_origins="*", namespaces="*")
80 + lock = __import__("threading").RLock()
81 + manager = WebSocketManager(sio, lock)
82 +
83 + configure_websocket_namespaces(
84 + webapp=webapp,
85 + socketio_server=sio,
86 + websocket_manager=manager,
87 + handlers_by_namespace={"/open": [OpenHandler.get_instance(sio, lock)]},
88 + )
89 +
90 + asgi_app = socketio.ASGIApp(sio)
91 +
92 + async with _run_asgi_app(asgi_app) as base_url:
93 + client = socketio.AsyncClient()
94 + connect_error_fut: asyncio.Future[Any] = asyncio.get_running_loop().create_future()
95 +
96 + async def _on_connect_error(data: Any) -> None:
97 + if not connect_error_fut.done():
98 + connect_error_fut.set_result(data)
99 +
100 + client.on("connect_error", _on_connect_error, namespace="/unknown")
101 +
102 + try:
103 + with pytest.raises(socketio.exceptions.ConnectionError):
104 + await client.connect(base_url, namespaces=["/unknown"])
105 +
106 + err = await asyncio.wait_for(connect_error_fut, timeout=2)
107 + assert err["message"] == "UNKNOWN_NAMESPACE"
108 + assert err["data"] == {"code": "UNKNOWN_NAMESPACE", "namespace": "/unknown"}
109 + finally:
110 + try:
111 + await client.disconnect()
112 + except Exception:
113 + pass
tests/test_websocket_root_namespace.py new
+183
@@ -0,0 +1,183 @@
1 +import asyncio
2 +import contextlib
3 +import socket
4 +from typing import Any, AsyncIterator
5 +
6 +import pytest
7 +
8 +
9 +@contextlib.asynccontextmanager
10 +async def _run_asgi_app(app: Any) -> AsyncIterator[str]:
11 + import uvicorn
12 +
13 + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
14 + sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
15 + sock.bind(("127.0.0.1", 0))
16 + sock.listen(128)
17 +
18 + port = sock.getsockname()[1]
19 +
20 + config = uvicorn.Config(
21 + app,
22 + host="127.0.0.1",
23 + port=port,
24 + log_level="warning",
25 + access_log=False,
26 + lifespan="off",
27 + )
28 + server = uvicorn.Server(config)
29 + server.install_signal_handlers = lambda: None # type: ignore[method-assign]
30 +
31 + task = asyncio.create_task(server.serve(sockets=[sock]))
32 + try:
33 + while not server.started:
34 + await asyncio.sleep(0.01)
35 + yield f"http://127.0.0.1:{port}"
36 + finally:
37 + server.should_exit = True
38 + try:
39 + await asyncio.wait_for(task, timeout=5)
40 + finally:
41 + sock.close()
42 +
43 +
44 +@pytest.mark.asyncio
45 +async def test_root_namespace_request_style_calls_resolve_with_no_handlers() -> None:
46 + """
47 + CONTRACT.INVARIANT.NS.ROOT.UNHANDLED: root (`/`) is reserved and unhandled for application
48 + events by default, but request-style calls must not hang (NO_HANDLERS).
49 + """
50 +
51 + from flask import Flask
52 + import socketio
53 +
54 + from python.helpers.websocket import WebSocketHandler
55 + from python.helpers.websocket_manager import WebSocketManager
56 + from run_ui import configure_websocket_namespaces
57 +
58 + app = Flask("test_ws_root_namespace")
59 + app.secret_key = "test-secret"
60 +
61 + calls: list[str] = []
62 +
63 + class HelloHandler(WebSocketHandler):
64 + @classmethod
65 + def requires_auth(cls) -> bool:
66 + return False
67 +
68 + @classmethod
69 + def requires_csrf(cls) -> bool:
70 + return False
71 +
72 + @classmethod
73 + def get_event_types(cls) -> list[str]:
74 + return ["hello_request"]
75 +
76 + async def process_event(self, event_type: str, data: dict[str, Any], sid: str):
77 + calls.append(sid)
78 + return {"hello": True}
79 +
80 + HelloHandler._reset_instance_for_testing()
81 +
82 + sio = socketio.AsyncServer(async_mode="asgi", cors_allowed_origins="*", namespaces="*")
83 + lock = __import__("threading").RLock()
84 + manager = WebSocketManager(sio, lock)
85 +
86 + configure_websocket_namespaces(
87 + webapp=app,
88 + socketio_server=sio,
89 + websocket_manager=manager,
90 + handlers_by_namespace={
91 + "/state_sync": [HelloHandler.get_instance(sio, lock)],
92 + },
93 + )
94 +
95 + asgi_app = socketio.ASGIApp(sio)
96 +
97 + async with _run_asgi_app(asgi_app) as base_url:
98 + client = socketio.AsyncClient()
99 + await client.connect(
100 + base_url,
101 + namespaces=["/"],
102 + headers={"Origin": base_url},
103 + wait_timeout=2,
104 + )
105 + try:
106 + res_unknown = await client.call("unknown_event", {"x": 1}, namespace="/", timeout=2)
107 + assert res_unknown["results"][0]["ok"] is False
108 + assert res_unknown["results"][0]["error"]["code"] == "NO_HANDLERS"
109 +
110 + res_known_elsewhere = await client.call("hello_request", {"name": "x"}, namespace="/", timeout=2)
111 + assert res_known_elsewhere["results"][0]["ok"] is False
112 + assert res_known_elsewhere["results"][0]["error"]["code"] == "NO_HANDLERS"
113 + assert calls == []
114 + finally:
115 + await client.disconnect()
116 +
117 +
118 +@pytest.mark.asyncio
119 +async def test_root_namespace_fire_and_forget_does_not_invoke_application_handlers() -> None:
120 + """
121 + Fire-and-forget emits on `/` must not invoke any application handler.
122 + """
123 +
124 + from flask import Flask
125 + import socketio
126 +
127 + from python.helpers.websocket import WebSocketHandler
128 + from python.helpers.websocket_manager import WebSocketManager
129 + from run_ui import configure_websocket_namespaces
130 +
131 + app = Flask("test_ws_root_fire_and_forget")
132 + app.secret_key = "test-secret"
133 +
134 + calls: list[str] = []
135 +
136 + class SideEffectHandler(WebSocketHandler):
137 + @classmethod
138 + def requires_auth(cls) -> bool:
139 + return False
140 +
141 + @classmethod
142 + def requires_csrf(cls) -> bool:
143 + return False
144 +
145 + @classmethod
146 + def get_event_types(cls) -> list[str]:
147 + return ["hello_request"]
148 +
149 + async def process_event(self, event_type: str, data: dict[str, Any], sid: str):
150 + calls.append(sid)
151 + return {"ok": True}
152 +
153 + SideEffectHandler._reset_instance_for_testing()
154 +
155 + sio = socketio.AsyncServer(async_mode="asgi", cors_allowed_origins="*", namespaces="*")
156 + lock = __import__("threading").RLock()
157 + manager = WebSocketManager(sio, lock)
158 +
159 + configure_websocket_namespaces(
160 + webapp=app,
161 + socketio_server=sio,
162 + websocket_manager=manager,
163 + handlers_by_namespace={
164 + "/state_sync": [SideEffectHandler.get_instance(sio, lock)],
165 + },
166 + )
167 +
168 + asgi_app = socketio.ASGIApp(sio)
169 +
170 + async with _run_asgi_app(asgi_app) as base_url:
171 + client = socketio.AsyncClient()
172 + await client.connect(
173 + base_url,
174 + namespaces=["/"],
175 + headers={"Origin": base_url},
176 + wait_timeout=2,
177 + )
178 + try:
179 + await client.emit("hello_request", {"name": "x"}, namespace="/")
180 + await asyncio.sleep(0.1)
181 + assert calls == []
182 + finally:
183 + await client.disconnect()
tests/websocket_namespace_test_utils.py new
+56
@@ -0,0 +1,56 @@
1 +from __future__ import annotations
2 +
3 +from dataclasses import dataclass
4 +from typing import Any
5 +from unittest.mock import AsyncMock
6 +
7 +
8 +ConnectionIdentity = tuple[str, str] # (namespace, sid)
9 +
10 +
11 +def nsid(namespace: str, sid: str) -> ConnectionIdentity:
12 + return (namespace, sid)
13 +
14 +
15 +@dataclass(frozen=True)
16 +class SocketIOCall:
17 + args: tuple[Any, ...]
18 + kwargs: dict[str, Any]
19 +
20 + @property
21 + def namespace(self) -> str | None:
22 + value = self.kwargs.get("namespace")
23 + if value is None:
24 + return None
25 + if not isinstance(value, str):
26 + raise TypeError(f"Expected namespace to be str, got {type(value).__name__}")
27 + return value
28 +
29 +
30 +class FakeSocketIOServer:
31 + """
32 + Test double for python-socketio AsyncServer.
33 +
34 + Captures calls and surfaces the optional Socket.IO namespace dimension via recorded kwargs.
35 + """
36 +
37 + def __init__(self) -> None:
38 + self._emit_calls: list[SocketIOCall] = []
39 + self._disconnect_calls: list[SocketIOCall] = []
40 +
41 + self.emit = AsyncMock(side_effect=self._emit)
42 + self.disconnect = AsyncMock(side_effect=self._disconnect)
43 +
44 + async def _emit(self, *args: Any, **kwargs: Any) -> None:
45 + self._emit_calls.append(SocketIOCall(args=args, kwargs=dict(kwargs)))
46 +
47 + async def _disconnect(self, *args: Any, **kwargs: Any) -> None:
48 + self._disconnect_calls.append(SocketIOCall(args=args, kwargs=dict(kwargs)))
49 +
50 + @property
51 + def emit_calls(self) -> list[SocketIOCall]:
52 + return self._emit_calls
53 +
54 + @property
55 + def disconnect_calls(self) -> list[SocketIOCall]:
56 + return self._disconnect_calls
webui/components/chat/input/chat-bar-input.html
+3 -16
@@ -23,9 +23,9 @@
23 <!-- Container for textarea and expand button -->
24 <div id="chat-input-container" style="position: relative;">
25 <textarea id="chat-input" :placeholder="$store.chatInput.inputPlaceholder" rows="1"
26 - x-effect="$el.placeholder = $store.chatInput.inputPlaceholder"
26 @keydown.enter="if (!$event.shiftKey && !$event.isComposing && $event.keyCode !== 229) { $event.preventDefault(); $store.chatInput.sendMessage(); }"
28 - @input="$store.chatInput.adjustTextareaHeight()"></textarea>
27 + @input="$store.chatInput.adjustTextareaHeight()"
28 + x-model="$store.chatInput.message"></textarea>
29 <button id="expand-button" @click="$store.fullScreenInputModal.openModal()" aria-label="Expand input">
30 <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
31 <path d="M7 14H5v5h5v-2H7v-3zm-2-4h2V7h3V5H5v5zm12 7h-3v2h5v-5h-2v3zM14 5v2h3v3h2V5h-5z"/>
@@ -39,20 +39,7 @@
39 @click="$store.chatInput.sendMessage()"
40 :class="$store.chatInput.sendButtonClass"
41 :title="$store.chatInput.sendButtonTitle">
42 - <!-- Send all queued: double arrow -->
43 - <template x-if="$store.chatInput.sendButtonIcon === 'send-all'">
44 - <span class="material-symbols-outlined">keyboard_double_arrow_right</span>
45 - </template>
46 - <!-- Queue message: schedule send -->
47 - <template x-if="$store.chatInput.sendButtonIcon === 'queue'">
48 - <span class="material-symbols-outlined">schedule_send</span>
49 - </template>
50 - <!-- Normal send -->
51 - <template x-if="$store.chatInput.sendButtonIcon === 'send'">
52 - <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
53 - <path d="M25 20 L75 50 L25 80" fill="none" stroke="currentColor" stroke-width="15"></path>
54 - </svg>
55 - </template>
42 + <span class="material-symbols-outlined" x-text="$store.chatInput.sendButtonIcon"></span>
43 </button>
44
45 <!-- Microphone button -->
webui/components/chat/input/input-store.js
+28 -20
@@ -2,48 +2,50 @@ import { createStore } from "/js/AlpineStore.js";
2 import * as shortcuts from "/js/shortcuts.js";
3 import { store as fileBrowserStore } from "/components/modals/file-browser/file-browser-store.js";
4 import { store as messageQueueStore } from "/components/chat/message-queue/message-queue-store.js";
5 -import { store as chatTopStore } from "/components/chat/top-section/chat-top-store.js";
5 import { store as attachmentsStore } from "/components/chat/attachments/attachmentsStore.js";
6 +import { store as chatsStore } from "/components/sidebar/chats/chats-store.js";
7
8 const model = {
9 paused: false,
10 + message: "",
11
11 - get inputPlaceholder() {
12 - const input = document.getElementById("chat-input");
13 - const hasTypedText = !!input?.value?.trim();
14 - const hasAttachments = (attachmentsStore?.attachments?.length || 0) > 0;
12 + _getSendState() {
13 + const hasInput = this.message.trim() || attachmentsStore?.attachments?.length > 0;
14 const hasQueue = !!messageQueueStore?.hasQueue;
15 + const running = !!chatsStore.selectedContext?.running;
16 +
17 + if (hasQueue && !hasInput) return "all";
18 + if ((running || hasQueue) && hasInput) return "queue";
19 + return "normal";
20 + },
21
17 - if (hasQueue && !hasTypedText && !hasAttachments)
18 - return "Press Enter to send queued messages";
22 + get inputPlaceholder() {
23 + const state = this._getSendState();
24 + if (state === "all") return "Press Enter to send queued messages";
25 return "Type your message here...";
26 },
27
28 // Computed: send button icon type
29 get sendButtonIcon() {
24 - const input = document.getElementById("chat-input");
25 - const hasInput = input?.value?.trim() || attachmentsStore?.attachments?.length > 0;
26 - const hasQueue = messageQueueStore?.hasQueue;
27 - const running = chatTopStore?.running;
28 -
29 - if (hasQueue && !hasInput) return "send-all";
30 - if ((running || hasQueue) && hasInput) return "queue";
30 + const state = this._getSendState();
31 + if (state === "all") return "send_and_archive";
32 + if (state === "queue") return "schedule_send";
33 return "send";
34 },
35
36 // Computed: send button CSS class
37 get sendButtonClass() {
36 - const icon = this.sendButtonIcon;
37 - if (icon === "send-all") return "send-queue";
38 - if (icon === "queue") return "send-queue";
38 + const state = this._getSendState();
39 + if (state === "all") return "send-queue send-all";
40 + if (state === "queue") return "send-queue queue";
41 return "";
42 },
43
44 // Computed: send button title
45 get sendButtonTitle() {
44 - const icon = this.sendButtonIcon;
45 - if (icon === "send-all") return "Send all queued messages";
46 - if (icon === "queue") return "Add to queue";
46 + const state = this._getSendState();
47 + if (state === "all") return "Send all queued messages";
48 + if (state === "queue") return "Add to queue";
49 return "Send message";
50 },
51
@@ -198,6 +200,12 @@ const model = {
200 }
201 await fileBrowserStore.open(path);
202 },
203 +
204 + reset() {
205 + this.message = "";
206 + attachmentsStore.clearAttachments();
207 + this.adjustTextareaHeight();
208 + }
209 };
210
211 const store = createStore("chatInput", model);
webui/components/chat/input/progress.html
+5 -3
@@ -11,6 +11,11 @@
11 <span id="progress-bar-i">|></span><span id="progress-bar"></span>
12 </h4>
13 <div id="progress-bar-right">
14 + <h4 id="progress-bar-stop-speech" x-data x-cloak x-show="$store.speech.isSpeaking">
15 + <span id="stop-speech" @click="$store.speech.stop()" style="cursor: pointer" title="Stop Speech" aria-label="Stop Speech">
16 + <span class="icon material-symbols-outlined">volume_off</span>
17 + </span>
18 + </h4>
19 <div id="chat-nav-buttons" aria-label="Chat navigation">
20 <button class="btn-icon-action" title="Scroll to top" x-on:click="$store.chatNavigation.scrollToTop()">
21 <span class="material-symbols-outlined">vertical_align_top</span>
@@ -25,9 +30,6 @@
30 <span class="material-symbols-outlined">vertical_align_bottom</span>
31 </button>
32 </div>
28 - <h4 id="progress-bar-stop-speech" x-data x-cloak x-show="$store.speech.isSpeaking">
29 - <span id="stop-speech" @click="$store.speech.stop()" style="cursor: pointer">Stop Speech</span>
30 - </h4>
33 </div>
34 </div>
35
webui/components/chat/message-queue/message-queue-store.js
+116 -39
@@ -1,13 +1,25 @@
1 import { createStore } from "/js/AlpineStore.js";
2 import { store as navStore } from "/components/chat/navigation/chat-navigation-store.js";
3 import * as api from "/js/api.js";
4 -import { toastFrontendInfo, NotificationPriority } from "/components/notifications/notification-store.js";
4 +import {
5 + toastFrontendInfo,
6 + NotificationPriority,
7 +} from "/components/notifications/notification-store.js";
8 import { sleep } from "/js/sleep.js";
9 +import { store as chatsStore } from "/components/sidebar/chats/chats-store.js";
10
11 const model = {
8 - items: [],
12 + // items: [],
13 + get items() {
14 + return chatsStore.selectedContext?.message_queue || [];
15 + },
16 +
17 pendingItems: [], // Local pending items (uploading to queue)
18
19 + _pendingAddOps: {},
20 +
21 + _lastAddToQueuePromise: Promise.resolve(),
22 +
23 _getQueueScrollerEl() {
24 return document.querySelector(".queue-preview .queue-items");
25 },
@@ -44,51 +56,116 @@ const model = {
56 if (!context) return false;
57
58 // Generate a temporary ID for pending item
47 - const tempId = `pending-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
59 + const tempId = `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
60 const pendingItem = {
61 id: tempId,
50 - text: text || "(attachment only)",
51 - attachments: attachments.map(a => a.name || a.file?.name || "file"),
62 + text: text.substring(0, 200) || "(attachment only)",
63 + attachments: attachments.map((a) => a.name || a.file?.name || "file"),
64 pending: true,
65 };
66
67 + const controller =
68 + typeof AbortController !== "undefined" ? new AbortController() : null;
69 + this._pendingAddOps = {
70 + ...this._pendingAddOps,
71 + [tempId]: {
72 + canceled: false,
73 + controller,
74 + },
75 + };
76 +
77 // Add to pending immediately for UI feedback
78 this.pendingItems = [...this.pendingItems, pendingItem];
79 this.scrollQueueToBottom();
80
59 - try {
60 - let filenames = [];
61 - if (attachments.length > 0) {
62 - const formData = new FormData();
63 - for (const att of attachments) {
64 - formData.append("file", att.file || att);
81 + const run = async () => {
82 + const op = this._pendingAddOps?.[tempId];
83 + if (!op || op.canceled) {
84 + this._pendingAddOps = { ...this._pendingAddOps };
85 + delete this._pendingAddOps[tempId];
86 + return false;
87 + }
88 +
89 + try {
90 + let filenames = [];
91 + if (attachments.length > 0) {
92 + const formData = new FormData();
93 + for (const att of attachments) {
94 + formData.append("file", att.file || att);
95 + }
96 + const resp = await api.fetchApi("/upload", {
97 + method: "POST",
98 + body: formData,
99 + signal: op.controller ? op.controller.signal : undefined,
100 + });
101 + if (resp.ok) {
102 + const result = await resp.json();
103 + filenames = result.filenames || [];
104 + }
105 }
66 - const resp = await api.fetchApi("/upload", { method: "POST", body: formData });
67 - if (resp.ok) {
68 - const result = await resp.json();
69 - filenames = result.filenames || [];
106 +
107 + const resp = await api.fetchApi("/message_queue_add", {
108 + method: "POST",
109 + headers: {
110 + "Content-Type": "application/json",
111 + },
112 + credentials: "same-origin",
113 + body: JSON.stringify({
114 + context,
115 + text,
116 + attachments: filenames,
117 + item_id: tempId,
118 + }),
119 + signal: op.controller ? op.controller.signal : undefined,
120 + });
121 +
122 + if (!resp || !resp.ok) {
123 + return false;
124 }
71 - }
72 - const response = await api.callJsonApi("/message_queue_add", { context, text, attachments: filenames });
125
74 - const serverId = response?.item_id;
75 - if (serverId) {
76 - this.pendingItems = this.pendingItems.map((p) =>
77 - p.id === tempId ? { ...p, serverId } : p,
78 - );
126 + const response = await resp.json();
127 +
128 + return response?.ok || false;
129 + } catch (e) {
130 + if (e?.name !== "AbortError") {
131 + console.error("Failed to queue message:", e);
132 + }
133 + return false;
134 + } finally {
135 + this._pendingAddOps = { ...this._pendingAddOps };
136 + delete this._pendingAddOps[tempId];
137 }
80 - return response?.ok || false;
81 - } catch (e) {
82 - console.error("Failed to queue message:", e);
83 - return false;
84 - }
138 + };
139 +
140 + // Chain promises to ensure sequential execution
141 + const previous = this._lastAddToQueuePromise || Promise.resolve();
142 + const chained = previous.catch(() => false).then(run);
143 + this._lastAddToQueuePromise = chained.catch(() => false);
144 + return await chained;
145 },
146
147 async removeItem(itemId) {
148 const context = globalThis.getContext?.();
149 if (!context) return;
150 +
151 + const isPending = this.pendingItems.some((p) => p.id === itemId);
152 + if (isPending) {
153 + const op = this._pendingAddOps?.[itemId];
154 + if (op) {
155 + op.canceled = true;
156 + if (op.controller) {
157 + op.controller.abort();
158 + }
159 + }
160 + this.pendingItems = this.pendingItems.filter((p) => p.id !== itemId);
161 + return;
162 + }
163 +
164 try {
91 - await api.callJsonApi("/message_queue_remove", { context, item_id: itemId });
165 + await api.callJsonApi("/message_queue_remove", {
166 + context,
167 + item_id: itemId,
168 + });
169 } catch (e) {
170 console.error("Failed to remove from queue:", e);
171 }
@@ -108,7 +185,10 @@ const model = {
185 const context = globalThis.getContext?.();
186 if (!context) return;
187 try {
111 - await api.callJsonApi("/message_queue_send", { context, item_id: itemId });
188 + await api.callJsonApi("/message_queue_send", {
189 + context,
190 + item_id: itemId,
191 + });
192 } catch (e) {
193 console.error("Failed to send queued message:", e);
194 }
@@ -143,18 +223,15 @@ const model = {
223 }
224 },
225
146 - updateFromPoll(queue) {
147 - this.items = queue || [];
226 + updateFromPoll() {
227 + // this.items = queue || [];
228
229 if (this.pendingItems.length > 0) {
150 - const hasPendingWithServerId = this.pendingItems.some((p) => p.serverId);
151 - if (hasPendingWithServerId) {
152 - const serverIds = new Set(this.items.map((i) => i.id).filter(Boolean));
153 - this.pendingItems = this.pendingItems.filter((p) => {
154 - if (!p.serverId) return true;
155 - return !serverIds.has(p.serverId);
156 - });
157 - }
230 + const serverIds = new Set(this.items.map((i) => i.id).filter(Boolean));
231 + this.pendingItems = this.pendingItems.filter((p) => {
232 + if (!p.id) return true;
233 + return !serverIds.has(p.id);
234 + });
235 }
236 // this.scrollQueueToBottom();
237 },
webui/components/chat/message-queue/message-queue.html
+2 -1
@@ -232,8 +232,9 @@
232 opacity: 0.6;
233 }
234
235 - .queue-item-pending .queue-item-actions {
235 + .queue-item-pending .queue-action-btn.send {
236 visibility: hidden !important;
237 + pointer-events: none !important;
238 }
239
240 .queue-item-pending-icon {
webui/components/chat/top-section/chat-top-store.js
-1
@@ -4,7 +4,6 @@ import { createStore } from "/js/AlpineStore.js";
4 const model = {
5 connected: false,
6 progressActive: false, // true when progress bar is active
7 - running: false, // true when agent is running (from context.is_running())
7 };
8
9 // convert it to alpine store
webui/components/chat/top-section/chat-top.html
+2 -12
@@ -18,17 +18,7 @@
18 <!-- Time and Date -->
19 <div id="time-date-container">
20 <div id="time-date"></div>
21 - <div class="status-icon">
22 - <svg viewBox="0 0 30 30">
23 - <!-- Connected State (filled circle) -->
24 - <circle class="connected-circle" cx="15" cy="15" r="8"
25 - x-bind:fill="$store.chatTop.connected ? '#00c340' : 'none'" x-bind:opacity="$store.chatTop.connected ? 1 : 0" />
26 -
27 - <!-- Disconnected State (outline circle) -->
28 - <circle class="disconnected-circle" cx="15" cy="15" r="12" fill="none" stroke="#e40138"
29 - stroke-width="3" x-bind:opacity="$store.chatTop.connected ? 0 : 1" />
30 - </svg>
31 - </div>
21 + <x-component path="sync/sync-status.html"></x-component>
22 <!-- Notification Toggle positioned next to time-date -->
23 <x-component path="notifications/notification-icons.html"></x-component>
24 <!-- Project Selector -->
@@ -40,4 +30,4 @@
30
31 </body>
32
43 -</html>
\ No newline at end of file
33 +</html>
webui/components/notifications/notification-store.js
+7 -6
@@ -592,10 +592,10 @@ const model = {
592 // Add to bottom of stack (newest at bottom)
593 this.toastStack.push(toast);
594
595 - // Enforce max stack limit (remove oldest from top)
596 - if (this.toastStack.length > this.maxToastStack) {
597 - const removed = this.toastStack.shift(); // Remove from top
598 - if (removed.autoRemoveTimer) {
595 + // Enforce max stack limit (remove oldest).
596 + while (this.toastStack.length > maxToasts) {
597 + const removed = this.toastStack.shift();
598 + if (removed?.autoRemoveTimer) {
599 clearTimeout(removed.autoRemoveTimer);
600 }
601 }
@@ -646,7 +646,7 @@ const model = {
646 console.log("Backend disconnected, showing as frontend-only toast");
647 }
648 }
649 -
649 +
650 // Fallback to frontend-only toast
651 return this.addFrontendToastOnly(
652 type,
@@ -683,7 +683,8 @@ const model = {
683 title = "Warning",
684 display_time = 5,
685 group = "",
686 - priority = defaultPriority
686 + priority = defaultPriority,
687 + frontendOnly = false
688 ) {
689 return await this.addFrontendToast(
690 NotificationType.WARNING,
webui/components/settings/developer/dev.html
+82
@@ -79,6 +79,88 @@
79 </div>
80 </div>
81 </template>
82 +
83 + <div class="field">
84 + <div class="field-label">
85 + <div class="field-title">Broadcast server restart event</div>
86 + <div class="field-description">
87 + Emit a fire-and-forget <code>server_restart</code> broadcast to clients after the server starts.
88 + </div>
89 + </div>
90 + <div class="field-control">
91 + <label class="toggle">
92 + <input type="checkbox" x-model="$store.settingsStore.settings.websocket_server_restart_enabled" />
93 + <span class="toggler"></span>
94 + </label>
95 + </div>
96 + </div>
97 +
98 + <div class="field">
99 + <div class="field-label">
100 + <div class="field-title">Enable uvicorn access logs</div>
101 + <div class="field-description">
102 + Temporarily enable uvicorn access logs for debugging WebSocket transport issues (default off).
103 + </div>
104 + <template
105 + x-if="$store.settingsStore.additional?.runtime_settings &&
106 + $store.settingsStore.settings?.uvicorn_access_logs_enabled !==
107 + $store.settingsStore.additional.runtime_settings.uvicorn_access_logs_enabled"
108 + >
109 + <div class="field-description">
110 + Applies after backend restart.
111 + </div>
112 + </template>
113 + </div>
114 + <div class="field-control">
115 + <label class="toggle">
116 + <input type="checkbox" x-model="$store.settingsStore.settings.uvicorn_access_logs_enabled" />
117 + <span class="toggler"></span>
118 + </label>
119 + </div>
120 + </div>
121 +
122 + <template x-if="!$store.settingsStore.additional?.is_dockerized">
123 + <div>
124 + <div class="section-title">Testing</div>
125 + <div class="section-description">
126 + Utilities for validating WebSocket infrastructure in development environments.
127 + </div>
128 +
129 + <div class="field">
130 + <div class="field-label">
131 + <div class="field-title">WebSocket Test Harness</div>
132 + <div class="field-description">
133 + Open the developer harness to run automated and manual WebSocket validation suites.
134 + </div>
135 + </div>
136 + <div class="field-control">
137 + <button
138 + class="btn btn-field"
139 + @click="openModal('settings/developer/websocket-tester.html');"
140 + >
141 + Open Harness
142 + </button>
143 + </div>
144 + </div>
145 +
146 + <div class="field">
147 + <div class="field-label">
148 + <div class="field-title">WebSocket Event Console</div>
149 + <div class="field-description">
150 + Inspect inbound and outbound envelopes and lifecycle events in real time (development only).
151 + </div>
152 + </div>
153 + <div class="field-control">
154 + <button
155 + class="btn btn-field"
156 + @click="openModal('settings/developer/websocket-event-console.html');"
157 + >
158 + Open Console
159 + </button>
160 + </div>
161 + </div>
162 + </div>
163 + </template>
164 </div>
165 </template>
166 </div>
webui/components/settings/developer/websocket-event-console-store.js new
+247
@@ -0,0 +1,247 @@
1 +import { createStore } from "/js/AlpineStore.js";
2 +import { getNamespacedClient } from "/js/websocket.js";
3 +import { store as notificationStore } from "/components/notifications/notification-store.js";
4 +
5 +const websocket = getNamespacedClient("/dev_websocket_test");
6 +
7 +const DIAGNOSTIC_EVENT = "ws_dev_console_event";
8 +const SUBSCRIBE_EVENT = "ws_event_console_subscribe";
9 +const UNSUBSCRIBE_EVENT = "ws_event_console_unsubscribe";
10 +const MAX_ENTRIES = 200;
11 +const CAPTURE_ENABLED_KEY = "a0.websocket_event_console.capture_enabled";
12 +
13 +const model = {
14 + entries: [],
15 + isEnabled: false,
16 + captureEnabled: false,
17 + subscriptionActive: false,
18 + showHandledOnly: false,
19 + lastError: null,
20 + _consoleCallback: null,
21 + _lifecycleBound: false,
22 + _entrySeq: 0,
23 +
24 + init() {
25 + this.isEnabled = Boolean(window.runtimeInfo?.isDevelopment);
26 + if (!this.isEnabled) {
27 + this.captureEnabled = false;
28 + return;
29 + }
30 +
31 + this._bindLifecycle();
32 + this.captureEnabled = this._loadCaptureEnabled();
33 + },
34 +
35 + onOpen() {
36 + // `init()` is called once when the store is registered; `onOpen()` is called
37 + // every time the component is displayed (modal open).
38 + this.init();
39 + if (!this.isEnabled) return;
40 + if (this.captureEnabled) {
41 + this.attach({ notify: false });
42 + }
43 + },
44 +
45 + _bindLifecycle() {
46 + if (this._lifecycleBound) return;
47 + this._lifecycleBound = true;
48 +
49 + websocket.onDisconnect(() => {
50 + // Watcher subscriptions are per-sid and cleared server-side on disconnect.
51 + this.subscriptionActive = false;
52 + });
53 +
54 + websocket.onConnect(() => {
55 + if (!this.captureEnabled) return;
56 + // Re-subscribe after reconnect (server watcher set is per-sid).
57 + this._subscribe({ notify: false });
58 + });
59 + },
60 +
61 + _loadCaptureEnabled() {
62 + try {
63 + const raw = window.localStorage?.getItem(CAPTURE_ENABLED_KEY);
64 + return raw === "1" || raw === "true";
65 + } catch (error) {
66 + return false;
67 + }
68 + },
69 +
70 + _persistCaptureEnabled(enabled) {
71 + try {
72 + window.localStorage?.setItem(CAPTURE_ENABLED_KEY, enabled ? "1" : "0");
73 + } catch (error) {
74 + // Ignore storage failures (private mode, etc).
75 + }
76 + },
77 +
78 + async startCapture() {
79 + await this.setCaptureEnabled(true, { notify: true });
80 + },
81 +
82 + async stopCapture() {
83 + await this.setCaptureEnabled(false, { notify: true });
84 + },
85 +
86 + async setCaptureEnabled(enabled, { notify = true } = {}) {
87 + if (!this.isEnabled) return;
88 +
89 + const desired = Boolean(enabled);
90 + if (this.captureEnabled === desired) {
91 + if (desired) {
92 + await this.attach({ notify: false });
93 + }
94 + return;
95 + }
96 +
97 + this.captureEnabled = desired;
98 + this._persistCaptureEnabled(desired);
99 +
100 + if (desired) {
101 + await this.attach({ notify });
102 + return;
103 + }
104 +
105 + await this.detach({ notify });
106 + },
107 +
108 + async _subscribe({ notify = true } = {}) {
109 + if (!this.isEnabled) return;
110 + if (this.subscriptionActive) return;
111 +
112 + try {
113 + await websocket.request(SUBSCRIBE_EVENT, {
114 + requestedAt: new Date().toISOString(),
115 + });
116 + this.subscriptionActive = true;
117 + this.lastError = null;
118 +
119 + if (notify) {
120 + notificationStore.frontendInfo(
121 + "WebSocket diagnostics capture enabled",
122 + "Event Console",
123 + 4,
124 + );
125 + }
126 + } catch (error) {
127 + this.handleError(error);
128 + throw error;
129 + }
130 + },
131 +
132 + async attach({ notify = true } = {}) {
133 + if (!this.isEnabled) return;
134 + if (this.subscriptionActive && this._consoleCallback) return;
135 +
136 + try {
137 + await websocket.connect();
138 +
139 + if (!this._consoleCallback) {
140 + this._consoleCallback = (envelope) => {
141 + try {
142 + this.addEntry(envelope);
143 + } catch (error) {
144 + this.handleError(error);
145 + }
146 + };
147 +
148 + await websocket.on(DIAGNOSTIC_EVENT, this._consoleCallback);
149 + }
150 +
151 + await this._subscribe({ notify });
152 + } catch (error) {
153 + this.handleError(error);
154 + throw error;
155 + }
156 + },
157 +
158 + async detach({ notify = false } = {}) {
159 + if (this._consoleCallback) {
160 + websocket.off(DIAGNOSTIC_EVENT, this._consoleCallback);
161 + this._consoleCallback = null;
162 + }
163 + if (this.subscriptionActive) {
164 + try {
165 + await websocket.request(UNSUBSCRIBE_EVENT, {});
166 + } catch (error) {
167 + this.handleError(error);
168 + }
169 + }
170 + this.subscriptionActive = false;
171 +
172 + if (notify) {
173 + notificationStore.frontendInfo(
174 + "WebSocket diagnostics capture disabled",
175 + "Event Console",
176 + 3,
177 + );
178 + }
179 + },
180 +
181 + async reconnect() {
182 + if (!this.isEnabled) return;
183 + if (!this.captureEnabled) {
184 + await this.startCapture();
185 + return;
186 + }
187 +
188 + await this.detach({ notify: false });
189 + await this.attach({ notify: true });
190 + },
191 +
192 + handleError(error) {
193 + const message = error?.message || String(error || "Unknown error");
194 + this.lastError = message;
195 + notificationStore.frontendError(message, "WebSocket Event Console", 6);
196 + },
197 +
198 + addEntry(envelope) {
199 + const payload = envelope?.data || {};
200 + const entry = {
201 + kind: payload.kind || "unknown",
202 + sourceNamespace: payload.sourceNamespace || payload.namespace || null,
203 + eventType: payload.eventType || payload.event || "unknown",
204 + eventId: envelope?.eventId || null,
205 + sid: payload.sid || null,
206 + correlationId: payload.correlationId || envelope?.correlationId || null,
207 + timestamp: payload.timestamp || envelope?.ts || new Date().toISOString(),
208 + handlerId: payload.handlerId || envelope?.handlerId || "WebSocketManager",
209 + resultSummary: payload.resultSummary || {},
210 + payloadSummary: payload.payloadSummary || {},
211 + delivered: payload.delivered ?? null,
212 + buffered: payload.buffered ?? null,
213 + targets: Array.isArray(payload.targets) ? payload.targets : [],
214 + targetCount: payload.targetCount ?? null,
215 + };
216 + if (!entry.eventId) {
217 + this._entrySeq += 1;
218 + entry.eventId = `evt_${this._entrySeq}`;
219 + }
220 + entry.hasHandlers =
221 + (entry.resultSummary?.handlerCount ?? entry.resultSummary?.ok ?? 0) > 0;
222 +
223 + this.entries.push(entry);
224 + if (this.entries.length > MAX_ENTRIES) {
225 + this.entries.shift();
226 + }
227 + },
228 +
229 + filteredEntries() {
230 + if (!this.showHandledOnly) {
231 + return this.entries;
232 + }
233 + return this.entries.filter(
234 + (entry) =>
235 + entry.kind !== "inbound" ||
236 + entry.hasHandlers ||
237 + entry.resultSummary?.error > 0,
238 + );
239 + },
240 +
241 + clear() {
242 + this.entries = [];
243 + },
244 +};
245 +
246 +const store = createStore("websocketEventConsoleStore", model);
247 +export { store };
webui/components/settings/developer/websocket-event-console.html new
+271
@@ -0,0 +1,271 @@
1 +<html>
2 +<head>
3 + <title>WebSocket Event Console</title>
4 + <script type="module">
5 + import { store as websocketEventConsoleStore } from "/components/settings/developer/websocket-event-console-store.js";
6 + </script>
7 +</head>
8 +<body>
9 + <div x-data>
10 + <template x-if="window.runtimeInfo?.isDevelopment && $store.websocketEventConsoleStore">
11 + <div
12 + class="ws-console"
13 + x-create="$store.websocketEventConsoleStore.onOpen()"
14 + x-destroy="$store.websocketEventConsoleStore.detach({ notify: false })"
15 + >
16 + <h2>WebSocket Event Console</h2>
17 + <p class="ws-description">
18 + Capture inbound/outbound WebSocket envelopes, lifecycle broadcasts, and diagnostic metadata while capture is enabled.
19 + This modal is the display layer for the bounded in-memory buffer.
20 + </p>
21 +
22 + <div class="ws-controls">
23 + <button
24 + class="btn"
25 + x-show="!$store.websocketEventConsoleStore.captureEnabled"
26 + @click="$store.websocketEventConsoleStore.startCapture()"
27 + >
28 + Start capture
29 + </button>
30 + <button
31 + class="btn"
32 + x-show="$store.websocketEventConsoleStore.captureEnabled"
33 + @click="$store.websocketEventConsoleStore.stopCapture()"
34 + >
35 + Stop capture
36 + </button>
37 + <button
38 + class="btn"
39 + :disabled="!$store.websocketEventConsoleStore.captureEnabled"
40 + @click="$store.websocketEventConsoleStore.reconnect()"
41 + >
42 + Resubscribe
43 + </button>
44 + <button class="btn" @click="$store.websocketEventConsoleStore.clear()">Clear</button>
45 + <label class="ws-toggle">
46 + <input
47 + type="checkbox"
48 + x-model="$store.websocketEventConsoleStore.showHandledOnly"
49 + />
50 + Show inbound events with handlers only
51 + </label>
52 + </div>
53 +
54 + <div class="ws-status">
55 + <span
56 + class="badge"
57 + :class="($store.websocketEventConsoleStore.captureEnabled && $store.websocketEventConsoleStore.subscriptionActive) ? 'badge-success' : 'badge-warning'"
58 + x-text="!$store.websocketEventConsoleStore.captureEnabled ? 'Capture OFF' : ($store.websocketEventConsoleStore.subscriptionActive ? 'Capture ON' : 'Capture ON (not subscribed)')"
59 + ></span>
60 + <span
61 + class="ws-error"
62 + x-show="$store.websocketEventConsoleStore.lastError"
63 + x-text="$store.websocketEventConsoleStore.lastError"
64 + ></span>
65 + </div>
66 +
67 + <div class="ws-entries">
68 + <template
69 + x-for="entry in $store.websocketEventConsoleStore.filteredEntries().slice().reverse()"
70 + :key="entry.eventId"
71 + >
72 + <div class="ws-entry">
73 + <div class="ws-entry-header">
74 + <span class="badge" :class="`badge-${entry.kind}`" x-text="entry.kind"></span>
75 + <strong x-text="entry.eventType"></strong>
76 + <span class="ws-meta" x-text="entry.timestamp"></span>
77 + </div>
78 + <div class="ws-entry-body">
79 + <div class="ws-row">
80 + <div>
81 + <span class="ws-label">Correlation:</span>
82 + <span x-text="entry.correlationId || '—'"></span>
83 + </div>
84 + <div>
85 + <span class="ws-label">SID:</span>
86 + <span x-text="entry.sid || (entry.targets && entry.targets.join(', ') ) || '—'"></span>
87 + </div>
88 + <div>
89 + <span class="ws-label">Handlers:</span>
90 + <span
91 + x-text="entry.resultSummary?.handlerCount ?? entry.resultSummary?.handlers?.length ?? 0"
92 + ></span>
93 + <template x-if="entry.resultSummary?.ok || entry.resultSummary?.error">
94 + <span class="ws-inline-summary">
95 + <span>OK: <span x-text="entry.resultSummary.ok || 0"></span></span>
96 + <span>Errors: <span x-text="entry.resultSummary.error || 0"></span></span>
97 + </span>
98 + </template>
99 + </div>
100 + </div>
101 +
102 + <div class="ws-json-grid">
103 + <div>
104 + <h4>Payload Summary</h4>
105 + <pre x-text="JSON.stringify(entry.payloadSummary || {}, null, 2)"></pre>
106 + </div>
107 + <div>
108 + <h4>Result Summary</h4>
109 + <pre x-text="JSON.stringify(entry.resultSummary || {}, null, 2)"></pre>
110 + </div>
111 + </div>
112 + </div>
113 + </div>
114 + </template>
115 + </div>
116 + </div>
117 + </template>
118 +
119 + <template x-if="!window.runtimeInfo?.isDevelopment">
120 + <div class="ws-console ws-disabled">
121 + <h2>WebSocket Event Console</h2>
122 + <p class="ws-description">
123 + The event console is available only when Agent Zero runs in development mode.
124 + </p>
125 + </div>
126 + </template>
127 + </div>
128 +
129 + <style>
130 + .ws-console {
131 + display: flex;
132 + flex-direction: column;
133 + gap: 1rem;
134 + color: var(--color-text-primary);
135 + }
136 +
137 + .ws-description {
138 + margin: 0;
139 + color: var(--color-text-secondary);
140 + }
141 +
142 + .ws-controls {
143 + display: flex;
144 + align-items: center;
145 + gap: 0.75rem;
146 + flex-wrap: wrap;
147 + }
148 +
149 + .ws-toggle {
150 + display: flex;
151 + align-items: center;
152 + gap: 0.4rem;
153 + font-size: 0.9rem;
154 + color: var(--color-text-secondary);
155 + }
156 +
157 + .ws-status {
158 + display: flex;
159 + align-items: center;
160 + gap: 0.75rem;
161 + }
162 +
163 + .ws-error {
164 + color: var(--color-danger);
165 + font-size: 0.9rem;
166 + }
167 +
168 + .ws-entries {
169 + display: flex;
170 + flex-direction: column;
171 + gap: 0.75rem;
172 + max-height: 60vh;
173 + overflow-y: auto;
174 + }
175 +
176 + .ws-entry {
177 + border: 1px solid var(--color-border);
178 + border-radius: 6px;
179 + padding: 0.75rem;
180 + background: var(--color-bg-secondary);
181 + }
182 +
183 + .ws-entry-header {
184 + display: flex;
185 + align-items: center;
186 + gap: 0.5rem;
187 + flex-wrap: wrap;
188 + }
189 +
190 + .ws-entry-body {
191 + margin-top: 0.5rem;
192 + display: flex;
193 + flex-direction: column;
194 + gap: 0.5rem;
195 + }
196 +
197 + .ws-row {
198 + display: flex;
199 + flex-wrap: wrap;
200 + gap: 1rem;
201 + font-size: 0.9rem;
202 + }
203 +
204 + .ws-label {
205 + font-weight: 600;
206 + margin-right: 0.25rem;
207 + }
208 +
209 + .ws-inline-summary {
210 + display: inline-flex;
211 + gap: 0.5rem;
212 + margin-left: 0.5rem;
213 + }
214 +
215 + .ws-json-grid {
216 + display: grid;
217 + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
218 + gap: 0.5rem;
219 + }
220 +
221 + .ws-json-grid pre {
222 + background: var(--color-bg-primary);
223 + border: 1px solid var(--color-border);
224 + border-radius: 4px;
225 + padding: 0.5rem;
226 + font-size: 0.8rem;
227 + max-height: 160px;
228 + overflow: auto;
229 + }
230 +
231 + .badge {
232 + padding: 0.1rem 0.4rem;
233 + border-radius: 3px;
234 + font-size: 0.75rem;
235 + text-transform: uppercase;
236 + letter-spacing: 0.03em;
237 + }
238 +
239 + .badge-success {
240 + background: var(--color-success, #16a34a);
241 + color: var(--color-bg-primary);
242 + }
243 +
244 + .badge-warning {
245 + background: var(--color-warning, #f59e0b);
246 + color: var(--color-bg-primary);
247 + }
248 +
249 + .badge-inbound {
250 + background: var(--color-accent, #6366f1);
251 + color: var(--color-bg-primary);
252 + }
253 +
254 + .badge-outbound {
255 + background: var(--color-info, #0ea5e9);
256 + color: var(--color-bg-primary);
257 + }
258 +
259 + .badge-lifecycle {
260 + background: var(--color-success, #16a34a);
261 + color: var(--color-bg-primary);
262 + }
263 +
264 + .ws-disabled {
265 + border: 1px dashed var(--color-border);
266 + padding: 1rem;
267 + border-radius: 6px;
268 + }
269 + </style>
270 +</body>
271 +</html>
webui/components/settings/developer/websocket-test-store.js new
+924
@@ -0,0 +1,924 @@
1 +import { createStore } from "/js/AlpineStore.js";
2 +import {
3 + getNamespacedClient,
4 + createCorrelationId,
5 + validateServerEnvelope,
6 +} from "/js/websocket.js";
7 +import { store as notificationStore } from "/components/notifications/notification-store.js";
8 +import { store as chatsStore } from "/components/sidebar/chats/chats-store.js";
9 +import { store as syncStore } from "/components/sync/sync-store.js";
10 +
11 +const MAX_PAYLOAD_BYTES = 50 * 1024 * 1024;
12 +const TOAST_DURATION = 5;
13 +
14 +const websocket = getNamespacedClient("/dev_websocket_test");
15 +const stateSocket = getNamespacedClient("/state_sync");
16 +
17 +function now() {
18 + return new Date().toISOString();
19 +}
20 +
21 +function payloadSize(value) {
22 + try {
23 + return new TextEncoder().encode(JSON.stringify(value ?? null)).length;
24 + } catch (_error) {
25 + return String(value ?? "").length * 2;
26 + }
27 +}
28 +
29 +function clientForEventType(eventType) {
30 + if (typeof eventType === "string" && eventType.startsWith("state_")) {
31 + return stateSocket;
32 + }
33 + return websocket;
34 +}
35 +
36 +async function showToast(type, message, title) {
37 + const normalized = (type || "info").toLowerCase();
38 + switch (normalized) {
39 + case "error":
40 + return notificationStore.addFrontendToastOnly(
41 + "error",
42 + message,
43 + title || "Error",
44 + TOAST_DURATION,
45 + "ws-harness",
46 + 10,
47 + );
48 + case "success":
49 + return notificationStore.addFrontendToastOnly(
50 + "success",
51 + message,
52 + title || "Success",
53 + TOAST_DURATION,
54 + "ws-harness",
55 + 10,
56 + );
57 + case "warning":
58 + return notificationStore.addFrontendToastOnly(
59 + "warning",
60 + message,
61 + title || "Warning",
62 + TOAST_DURATION,
63 + "ws-harness",
64 + 10,
65 + );
66 + case "info":
67 + default:
68 + return notificationStore.addFrontendToastOnly(
69 + "info",
70 + message,
71 + title || "Info",
72 + TOAST_DURATION,
73 + "ws-harness",
74 + 10,
75 + );
76 + }
77 +}
78 +
79 +function withTimeout(promise, timeoutMs, label) {
80 + const normalizedTimeout = Number(timeoutMs);
81 + if (!Number.isFinite(normalizedTimeout) || normalizedTimeout <= 0) {
82 + return Promise.resolve(promise);
83 + }
84 + return new Promise((resolve, reject) => {
85 + const timer = setTimeout(() => {
86 + reject(new Error(`${label} timed out after ${normalizedTimeout}ms`));
87 + }, normalizedTimeout);
88 + Promise.resolve(promise).then(
89 + (value) => {
90 + clearTimeout(timer);
91 + resolve(value);
92 + },
93 + (error) => {
94 + clearTimeout(timer);
95 + reject(error);
96 + },
97 + );
98 + });
99 +}
100 +
101 +const model = {
102 + logs: "",
103 + running: false,
104 + manualRunning: false,
105 + subscriptionCount: 0,
106 + lastAggregated: null,
107 + receivedBroadcasts: [],
108 + isEnabled: false,
109 + _serverRestartHandler: null,
110 + _subscriptionHandlers: null,
111 + _broadcastSeq: 0,
112 +
113 + init() {
114 + this.isEnabled = Boolean(window.runtimeInfo?.isDevelopment);
115 + },
116 +
117 + onOpen() {
118 + // `init()` is called once when the store is registered; `onOpen()` is called
119 + // every time the component is displayed (modal open).
120 + this.init();
121 +
122 + if (this.isEnabled) {
123 + this.appendLog("WebSocket tester harness ready.");
124 + if (this._serverRestartHandler) {
125 + websocket.off("server_restart", this._serverRestartHandler);
126 + }
127 + this._serverRestartHandler = (payload) => {
128 + try {
129 + const envelope = validateServerEnvelope(payload);
130 + this.appendLog(
131 + `server_restart received (runtimeId=${envelope.data.runtimeId ?? "unknown"})`,
132 + );
133 + } catch (error) {
134 + this.appendLog(`server_restart envelope invalid: ${error.message || error}`);
135 + }
136 + };
137 + websocket
138 + .on("server_restart", this._serverRestartHandler)
139 + .catch((error) => {
140 + this.appendLog(`Failed to subscribe to server_restart: ${error.message || error}`);
141 + });
142 + } else {
143 + this.appendLog("WebSocket tester harness is available only in development runtime.");
144 + }
145 + },
146 +
147 + detach() {
148 + if (this._subscriptionHandlers && typeof this._subscriptionHandlers === "object") {
149 + for (const [eventType, handler] of Object.entries(this._subscriptionHandlers)) {
150 + if (typeof handler === "function") {
151 + clientForEventType(eventType).off(eventType, handler);
152 + }
153 + }
154 + this._subscriptionHandlers = null;
155 + }
156 + if (this._serverRestartHandler) {
157 + websocket.off("server_restart", this._serverRestartHandler);
158 + this._serverRestartHandler = null;
159 + } else {
160 + websocket.off("server_restart");
161 + }
162 + // Legacy cleanup: ensure we do not leave stray tester handlers attached.
163 + websocket.off("ws_tester_broadcast");
164 + websocket.off("ws_tester_persistence");
165 + websocket.off("ws_tester_broadcast_demo");
166 + stateSocket.off("state_push");
167 + },
168 +
169 + appendLog(message) {
170 + this.logs += `[${now()}] ${message}\n`;
171 + },
172 +
173 + clearLog() {
174 + this.logs = "";
175 + this.appendLog("Log cleared.");
176 + },
177 +
178 + assertEnabled() {
179 + if (!this.isEnabled) {
180 + throw new Error("WebSocket harness is available only in development runtime.");
181 + }
182 + },
183 +
184 + async ensureConnected() {
185 + this.assertEnabled();
186 + if (!websocket.isConnected()) {
187 + this.appendLog("Connecting WebSocket client...");
188 + await withTimeout(websocket.connect(), 5000, "websocket.connect");
189 + this.appendLog("Connected to WebSocket server.");
190 + }
191 + },
192 +
193 + async _toast(type, message, title) {
194 + try {
195 + await showToast(type, message, title);
196 + } catch (error) {
197 + this.appendLog(`Toast failed: ${error.message || error}`);
198 + }
199 + },
200 +
201 + async runAutomaticSuite() {
202 + this.assertEnabled();
203 + if (this.running) return;
204 + this.running = true;
205 + this.lastAggregated = null;
206 + this.receivedBroadcasts = [];
207 + this._broadcastSeq = 0;
208 +
209 + const results = [];
210 +
211 + const steps = [
212 + this.testEmit.bind(this),
213 + this.testRequest.bind(this),
214 + this.testRequestTimeout.bind(this),
215 + this.testSubscriptionPersistence.bind(this),
216 + this.testRequestAll.bind(this),
217 + this.testStateSyncNoPollHealthy.bind(this),
218 + this.testContextSwitchNoLeak.bind(this),
219 + this.testFallbackRecoveryDegraded.bind(this),
220 + this.testResyncTriggersRuntimeEpochAndSeqGap.bind(this),
221 + ];
222 +
223 + try {
224 + this.appendLog("Starting automatic WebSocket validation suite...");
225 + await this.ensureConnected();
226 +
227 + for (const step of steps) {
228 + const result = await step();
229 + results.push(result);
230 + if (!result.ok) {
231 + await this._toast("warning", `Automatic suite halted: ${result.label} failed`, "WebSocket Harness");
232 + this.appendLog(`Automatic suite halted on step: ${result.label} (${result.error || 'unknown error'})`);
233 + this.running = false;
234 + return;
235 + }
236 + }
237 +
238 + await this._toast("success", "Automatic WebSocket validation succeeded", "WebSocket Harness");
239 + this.appendLog("Automatic suite completed successfully.");
240 + } catch (error) {
241 + this.appendLog(`Automatic suite failed: ${error.message || error}`);
242 + await this._toast("error", `Automatic suite failed: ${error.message || error}`, "WebSocket Harness");
243 + } finally {
244 + this.running = false;
245 + }
246 + },
247 +
248 + async manualStep(stepFn) {
249 + this.assertEnabled();
250 + if (this.manualRunning) return;
251 + this.manualRunning = true;
252 + try {
253 + await this.ensureConnected();
254 + const result = await stepFn();
255 + this.appendLog(
256 + `${result.ok ? "PASS" : "FAIL"} - ${result.label}${result.error ? `: ${result.error}` : ""}`,
257 + );
258 + if (result.ok) {
259 + await this._toast("success", `${result.label} succeeded`, "WebSocket Harness");
260 + } else {
261 + await this._toast("warning", `${result.label} failed: ${result.error}`, "WebSocket Harness");
262 + }
263 + } catch (error) {
264 + await this._toast("error", `${error.message || error}`, "WebSocket Harness");
265 + this.appendLog(`Manual step error: ${error.message || error}`);
266 + } finally {
267 + this.manualRunning = false;
268 + }
269 + },
270 +
271 + async testEmit() {
272 + const label = "Fire-and-forget emit";
273 + try {
274 + this.appendLog("Testing fire-and-forget emit...");
275 + await this.ensureSubscribed("ws_tester_broadcast", true);
276 + const emitOptions = {
277 + correlationId: createCorrelationId("harness-emit"),
278 + };
279 + await websocket.emit(
280 + "ws_tester_emit",
281 + { message: "emit-check", timestamp: now() },
282 + emitOptions,
283 + );
284 + const received = await this.waitForEvent(
285 + "ws_tester_broadcast",
286 + (_data, envelope) =>
287 + envelope?.data?.message === "emit-check" &&
288 + typeof envelope?.handlerId === "string" &&
289 + typeof envelope?.eventId === "string" &&
290 + typeof envelope?.correlationId === "string" &&
291 + typeof envelope?.ts === "string",
292 + );
293 + this.appendLog("Received broadcast echo with valid envelope metadata.");
294 + return { ok: received, label, error: received ? undefined : "Envelope validation failed" };
295 + } catch (error) {
296 + this.appendLog(`${label} failed: ${error.message || error}`);
297 + return { ok: false, label, error: error.message || error };
298 + }
299 + },
300 +
301 + async testRequest() {
302 + const label = "Request-response";
303 + try {
304 + this.appendLog("Testing request-response...");
305 + const requestOptions = {
306 + correlationId: createCorrelationId("harness-request"),
307 + };
308 + const response = await websocket.request(
309 + "ws_tester_request",
310 + { value: 42 },
311 + { ...requestOptions },
312 + );
313 + const delayedResponse = await websocket.request(
314 + "ws_tester_request_delayed",
315 + { delay_ms: 750 },
316 + { correlationId: createCorrelationId("harness-request-no-timeout") },
317 + );
318 + const first = response.results?.[0];
319 + const ok = Boolean(
320 + response?.correlationId &&
321 + Array.isArray(response.results) &&
322 + first?.ok === true &&
323 + first?.handlerId &&
324 + first?.correlationId === response.correlationId &&
325 + first?.data?.echo === 42,
326 + );
327 + const delayedOk = Boolean(
328 + Array.isArray(delayedResponse.results) &&
329 + delayedResponse.results[0]?.ok === true &&
330 + delayedResponse.results[0]?.data?.status === "delayed",
331 + );
332 + this.appendLog(`Request-response result: ${JSON.stringify(response)}`);
333 + this.appendLog(`Request-response (no-timeout) result: ${JSON.stringify(delayedResponse)}`);
334 + return {
335 + ok: ok && delayedOk,
336 + label,
337 + error: ok && delayedOk ? undefined : "Unexpected response payload or default timeout behaviour",
338 + };
339 + } catch (error) {
340 + this.appendLog(`${label} failed: ${error.message || error}`);
341 + return { ok: false, label, error: error.message || error };
342 + }
343 + },
344 +
345 + async testRequestTimeout() {
346 + const label = "Request timeout";
347 + try {
348 + this.appendLog("Testing request timeout...");
349 + let threw = false;
350 + try {
351 + const timeoutOptions = {
352 + correlationId: createCorrelationId("harness-timeout"),
353 + };
354 + await websocket.request(
355 + "ws_tester_request_delayed",
356 + { delay_ms: 2000 },
357 + { timeoutMs: 500, ...timeoutOptions },
358 + );
359 + } catch (error) {
360 + threw = error.message === "Request timeout";
361 + if (!threw) {
362 + throw error;
363 + }
364 + }
365 + if (threw) {
366 + this.appendLog("Timeout correctly triggered.");
367 + return { ok: true, label };
368 + }
369 + this.appendLog("Timeout test failed: request resolved unexpectedly.");
370 + return { ok: false, label, error: "Request resolved but should timeout" };
371 + } catch (error) {
372 + this.appendLog(`${label} failed: ${error.message || error}`);
373 + return { ok: false, label, error: error.message || error };
374 + }
375 + },
376 +
377 + async testSubscriptionPersistence() {
378 + const label = "Subscription persistence";
379 + try {
380 + this.appendLog("Testing subscription persistence across reconnect...");
381 + await this.ensureSubscribed("ws_tester_persistence", true);
382 + const emitOptions = {
383 + correlationId: createCorrelationId("harness-persistence"),
384 + };
385 + await websocket.emit("ws_tester_trigger_persistence", { phase: "before" }, emitOptions);
386 + await this.waitForEvent("ws_tester_persistence", (data) => data?.phase === "before");
387 + this.appendLog("Initial subscription event received.");
388 +
389 + websocket.socket.disconnect();
390 + this.appendLog("Disconnected socket manually.");
391 + await websocket.connect();
392 + this.appendLog("Reconnected socket.");
393 +
394 + await websocket.emit(
395 + "ws_tester_trigger_persistence",
396 + { phase: "after" },
397 + emitOptions,
398 + );
399 + const received = await this.waitForEvent("ws_tester_persistence", (data) => data?.phase === "after", 2000);
400 + this.appendLog("Post-reconnect event received.");
401 + return { ok: received, label, error: received ? undefined : "Callback not triggered after reconnect" };
402 + } catch (error) {
403 + this.appendLog(`${label} failed: ${error.message || error}`);
404 + return { ok: false, label, error: error.message || error };
405 + }
406 + },
407 +
408 + async testRequestAll() {
409 + const label = "requestAll aggregation";
410 + try {
411 + this.appendLog("Testing requestAll aggregation...");
412 + const options = {
413 + correlationId: createCorrelationId("harness-requestAll"),
414 + };
415 + const response = await websocket.request(
416 + "ws_tester_request_all",
417 + { marker: "aggregate" },
418 + { timeoutMs: 2000, ...options },
419 + );
420 + this.lastAggregated = response;
421 +
422 + const first = response?.results?.[0];
423 + const aggregated = first?.ok === true ? first?.data?.results : null;
424 + const ok =
425 + Array.isArray(aggregated) &&
426 + aggregated.length > 0 &&
427 + aggregated.every(
428 + (entry) =>
429 + typeof entry?.sid === "string" &&
430 + typeof entry?.correlationId === "string" &&
431 + Array.isArray(entry.results) &&
432 + entry.results.length > 0,
433 + );
434 +
435 + this.appendLog(`ws_tester_request_all response: ${JSON.stringify(response)}`);
436 + return { ok, label, error: ok ? undefined : "Aggregation payload missing expected metadata" };
437 + } catch (error) {
438 + this.appendLog(`${label} failed: ${error.message || error}`);
439 + return { ok: false, label, error: error.message || error };
440 + }
441 + },
442 +
443 + async testStateSyncNoPollHealthy() {
444 + const label = "State sync (state_request/state_push + no poll when HEALTHY)";
445 + const originalPoll = globalThis.poll;
446 + let pollCalls = 0;
447 + try {
448 + this.appendLog("Testing state_request/state_push contract and healthy-mode poll suppression...");
449 +
450 + if (typeof originalPoll === "function") {
451 + globalThis.poll = async (...args) => {
452 + pollCalls += 1;
453 + return await originalPoll(...args);
454 + };
455 + }
456 +
457 + await this.ensureSubscribed("state_push", true);
458 + this.appendLog("Subscribed to state_push.");
459 +
460 + const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
461 + const response = await stateSocket.request(
462 + "state_request",
463 + {
464 + context: globalThis.getContext ? globalThis.getContext() : null,
465 + log_from: 0,
466 + notifications_from: 0,
467 + timezone,
468 + },
469 + { timeoutMs: 2000, correlationId: createCorrelationId("harness-state-request") },
470 + );
471 +
472 + const first = response?.results?.[0];
473 + const requestOk = Boolean(
474 + response?.correlationId &&
475 + first?.ok === true &&
476 + typeof first?.data?.runtime_epoch === "string" &&
477 + typeof first?.data?.seq_base === "number",
478 + );
479 + if (!requestOk) {
480 + this.appendLog(`state_request response invalid: ${JSON.stringify(response)}`);
481 + return { ok: false, label, error: "state_request did not return expected {runtime_epoch, seq_base}" };
482 + }
483 + this.appendLog("state_request OK.");
484 +
485 + const start = Date.now();
486 + let pushOk = false;
487 + while (Date.now() - start < 1000) {
488 + const hit = this.receivedBroadcasts.find(
489 + (entry) =>
490 + entry.eventType === "state_push" &&
491 + typeof entry?.payload?.data?.runtime_epoch === "string" &&
492 + typeof entry?.payload?.data?.seq === "number" &&
493 + entry?.payload?.data?.snapshot &&
494 + typeof entry?.payload?.data?.snapshot === "object" &&
495 + Array.isArray(entry?.payload?.data?.snapshot?.contexts) &&
496 + Array.isArray(entry?.payload?.data?.snapshot?.tasks) &&
497 + Array.isArray(entry?.payload?.data?.snapshot?.notifications),
498 + );
499 + if (hit) {
500 + pushOk = true;
501 + break;
502 + }
503 + await new Promise((resolve) => setTimeout(resolve, 25));
504 + }
505 +
506 + if (!pushOk) {
507 + return { ok: false, label, error: "Did not observe state_push within 1s after handshake" };
508 + }
509 + this.appendLog("state_push observed.");
510 +
511 + // The sync store applies snapshots asynchronously; give it a moment to
512 + // reach HEALTHY before asserting poll suppression.
513 + const startedHealthyWait = Date.now();
514 + while (Date.now() - startedHealthyWait < 1000 && syncStore.mode !== "HEALTHY") {
515 + await new Promise((resolve) => setTimeout(resolve, 25));
516 + }
517 + if (syncStore.mode !== "HEALTHY") {
518 + const mode = typeof syncStore.mode === "string" ? syncStore.mode : "missing";
519 + return { ok: false, label, error: `syncStore did not reach HEALTHY mode (mode=${mode})` };
520 + }
521 +
522 + // Reset count after the store is HEALTHY; then observe for >1 poll interval.
523 + pollCalls = 0;
524 + await new Promise((resolve) => setTimeout(resolve, 600));
525 + const noPoll = pollCalls === 0;
526 + if (!noPoll) {
527 + return { ok: false, label, error: `poll() invoked ${pollCalls}x while HEALTHY` };
528 + }
529 +
530 + return { ok: true, label };
531 + } catch (error) {
532 + this.appendLog(`${label} failed: ${error.message || error}`);
533 + return { ok: false, label, error: error.message || error };
534 + } finally {
535 + if (typeof originalPoll === "function") {
536 + globalThis.poll = originalPoll;
537 + }
538 + }
539 + },
540 +
541 + async testContextSwitchNoLeak() {
542 + const label = "Context switching (state_request updates active context, no stale pushes)";
543 + const originalContext = typeof globalThis.getContext === "function" ? globalThis.getContext() : null;
544 + try {
545 + this.appendLog("Testing context switching does not leak or keep pushing stale contexts...");
546 + await this.ensureSubscribed("state_push", true);
547 +
548 + if (!Array.isArray(chatsStore.contexts)) {
549 + return { ok: false, label, error: "chats store not available" };
550 + }
551 +
552 + const ids = chatsStore.contexts
553 + .map((ctx) => ctx?.id)
554 + .filter((id) => typeof id === "string" && id.length > 0);
555 + const unique = Array.from(new Set(ids));
556 + if (unique.length < 2) {
557 + return { ok: false, label, error: "Need at least 2 chats to validate switching" };
558 + }
559 +
560 + const current = typeof originalContext === "string" ? originalContext : null;
561 + let first = unique[0];
562 + let second = unique[1];
563 + if (current && unique.includes(current)) {
564 + const alternate = unique.find((id) => id !== current);
565 + if (!alternate) {
566 + return { ok: false, label, error: "Need at least 2 distinct chats to validate switching" };
567 + }
568 + first = alternate;
569 + second = current;
570 + }
571 +
572 + const switchTo = async (ctxid) => {
573 + if (typeof chatsStore.selectChat === "function") {
574 + await chatsStore.selectChat(ctxid);
575 + return;
576 + }
577 + if (typeof globalThis.setContext === "function") {
578 + globalThis.setContext(ctxid);
579 + return;
580 + }
581 + throw new Error("No chat selection function available");
582 + };
583 +
584 + const waitForContextPush = async (ctxid, timeoutMs = 2000) => {
585 + return await this.waitForEvent(
586 + "state_push",
587 + (data) => data?.snapshot?.context === ctxid,
588 + timeoutMs,
589 + );
590 + };
591 +
592 + const waitFirst = waitForContextPush(first, 2500);
593 + await switchTo(first);
594 + const gotFirst = await waitFirst;
595 + if (!gotFirst) {
596 + return { ok: false, label, error: "Did not observe state_push for first context after switch" };
597 + }
598 +
599 + const switchedAt = Date.now();
600 + const waitSecond = waitForContextPush(second, 2500);
601 + await switchTo(second);
602 + const gotSecond = await waitSecond;
603 + if (!gotSecond) {
604 + return { ok: false, label, error: "Did not observe state_push for second context after switch" };
605 + }
606 +
607 + // After switching, we should not observe new pushes for the old context.
608 + await new Promise((resolve) => setTimeout(resolve, 300));
609 + const stale = this.receivedBroadcasts.find((entry) => {
610 + if (entry.eventType !== "state_push") return false;
611 + const timestamp = Date.parse(entry.timestamp);
612 + if (!Number.isFinite(timestamp) || timestamp < switchedAt) return false;
613 + return entry?.payload?.data?.snapshot?.context === first;
614 + });
615 + if (stale) {
616 + return { ok: false, label, error: "Observed state_push for previous context after switching" };
617 + }
618 +
619 + return { ok: true, label };
620 + } catch (error) {
621 + this.appendLog(`${label} failed: ${error.message || error}`);
622 + return { ok: false, label, error: error.message || error };
623 + } finally {
624 + if (originalContext && typeof originalContext === "string") {
625 + try {
626 + if (typeof chatsStore.selectChat === "function") {
627 + await chatsStore.selectChat(originalContext);
628 + } else if (typeof globalThis.setContext === "function") {
629 + globalThis.setContext(originalContext);
630 + }
631 + } catch (_error) {
632 + // no-op
633 + }
634 + }
635 + }
636 + },
637 +
638 + async testFallbackRecoveryDegraded() {
639 + const label = "Fallback + recovery (DEGRADED polling, ignore pushes)";
640 + const originalPoll = globalThis.poll;
641 + const originalRequest = stateSocket.request;
642 + try {
643 + if (typeof syncStore.sendStateRequest !== "function") {
644 + return { ok: false, label, error: "syncStore.sendStateRequest not available" };
645 + }
646 +
647 + // Ensure we start from a known-good state.
648 + await syncStore.sendStateRequest({ forceFull: true });
649 + if (syncStore.mode !== "HEALTHY") {
650 + return { ok: false, label, error: `Expected HEALTHY before test, got ${syncStore.mode}` };
651 + }
652 +
653 + // Stub poll to avoid network side-effects and track calls.
654 + let pollCalls = 0;
655 + globalThis.poll = async () => {
656 + pollCalls += 1;
657 + return { ok: true, updated: false };
658 + };
659 +
660 + // Simulate state_request failures to force DEGRADED mode.
661 + stateSocket.request = async (eventType, payload, options) => {
662 + if (eventType === "state_request") {
663 + throw new Error("Request timeout");
664 + }
665 + return await originalRequest.call(stateSocket, eventType, payload, options);
666 + };
667 +
668 + let threw = false;
669 + try {
670 + await syncStore.sendStateRequest({ forceFull: true });
671 + } catch (_error) {
672 + threw = true;
673 + }
674 + if (!threw) {
675 + return { ok: false, label, error: "Expected state_request failure but request succeeded" };
676 + }
677 +
678 + if (syncStore.mode !== "DEGRADED") {
679 + return { ok: false, label, error: `Expected DEGRADED after failure, got ${syncStore.mode}` };
680 + }
681 + this.appendLog("Entered DEGRADED mode after simulated state_request failure.");
682 +
683 + // Poll fallback should kick in quickly (1Hz idle); wait long enough for at least one tick.
684 + await new Promise((resolve) => setTimeout(resolve, 1200));
685 + if (pollCalls < 1) {
686 + return { ok: false, label, error: "poll() was not invoked while DEGRADED" };
687 + }
688 +
689 + // While DEGRADED, pushes should be ignored (single-writer arbitration).
690 + const lastSeqBefore = typeof syncStore.lastSeq === "number" ? syncStore.lastSeq : 0;
691 + await syncStore._handlePush({
692 + data: {
693 + runtime_epoch: typeof syncStore.runtimeEpoch === "string" ? syncStore.runtimeEpoch : "test-epoch",
694 + seq: lastSeqBefore + 1,
695 + snapshot: { ignored: true },
696 + },
697 + });
698 + if (syncStore.lastSeq !== lastSeqBefore) {
699 + return { ok: false, label, error: "state_push advanced seq while DEGRADED (should be ignored)" };
700 + }
701 + this.appendLog("Verified state_push ignored while DEGRADED.");
702 +
703 + // Recover: restore request path and confirm we return to HEALTHY and polling stops.
704 + stateSocket.request = originalRequest;
705 + await syncStore.sendStateRequest({ forceFull: true });
706 + if (syncStore.mode !== "HEALTHY") {
707 + return { ok: false, label, error: `Expected HEALTHY after recovery, got ${syncStore.mode}` };
708 + }
709 +
710 + pollCalls = 0;
711 + await new Promise((resolve) => setTimeout(resolve, 600));
712 + if (pollCalls !== 0) {
713 + return { ok: false, label, error: `poll() invoked ${pollCalls}x after recovery to HEALTHY` };
714 + }
715 +
716 + return { ok: true, label };
717 + } catch (error) {
718 + this.appendLog(`${label} failed: ${error.message || error}`);
719 + return { ok: false, label, error: error.message || error };
720 + } finally {
721 + stateSocket.request = originalRequest;
722 + globalThis.poll = originalPoll;
723 + }
724 + },
725 +
726 + async testResyncTriggersRuntimeEpochAndSeqGap() {
727 + const label = "Resync triggers (runtime_epoch mismatch + seq gap)";
728 + if (typeof syncStore._handlePush !== "function") {
729 + return { ok: false, label, error: "syncStore._handlePush not available" };
730 + }
731 + const originalSendStateRequest = syncStore.sendStateRequest;
732 + let calls = [];
733 + try {
734 + if (typeof originalSendStateRequest !== "function") {
735 + return { ok: false, label, error: "syncStore.sendStateRequest not available" };
736 + }
737 +
738 + syncStore.sendStateRequest = async (options = {}) => {
739 + calls.push(options);
740 + };
741 +
742 + // Case 1: runtime_epoch mismatch should trigger resync.
743 + calls = [];
744 + syncStore.mode = "HEALTHY";
745 + syncStore.runtimeEpoch = "epoch-a";
746 + syncStore.lastSeq = 10;
747 + await syncStore._handlePush({ data: { runtime_epoch: "epoch-b", seq: 11 } });
748 + const runtimeTriggered = calls.length === 1 && calls[0] && calls[0].forceFull === true;
749 + if (!runtimeTriggered) {
750 + return { ok: false, label, error: "runtime_epoch mismatch did not trigger state_request resync" };
751 + }
752 + if (syncStore.mode !== "HANDSHAKE_PENDING") {
753 + return { ok: false, label, error: "runtime_epoch resync did not set HANDSHAKE_PENDING" };
754 + }
755 +
756 + // Case 2: seq gap should trigger resync.
757 + calls = [];
758 + syncStore.mode = "HEALTHY";
759 + syncStore.runtimeEpoch = "epoch-a";
760 + syncStore.lastSeq = 10;
761 + await syncStore._handlePush({ data: { runtime_epoch: "epoch-a", seq: 12 } });
762 + const seqTriggered = calls.length === 1 && calls[0] && calls[0].forceFull === true;
763 + if (!seqTriggered) {
764 + return { ok: false, label, error: "seq gap did not trigger state_request resync" };
765 + }
766 + if (syncStore.mode !== "HANDSHAKE_PENDING") {
767 + return { ok: false, label, error: "seq gap resync did not set HANDSHAKE_PENDING" };
768 + }
769 +
770 + return { ok: true, label };
771 + } catch (error) {
772 + this.appendLog(`${label} failed: ${error.message || error}`);
773 + return { ok: false, label, error: error.message || error };
774 + } finally {
775 + syncStore.sendStateRequest = originalSendStateRequest;
776 + }
777 + },
778 +
779 + async ensureSubscribed(eventType, reset = false) {
780 + if (!this._subscriptionHandlers || typeof this._subscriptionHandlers !== "object") {
781 + this._subscriptionHandlers = {};
782 + }
783 +
784 + const existing = this._subscriptionHandlers[eventType];
785 + if (reset && typeof existing === "function") {
786 + clientForEventType(eventType).off(eventType, existing);
787 + delete this._subscriptionHandlers[eventType];
788 + } else if (!reset && typeof existing === "function") {
789 + return;
790 + }
791 +
792 + const handler = (payload) => {
793 + try {
794 + const envelope = validateServerEnvelope(payload);
795 + if (!Array.isArray(this.receivedBroadcasts)) {
796 + this.receivedBroadcasts = [];
797 + }
798 + this._broadcastSeq = (this._broadcastSeq || 0) + 1;
799 + const id = envelope?.eventId
800 + ? `${eventType}-${envelope.eventId}`
801 + : `${eventType}-${this._broadcastSeq}`;
802 + this.receivedBroadcasts.push({
803 + id,
804 + eventType,
805 + payload: envelope,
806 + timestamp: now(),
807 + });
808 + } catch (error) {
809 + this.appendLog(`Received invalid envelope for ${eventType}: ${error.message || error}`);
810 + }
811 + };
812 +
813 + this._subscriptionHandlers[eventType] = handler;
814 + await clientForEventType(eventType).on(eventType, handler);
815 + },
816 +
817 + waitForEvent(eventType, predicate, timeout = 1500) {
818 + return new Promise((resolve) => {
819 + const client = clientForEventType(eventType);
820 + let timer;
821 + let done = false;
822 + let handler = null;
823 +
824 + const finish = (ok) => {
825 + if (done) return;
826 + done = true;
827 + if (timer) clearTimeout(timer);
828 + if (typeof handler === "function") {
829 + client.off(eventType, handler);
830 + }
831 + resolve(ok);
832 + };
833 +
834 + handler = (data) => {
835 + let envelope;
836 + try {
837 + envelope = validateServerEnvelope(data);
838 + } catch (error) {
839 + this.appendLog(`Skipping invalid envelope for ${eventType}: ${error.message || error}`);
840 + return;
841 + }
842 +
843 + if (predicate(envelope.data, envelope)) {
844 + finish(true);
845 + }
846 + };
847 +
848 + const onPromise = client.on(eventType, handler);
849 + if (onPromise && typeof onPromise.then === "function") {
850 + onPromise.catch((error) => {
851 + this.appendLog(`Failed to subscribe to ${eventType}: ${error.message || error}`);
852 + finish(false);
853 + });
854 + }
855 +
856 + timer = setTimeout(() => {
857 + finish(false);
858 + }, timeout);
859 + });
860 + },
861 +
862 + async runManualEmit() {
863 + await this.manualStep(this.testEmit.bind(this));
864 + },
865 +
866 + async runManualRequest() {
867 + await this.manualStep(this.testRequest.bind(this));
868 + },
869 +
870 + async runManualRequestTimeout() {
871 + await this.manualStep(this.testRequestTimeout.bind(this));
872 + },
873 +
874 + async runManualPersistence() {
875 + await this.manualStep(this.testSubscriptionPersistence.bind(this));
876 + },
877 +
878 + async runManualRequestAll() {
879 + await this.manualStep(this.testRequestAll.bind(this));
880 + },
881 +
882 + async runManualStateSync() {
883 + await this.manualStep(this.testStateSyncNoPollHealthy.bind(this));
884 + },
885 +
886 + async runManualContextSwitch() {
887 + await this.manualStep(this.testContextSwitchNoLeak.bind(this));
888 + },
889 +
890 + async runManualFallbackRecovery() {
891 + await this.manualStep(this.testFallbackRecoveryDegraded.bind(this));
892 + },
893 +
894 + async runManualResyncTriggers() {
895 + await this.manualStep(this.testResyncTriggersRuntimeEpochAndSeqGap.bind(this));
896 + },
897 +
898 + async triggerBroadcastDemo() {
899 + this.assertEnabled();
900 + try {
901 + await this.ensureConnected();
902 + await this.ensureSubscribed("ws_tester_broadcast_demo");
903 + const options = {
904 + correlationId: createCorrelationId("harness-demo"),
905 + };
906 + await websocket.emit(
907 + "ws_tester_broadcast_demo_trigger",
908 + { requested_at: now() },
909 + options,
910 + );
911 + await this._toast("info", "Broadcast demo triggered. Check log output.", "WebSocket Harness");
912 + } catch (error) {
913 + await this._toast("error", `Broadcast demo failed: ${error.message || error}`, "WebSocket Harness");
914 + this.appendLog(`Broadcast demo failed: ${error.message || error}`);
915 + }
916 + },
917 +
918 + payloadSizePreview(input) {
919 + return payloadSize(input);
920 + },
921 +};
922 +
923 +const store = createStore("websocketTesterStore", model);
924 +export { store };
webui/components/settings/developer/websocket-tester.html new
+188
@@ -0,0 +1,188 @@
1 +<html>
2 +<head>
3 + <title>WebSocket Test Harness</title>
4 + <script type="module">
5 + import { store as websocketTesterStore } from "/components/settings/developer/websocket-test-store.js";
6 + </script>
7 +</head>
8 +<body>
9 + <div x-data>
10 + <template x-if="window.runtimeInfo?.isDevelopment && $store.websocketTesterStore">
11 + <div class="ws-tester" x-create="$store.websocketTesterStore.onOpen()" x-destroy="$store.websocketTesterStore.detach()">
12 + <h2>WebSocket Developer Harness</h2>
13 + <p class="ws-description">
14 + Run automated and manual validations for the WebSocket client. Automatic tests cover fire-and-forget emit, request/response, timeout handling, subscription persistence, and requestAll aggregation.
15 + </p>
16 +
17 + <div class="ws-section">
18 + <div class="ws-section-header">
19 + <h3>Automatic Validation Suite</h3>
20 + <button class="btn btn-ok" :disabled="$store.websocketTesterStore.running" @click="$store.websocketTesterStore.runAutomaticSuite()">
21 + <span x-show="!$store.websocketTesterStore.running">Run Full Suite</span>
22 + <span x-show="$store.websocketTesterStore.running">Running...</span>
23 + </button>
24 + </div>
25 + <p class="ws-section-info">
26 + Executes all WebSocket feature tests sequentially. Progress and results appear in the log below and via toasts (5s).
27 + </p>
28 + </div>
29 +
30 + <div class="ws-section">
31 + <div class="ws-section-header">
32 + <h3>Manual Tests</h3>
33 + <div class="ws-button-grid">
34 + <button class="btn" :disabled="$store.websocketTesterStore.manualRunning" @click="$store.websocketTesterStore.runManualEmit()">Fire-and-forget emit</button>
35 + <button class="btn" :disabled="$store.websocketTesterStore.manualRunning" @click="$store.websocketTesterStore.runManualRequest()">Request/Response</button>
36 + <button class="btn" :disabled="$store.websocketTesterStore.manualRunning" @click="$store.websocketTesterStore.runManualRequestTimeout()">Request timeout</button>
37 + <button class="btn" :disabled="$store.websocketTesterStore.manualRunning" @click="$store.websocketTesterStore.runManualPersistence()">Subscription persistence</button>
38 + <button class="btn" :disabled="$store.websocketTesterStore.manualRunning" @click="$store.websocketTesterStore.runManualRequestAll()">requestAll aggregation</button>
39 + <button class="btn" :disabled="$store.websocketTesterStore.manualRunning" @click="$store.websocketTesterStore.runManualStateSync()">State sync (no poll when healthy)</button>
40 + <button class="btn" :disabled="$store.websocketTesterStore.manualRunning" @click="$store.websocketTesterStore.runManualContextSwitch()">Context switching</button>
41 + <button class="btn" :disabled="$store.websocketTesterStore.manualRunning" @click="$store.websocketTesterStore.runManualFallbackRecovery()">Fallback + recovery (DEGRADED)</button>
42 + <button class="btn" :disabled="$store.websocketTesterStore.manualRunning" @click="$store.websocketTesterStore.runManualResyncTriggers()">Resync triggers (runtime_epoch/seq gap)</button>
43 + <button class="btn" :disabled="$store.websocketTesterStore.manualRunning" @click="$store.websocketTesterStore.triggerBroadcastDemo()">Broadcast demo</button>
44 + </div>
45 + <p class="ws-section-info">
46 + Manual tests trigger individual scenarios and show toast results. Open a second browser tab for requestAll/broadcast demos to observe multi-connection behaviour.
47 + </p>
48 + </div>
49 + </div>
50 +
51 + <div class="ws-section">
52 + <div class="ws-section-header">
53 + <h3>Log Output</h3>
54 + <div class="ws-log-actions">
55 + <button class="btn slim" @click="$store.websocketTesterStore.clearLog()">Clear</button>
56 + </div>
57 + </div>
58 + <textarea class="ws-log" readonly x-model="$store.websocketTesterStore.logs"></textarea>
59 + </div>
60 +
61 + <div class="ws-section" x-show="$store.websocketTesterStore.lastAggregated">
62 + <div class="ws-section-header">
63 + <h3>Last Aggregated Results</h3>
64 + </div>
65 + <pre class="ws-json" x-text="JSON.stringify($store.websocketTesterStore.lastAggregated, null, 2)"></pre>
66 + </div>
67 +
68 + <div class="ws-section" x-show="$store.websocketTesterStore.receivedBroadcasts.length">
69 + <div class="ws-section-header">
70 + <h3>Recent Broadcast Payloads</h3>
71 + </div>
72 + <ul class="ws-list">
73 + <template x-for="item in $store.websocketTesterStore.receivedBroadcasts.slice(-10)" :key="item.id || (item.timestamp + item.eventType)">
74 + <li><strong x-text="item.eventType"></strong>: <span x-text="JSON.stringify(item.payload)"></span> <em x-text="item.timestamp"></em></li>
75 + </template>
76 + </ul>
77 + </div>
78 + </div>
79 + </template>
80 + <template x-if="!window.runtimeInfo?.isDevelopment">
81 + <div class="ws-tester ws-disabled">
82 + <h2>WebSocket Developer Harness</h2>
83 + <p class="ws-description">
84 + The WebSocket test harness is available only when Agent Zero runs in development mode.
85 + </p>
86 + </div>
87 + </template>
88 + </div>
89 +
90 + <style>
91 + .ws-tester {
92 + display: flex;
93 + flex-direction: column;
94 + gap: 1.5rem;
95 + color: var(--color-text-primary);
96 + }
97 +
98 + .ws-description {
99 + margin: 0;
100 + color: var(--color-text-secondary);
101 + }
102 +
103 + .ws-disabled {
104 + align-items: flex-start;
105 + }
106 +
107 + .ws-section {
108 + background: var(--color-bg-secondary);
109 + border: 1px solid var(--color-border);
110 + border-radius: 6px;
111 + padding: 1rem;
112 + }
113 +
114 + .ws-section-header {
115 + display: flex;
116 + align-items: center;
117 + justify-content: space-between;
118 + gap: 1rem;
119 + margin-bottom: 0.5rem;
120 + }
121 +
122 + .ws-section-header h3 {
123 + margin: 0;
124 + font-size: 1.1rem;
125 + }
126 +
127 + .ws-section-info {
128 + margin: 0;
129 + color: var(--color-text-secondary);
130 + font-size: 0.9rem;
131 + }
132 +
133 + .ws-button-grid {
134 + display: grid;
135 + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
136 + gap: 0.5rem;
137 + margin: 1rem 0;
138 + }
139 +
140 + .ws-log {
141 + width: 100%;
142 + min-height: 12em;
143 + background: var(--color-bg-primary);
144 + color: var(--color-text-primary);
145 + border: 1px solid var(--color-border);
146 + border-radius: 4px;
147 + padding: 0.75rem;
148 + font-family: monospace;
149 + font-size: 0.85rem;
150 + resize: vertical;
151 + }
152 +
153 + .ws-log-actions {
154 + display: flex;
155 + gap: 0.5rem;
156 + align-items: center;
157 + }
158 +
159 + .ws-json {
160 + background: var(--color-bg-primary);
161 + border: 1px solid var(--color-border);
162 + border-radius: 4px;
163 + padding: 0.75rem;
164 + font-family: monospace;
165 + font-size: 0.85rem;
166 + overflow: auto;
167 + max-height: 200px;
168 + }
169 +
170 + .ws-list {
171 + list-style: none;
172 + padding: 0;
173 + margin: 0;
174 + display: flex;
175 + flex-direction: column;
176 + gap: 0.5rem;
177 + font-size: 0.9rem;
178 + }
179 +
180 + .ws-list li {
181 + background: var(--color-bg-primary);
182 + border: 1px solid var(--color-border);
183 + border-radius: 4px;
184 + padding: 0.5rem;
185 + }
186 + </style>
187 +</body>
188 +</html>
webui/components/sidebar/chats/chats-store.js
+61 -39
@@ -3,7 +3,6 @@ import {
3 sendJsonData,
4 getContext,
5 setContext,
6 - poll as triggerPoll,
6 toastFetchError,
7 toast,
8 justToast,
@@ -11,6 +10,7 @@ import {
10 } from "/index.js";
11 import { store as notificationStore } from "/components/notifications/notification-store.js";
12 import { store as tasksStore } from "/components/sidebar/tasks/tasks-store.js";
13 +import { store as syncStore } from "/components/sync/sync-store.js";
14
15 const model = {
16 contexts: [],
@@ -40,6 +40,16 @@ const model = {
40 this.contexts = contextsList.sort(
41 (a, b) => (b.created_at || 0) - (a.created_at || 0)
42 );
43 +
44 + // Keep selectedContext in sync when the currently selected context's
45 + // metadata changes (e.g. project activation/deactivation).
46 + if (this.selected) {
47 + const selectedId = this.selected;
48 + const updated = this.contexts.find((ctx) => ctx.id === selectedId);
49 + if (updated) {
50 + this.selectedContext = updated;
51 + }
52 + }
53 },
54
55 // Select a chat
@@ -53,8 +63,17 @@ const model = {
63 // Update selection state (will also persist to localStorage)
64 this.setSelected(id);
65
56 - // Trigger immediate poll
57 - triggerPoll();
66 + // In push mode, context switching triggers a new `state_request` via setContext().
67 + // Keep polling only as a degraded-mode fallback.
68 + try {
69 + const mode = typeof syncStore.mode === "string" ? syncStore.mode : null;
70 + const shouldFallbackPoll = mode === "DEGRADED";
71 + if (shouldFallbackPoll && typeof globalThis.poll === "function") {
72 + globalThis.poll();
73 + }
74 + } catch (_e) {
75 + // no-op
76 + }
77 },
78
79 // Delete a chat
@@ -120,7 +139,7 @@ const model = {
139 await sendJsonData("/chat_reset", {
140 context
141 });
123 -
142 +
143 // Increment reset counter
144 if (typeof globalThis.resetCounter === 'number') {
145 globalThis.resetCounter = globalThis.resetCounter + 1;
@@ -272,47 +291,50 @@ const model = {
291
292 // Restart the backend
293 async restart() {
275 - try {
276 - // Check connection status
277 - const connectionStatus = getConnectionStatus();
278 - if (connectionStatus === false) {
279 - await notificationStore.frontendError(
280 - "Backend disconnected, cannot restart.",
281 - "Restart Error"
282 - );
283 - return;
284 - }
285 -
286 - // Try to initiate restart
287 - const resp = await sendJsonData("/restart", {});
288 - } catch (e) {
289 - // Show restarting message
290 - await notificationStore.frontendInfo("Restarting...", "System Restart", 9999, "restart");
294 + // Check connection status (avoid spamming requests when already disconnected)
295 + const connectionStatus = getConnectionStatus();
296 + if (connectionStatus === false) {
297 + await notificationStore.frontendError(
298 + "Backend disconnected, cannot restart.",
299 + "Restart Error",
300 + );
301 + return;
302 + }
303
292 - let retries = 0;
293 - const maxRetries = 240; // 60 seconds with 250ms interval
304 + // Create a backend notification first so other tabs have a chance to show it
305 + // before the process is replaced.
306 + const notificationId = await notificationStore.info(
307 + "Restarting...",
308 + "System Restart",
309 + "",
310 + 9999,
311 + "restart",
312 + );
313
295 - while (retries < maxRetries) {
314 + // Best-effort: wait briefly for the notification to arrive via state sync so
315 + // the initiating tab (and typically other tabs) renders the toast before restart.
316 + if (notificationId) {
317 + const deadline = Date.now() + 800;
318 + while (Date.now() < deadline) {
319 try {
297 - const resp = await sendJsonData("/health", {});
298 - // Server is back up
299 - await new Promise((resolve) => setTimeout(resolve, 250));
300 - await notificationStore.frontendSuccess("Restarted", "System Restart", 5, "restart");
301 - return;
302 - } catch (e) {
303 - // Server still down, keep waiting
304 - retries++;
305 - await new Promise((resolve) => setTimeout(resolve, 250));
320 +
321 + const stack = Array.isArray(notificationStore.toastStack) ? notificationStore.toastStack : null;
322 + if (stack && stack.some((toast) => toast && toast.id === notificationId)) {
323 + break;
324 + }
325 + } catch (_err) {
326 + break;
327 }
328 + await new Promise((resolve) => setTimeout(resolve, 25));
329 }
330 + }
331
309 - // Restart failed or timed out
310 - await notificationStore.frontendError(
311 - "Restart timed out or failed",
312 - "Restart Error",
313 - 8,
314 - "restart"
315 - );
332 + // The restart endpoint usually drops the connection as the process is replaced.
333 + // Do not wait on /health - recovery is driven by WebSocket CSRF preflight + reconnect.
334 + try {
335 + await sendJsonData("/restart", {});
336 + } catch (_e) {
337 + // ignore
338 }
339 }
340 };
webui/components/sync/sync-status.html new
+76
@@ -0,0 +1,76 @@
1 +<html>
2 +
3 +<head>
4 + <title>Sync Status</title>
5 + <script type="module">
6 + import { store } from "/components/sync/sync-store.js";
7 + </script>
8 +
9 + <style>
10 + .status-icon {
11 + display: inline-flex;
12 + align-items: center;
13 + }
14 +
15 + .status-icon svg {
16 + pointer-events: none;
17 + }
18 +
19 + .pending-ring {
20 + animation: pendingPulse 1.2s infinite ease-in-out;
21 + }
22 +
23 + @keyframes pendingPulse {
24 + 0% {
25 + stroke-opacity: 1;
26 + stroke-width: 3;
27 + }
28 +
29 + 50% {
30 + stroke-opacity: 0.25;
31 + stroke-width: 5;
32 + }
33 +
34 + 100% {
35 + stroke-opacity: 1;
36 + stroke-width: 3;
37 + }
38 + }
39 + </style>
40 +</head>
41 +
42 +<body>
43 + <div x-data>
44 + <template x-if="$store.sync">
45 + <div
46 + class="status-icon"
47 + x-create="$store.sync.init()"
48 + :title="$store.sync.mode === 'HEALTHY'
49 + ? 'Connected (push sync healthy)'
50 + : $store.sync.mode === 'HANDSHAKE_PENDING'
51 + ? 'Connecting (waiting for state handshake)'
52 + : $store.sync.mode === 'DEGRADED'
53 + ? 'Degraded (polling fallback)'
54 + : 'Disconnected (waiting to reconnect)'"
55 + >
56 + <svg viewBox="0 0 30 30" width="20" height="20" aria-label="sync status">
57 + <!-- HEALTHY (filled circle) -->
58 + <circle x-show="$store.sync.mode === 'HEALTHY'" cx="15" cy="15" r="8" fill="#00c340" />
59 +
60 + <!-- DEGRADED (filled circle) -->
61 + <circle x-show="$store.sync.mode === 'DEGRADED'" cx="15" cy="15" r="8" fill="#ff6b00" />
62 +
63 + <!-- HANDSHAKE_PENDING (pulsing outline) -->
64 + <circle x-show="$store.sync.mode === 'HANDSHAKE_PENDING'" class="pending-ring" cx="15" cy="15" r="12"
65 + fill="none" stroke="#f0a000" stroke-width="3" />
66 +
67 + <!-- DISCONNECTED (outline circle) -->
68 + <circle x-show="$store.sync.mode === 'DISCONNECTED'" cx="15" cy="15" r="12"
69 + fill="none" stroke="#e40138" stroke-width="3" />
70 + </svg>
71 + </div>
72 + </template>
73 + </div>
74 +</body>
75 +
76 +</html>
webui/components/sync/sync-store.js new
+498
@@ -0,0 +1,498 @@
1 +import { createStore } from "/js/AlpineStore.js";
2 +import { getNamespacedClient } from "/js/websocket.js";
3 +import { invalidateCsrfToken } from "/js/api.js";
4 +import { applySnapshot, buildStateRequestPayload } from "/index.js";
5 +import { store as chatTopStore } from "/components/chat/top-section/chat-top-store.js";
6 +import { store as notificationStore } from "/components/notifications/notification-store.js";
7 +
8 +const stateSocket = getNamespacedClient("/state_sync");
9 +
10 +const SYNC_MODES = {
11 + DISCONNECTED: "DISCONNECTED",
12 + HANDSHAKE_PENDING: "HANDSHAKE_PENDING",
13 + HEALTHY: "HEALTHY",
14 + DEGRADED: "DEGRADED",
15 +};
16 +
17 +function isDevelopmentRuntime() {
18 + return Boolean(globalThis.runtimeInfo?.isDevelopment);
19 +}
20 +
21 +function isSyncDebugEnabled() {
22 + try {
23 + let value = globalThis.localStorage?.getItem("a0_debug_sync");
24 + if (isDevelopmentRuntime()) {
25 + globalThis.localStorage?.setItem("a0_debug_sync", "true");
26 + value = "true";
27 + }
28 + return value === "true";
29 + } catch (_error) {
30 + return false;
31 + }
32 +}
33 +
34 +function debug(...args) {
35 + if (!isSyncDebugEnabled()) return;
36 + // eslint-disable-next-line no-console
37 + console.debug(...args);
38 +}
39 +
40 +function isRestartToastActive() {
41 + return (
42 + Array.isArray(notificationStore.toastStack) &&
43 + notificationStore.toastStack.some((toast) => toast && toast.group === "restart")
44 + );
45 +}
46 +
47 +const model = {
48 + mode: SYNC_MODES.DISCONNECTED,
49 + initialized: false,
50 + needsHandshake: false,
51 + handshakePromise: null,
52 + _handshakeQueued: false,
53 + _queuedPayload: null,
54 + _inFlightPayload: null,
55 + _seenFirstConnect: false,
56 + _lastConnectWasFirst: true,
57 + _pendingReconnectToast: null,
58 + _wasDegraded: false,
59 + _degradedToastShown: false,
60 + _degradedToastTimer: null,
61 + _degradedToastDelayMs: 100,
62 + _handshakeRetryTimer: null,
63 + _handshakeRetryAttempt: 0,
64 + _handshakeRetryBaseMs: 500,
65 + _handshakeRetryCapMs: 5000,
66 + _handshakeFailureCount: 0,
67 + _forceReconnectCooldownMs: 5000,
68 + _lastForceReconnectAtMs: 0,
69 + _forceReconnectThreshold: 3,
70 + _suppressDisconnectToastOnce: false,
71 +
72 + runtimeEpoch: null,
73 + seqBase: 0,
74 + lastSeq: 0,
75 +
76 + _setMode(newMode, reason = "") {
77 + const oldMode = this.mode;
78 + if (oldMode === newMode) return;
79 + this.mode = newMode;
80 + debug("[syncStore] Mode transition:", oldMode, "→", newMode, reason ? `(${reason})` : "");
81 +
82 + if (newMode !== SYNC_MODES.DEGRADED) {
83 + if (this._degradedToastTimer) {
84 + clearTimeout(this._degradedToastTimer);
85 + this._degradedToastTimer = null;
86 + }
87 + }
88 +
89 + if (newMode === SYNC_MODES.DISCONNECTED) {
90 + this._wasDegraded = false;
91 + this._degradedToastShown = false;
92 + }
93 +
94 + if (newMode === SYNC_MODES.DEGRADED) {
95 + this._wasDegraded = true;
96 + if (this._degradedToastShown || this._degradedToastTimer) {
97 + return;
98 + }
99 + this._degradedToastTimer = setTimeout(() => {
100 + this._degradedToastTimer = null;
101 + this._degradedToastShown = true;
102 + notificationStore
103 + .frontendWarning(
104 + "WebSocket connection problems - using polling fallback",
105 + "Connection",
106 + 5,
107 + "sync-mode",
108 + undefined,
109 + true
110 + )
111 + .catch((error) => {
112 + console.error("[syncStore] degraded toast failed:", error);
113 + });
114 + }, this._degradedToastDelayMs);
115 + return;
116 + }
117 +
118 + if (newMode === SYNC_MODES.HEALTHY) {
119 + if (this._degradedToastShown) {
120 + notificationStore
121 + .frontendSuccess(
122 + "WebSocket connection restored",
123 + "Connection",
124 + 4,
125 + "sync-mode",
126 + undefined,
127 + true
128 + )
129 + .catch((error) => {
130 + console.error("[syncStore] recovery toast failed:", error);
131 + });
132 + }
133 + this._wasDegraded = false;
134 + this._degradedToastShown = false;
135 + }
136 + },
137 +
138 + _clearHandshakeRetry() {
139 + if (this._handshakeRetryTimer) {
140 + clearTimeout(this._handshakeRetryTimer);
141 + this._handshakeRetryTimer = null;
142 + }
143 + },
144 +
145 + _scheduleHandshakeRetry(reason, forceReconnect = false) {
146 + if (this._handshakeRetryTimer) return;
147 + if (!this.needsHandshake) return;
148 + if (!stateSocket.isConnected()) return;
149 +
150 + const attempt = Math.max(0, Number(this._handshakeRetryAttempt) || 0);
151 + const delayMs = Math.min(this._handshakeRetryCapMs, this._handshakeRetryBaseMs * 2 ** attempt);
152 + this._handshakeRetryAttempt = attempt + 1;
153 +
154 + debug("[syncStore] scheduling handshake retry", {
155 + reason,
156 + attempt,
157 + delayMs,
158 + forceReconnect,
159 + });
160 + this._handshakeRetryTimer = setTimeout(() => {
161 + this._handshakeRetryTimer = null;
162 + if (!stateSocket.isConnected()) return;
163 + if (!this.needsHandshake) return;
164 + if (forceReconnect) {
165 + this._forceReconnect(reason);
166 + return;
167 + }
168 + this.sendStateRequest({ forceFull: true }).catch((error) => {
169 + console.error("[syncStore] handshake retry failed:", error);
170 + });
171 + }, delayMs);
172 + },
173 +
174 + _handleHandshakeFailure(reason) {
175 + this._handshakeFailureCount += 1;
176 + debug("[syncStore] handshake failure tracked", {
177 + reason,
178 + count: this._handshakeFailureCount,
179 + threshold: this._forceReconnectThreshold,
180 + });
181 + if (this._handshakeFailureCount < this._forceReconnectThreshold) {
182 + this._scheduleHandshakeRetry(reason, false);
183 + return;
184 + }
185 + this._handshakeFailureCount = 0;
186 + this._scheduleHandshakeRetry(reason, true);
187 + },
188 +
189 + _forceReconnect(reason) {
190 + const now = Date.now();
191 + if (now - this._lastForceReconnectAtMs < this._forceReconnectCooldownMs) {
192 + return;
193 + }
194 + this._lastForceReconnectAtMs = now;
195 + this._suppressDisconnectToastOnce = true;
196 + debug("[syncStore] forcing socket reconnect", { reason });
197 + try {
198 + invalidateCsrfToken();
199 + } catch (_error) {
200 + // no-op
201 + }
202 + try {
203 + stateSocket.disconnect();
204 + } catch (error) {
205 + console.error("[syncStore] forced disconnect failed:", error);
206 + }
207 + this.needsHandshake = true;
208 + this._clearHandshakeRetry();
209 + this._handshakeRetryAttempt = 0;
210 + stateSocket.connect().catch((error) => {
211 + console.error("[syncStore] forced reconnect failed:", error);
212 + });
213 + },
214 +
215 + async _flushPendingReconnectToast() {
216 + const pending = this._pendingReconnectToast;
217 + if (!pending) return;
218 + this._pendingReconnectToast = null;
219 +
220 + try {
221 + if (pending === "restart") {
222 + await notificationStore.frontendSuccess(
223 + "Restarted",
224 + "System Restart",
225 + 5,
226 + "restart",
227 + undefined,
228 + true,
229 + );
230 + return;
231 + }
232 + await notificationStore.frontendSuccess(
233 + "Reconnected",
234 + "Connection",
235 + 3,
236 + "reconnect",
237 + undefined,
238 + true,
239 + );
240 + } catch (error) {
241 + console.error("[syncStore] reconnect toast failed:", error);
242 + }
243 + },
244 +
245 + async init() {
246 + if (this.initialized) return;
247 + this.initialized = true;
248 +
249 + try {
250 + stateSocket.onConnect((info) => {
251 + chatTopStore.connected = true;
252 + debug("[syncStore] websocket connected", { needsHandshake: this.needsHandshake });
253 +
254 + const firstConnect = Boolean(info && info.firstConnect);
255 + this._lastConnectWasFirst = firstConnect;
256 + if (firstConnect) {
257 + this._seenFirstConnect = true;
258 + } else if (this._seenFirstConnect) {
259 + const runtimeChanged = Boolean(info && info.runtimeChanged);
260 + this._pendingReconnectToast = runtimeChanged ? "restart" : "reconnect";
261 + }
262 + this._clearHandshakeRetry();
263 + this._handshakeRetryAttempt = 0;
264 +
265 + // Always re-handshake on every Socket.IO connect.
266 + //
267 + // The backend StateMonitor tracking is per-sid and starts with seq_base=0 on a
268 + // newly connected sid. If a tab misses the 'disconnect' event (e.g. browser
269 + // suspended overnight) it can look HEALTHY locally while never sending a
270 + // fresh state_request, so pushes are gated and logs appear to stall.
271 + this.sendStateRequest({ forceFull: true }).catch((error) => {
272 + console.error("[syncStore] connect handshake failed:", error);
273 + });
274 + });
275 +
276 + stateSocket.onDisconnect(() => {
277 + chatTopStore.connected = false;
278 + const restartToastActive = isRestartToastActive();
279 + this._setMode(
280 + SYNC_MODES.DISCONNECTED,
281 + restartToastActive ? "ws disconnect (restart toast active)" : "ws disconnect",
282 + );
283 + this.needsHandshake = true;
284 + this._clearHandshakeRetry();
285 + debug("[syncStore] websocket disconnected");
286 +
287 + // Tab-local UX: brief "Disconnected" toast. This intentionally does not go through
288 + // the backend notification pipeline (no cross-tab intent, avoids request storms).
289 + // Uses the same group as "Reconnected" so the reconnect toast replaces it if still visible.
290 + const suppressToast = this._suppressDisconnectToastOnce;
291 + this._suppressDisconnectToastOnce = false;
292 + if (this._seenFirstConnect && !restartToastActive && !suppressToast) {
293 + notificationStore
294 + .frontendWarning("Disconnected", "Connection", 5, "reconnect", undefined, true)
295 + .catch((error) => {
296 + console.error("[syncStore] disconnected toast failed:", error);
297 + });
298 + }
299 + });
300 +
301 + await stateSocket.on("state_push", (envelope) => {
302 + this._handlePush(envelope).catch((error) => {
303 + console.error("[syncStore] state_push handler failed:", error);
304 + });
305 + });
306 + debug("[syncStore] subscribed to state_push");
307 +
308 + await stateSocket.on("server_restart", (envelope) => {
309 + // Avoid showing restart toast on the initial connect; prefer reconnect flows.
310 + if (this._lastConnectWasFirst) return;
311 + const runtimeId = envelope?.data?.runtimeId || null;
312 + debug("[syncStore] server_restart received", { runtimeId });
313 + this._pendingReconnectToast = "restart";
314 + });
315 + debug("[syncStore] subscribed to server_restart");
316 +
317 + await this.sendStateRequest({ forceFull: true });
318 + } catch (error) {
319 + console.error("[syncStore] init failed:", error);
320 + // Initialization failures often mean the socket can't connect; treat as disconnected.
321 + this._setMode(SYNC_MODES.DISCONNECTED, "init failed");
322 + }
323 + },
324 +
325 + async sendStateRequest(options = {}) {
326 + const { forceFull = false } = options || {};
327 + const payload = buildStateRequestPayload({ forceFull });
328 + return await this._sendStateRequestPayload(payload);
329 + },
330 +
331 + async _sendStateRequestPayload(payload) {
332 + if (this.handshakePromise) {
333 + const inFlight = this._inFlightPayload;
334 + if (
335 + inFlight &&
336 + payload &&
337 + payload.context === inFlight.context &&
338 + typeof payload.log_from === "number" &&
339 + typeof payload.notifications_from === "number" &&
340 + typeof inFlight.log_from === "number" &&
341 + typeof inFlight.notifications_from === "number"
342 + ) {
343 + const stronger =
344 + payload.log_from <= inFlight.log_from &&
345 + payload.notifications_from <= inFlight.notifications_from &&
346 + (payload.log_from < inFlight.log_from ||
347 + payload.notifications_from < inFlight.notifications_from);
348 + if (!stronger) {
349 + debug("[syncStore] state_request ignored (in-flight stronger/equal)", payload);
350 + return await this.handshakePromise;
351 + }
352 + }
353 +
354 + // Coalesce repeated requests while a handshake is in-flight. This is important
355 + // for fast context switching and resync flows where multiple requests can happen
356 + // back-to-back with different contexts/offsets.
357 + this._handshakeQueued = true;
358 + const queued = this._queuedPayload;
359 + if (!queued || !payload || payload.context !== queued.context) {
360 + this._queuedPayload = payload;
361 + } else if (
362 + typeof payload.log_from === "number" &&
363 + typeof payload.notifications_from === "number" &&
364 + typeof queued.log_from === "number" &&
365 + typeof queued.notifications_from === "number"
366 + ) {
367 + // Keep the "strongest" request: smaller offsets (0) mean a more complete resync.
368 + const queuedStrongerOrEqual =
369 + queued.log_from <= payload.log_from && queued.notifications_from <= payload.notifications_from;
370 + if (!queuedStrongerOrEqual) {
371 + this._queuedPayload = payload;
372 + }
373 + }
374 + debug("[syncStore] state_request coalesced (handshake in-flight)", payload);
375 + return await this.handshakePromise;
376 + }
377 +
378 + this._inFlightPayload = payload;
379 + this.handshakePromise = (async () => {
380 + this._setMode(SYNC_MODES.HANDSHAKE_PENDING, "sendStateRequest");
381 +
382 + let response;
383 + try {
384 + debug("[syncStore] state_request sent", payload);
385 + response = await stateSocket.request("state_request", payload, { timeoutMs: 2000 });
386 + } catch (error) {
387 + this.needsHandshake = true;
388 + // If the socket isn't connected, we are disconnected (poll may or may not work).
389 + // If the socket is connected but the request failed/timed out, treat as degraded (poll fallback).
390 + this._setMode(
391 + stateSocket.isConnected() ? SYNC_MODES.DEGRADED : SYNC_MODES.DISCONNECTED,
392 + "state_request failed",
393 + );
394 + this._handleHandshakeFailure("state_request failed");
395 + throw error;
396 + }
397 +
398 + const first = response && Array.isArray(response.results) ? response.results[0] : null;
399 + if (!first || first.ok !== true || !first.data) {
400 + const code =
401 + first && first.error && typeof first.error.code === "string"
402 + ? first.error.code
403 + : "HANDSHAKE_FAILED";
404 + this._setMode(SYNC_MODES.DEGRADED, `handshake failed: ${code}`);
405 + this.needsHandshake = true;
406 + this._handleHandshakeFailure(`handshake failed: ${code}`);
407 + throw new Error(`state_request failed: ${code}`);
408 + }
409 +
410 + const data = first.data;
411 + if (typeof data.runtime_epoch === "string") {
412 + this.runtimeEpoch = data.runtime_epoch;
413 + }
414 + if (typeof data.seq_base === "number" && Number.isFinite(data.seq_base)) {
415 + this.seqBase = data.seq_base;
416 + this.lastSeq = data.seq_base;
417 + }
418 +
419 + this.needsHandshake = false;
420 + this._handshakeFailureCount = 0;
421 + this._clearHandshakeRetry();
422 + this._handshakeRetryAttempt = 0;
423 + this._setMode(SYNC_MODES.HEALTHY, "handshake ok");
424 + })().finally(() => {
425 + this.handshakePromise = null;
426 + this._inFlightPayload = null;
427 +
428 + if (this._handshakeQueued) {
429 + const queuedPayload = this._queuedPayload;
430 + this._handshakeQueued = false;
431 + this._queuedPayload = null;
432 + if (queuedPayload) {
433 + debug("[syncStore] sending queued state_request", queuedPayload);
434 + Promise.resolve().then(() => {
435 + this._sendStateRequestPayload(queuedPayload).catch((error) => {
436 + console.error("[syncStore] queued state_request failed:", error);
437 + });
438 + });
439 + }
440 + }
441 + });
442 +
443 + return await this.handshakePromise;
444 + },
445 +
446 + async _handlePush(envelope) {
447 + if (this.mode === SYNC_MODES.DEGRADED) {
448 + debug("[syncStore] ignoring state_push while DEGRADED");
449 + return;
450 + }
451 +
452 + const data = envelope && envelope.data ? envelope.data : null;
453 + if (!data || typeof data !== "object") return;
454 +
455 + if (typeof data.runtime_epoch === "string") {
456 + if (this.runtimeEpoch && this.runtimeEpoch !== data.runtime_epoch) {
457 + debug("[syncStore] runtime_epoch mismatch -> resync", {
458 + current: this.runtimeEpoch,
459 + incoming: data.runtime_epoch,
460 + });
461 + this._setMode(SYNC_MODES.HANDSHAKE_PENDING, "runtime_epoch mismatch");
462 + await this.sendStateRequest({ forceFull: true });
463 + return;
464 + }
465 + this.runtimeEpoch = data.runtime_epoch;
466 + }
467 +
468 + if (typeof data.seq === "number" && Number.isFinite(data.seq)) {
469 + const expected = this.lastSeq + 1;
470 + if (this.lastSeq > 0 && data.seq !== expected) {
471 + debug("[syncStore] seq gap/out-of-order -> resync", {
472 + lastSeq: this.lastSeq,
473 + expected,
474 + incoming: data.seq,
475 + });
476 + this._setMode(SYNC_MODES.HANDSHAKE_PENDING, "seq gap");
477 + await this.sendStateRequest({ forceFull: true });
478 + return;
479 + }
480 + this.lastSeq = data.seq;
481 + }
482 +
483 + if (data.snapshot && typeof data.snapshot === "object") {
484 + await applySnapshot(data.snapshot, {
485 + onLogGuidReset: async () => {
486 + debug("[syncStore] log_guid reset -> resync (forceFull)");
487 + await this.sendStateRequest({ forceFull: true });
488 + },
489 + });
490 + this._setMode(SYNC_MODES.HEALTHY, "push applied");
491 + await this._flushPendingReconnectToast();
492 + }
493 + },
494 +};
495 +
496 +const store = createStore("sync", model);
497 +
498 +export { store, SYNC_MODES };
webui/index.html
+7
@@ -62,6 +62,13 @@
62 <script>
63 // Expose git info for sidebar component
64 globalThis.gitinfo = { version: "{{version_no}}", commit_time: "{{version_time}}" };
65 +
66 + // Expose runtime info for frontend components (development gating, runtime-scoped cookies).
67 + globalThis.runtimeInfo = {
68 + ...(globalThis.runtimeInfo || {}),
69 + id: "{{runtime_id}}",
70 + isDevelopment: {{runtime_is_development}},
71 + };
72 </script>
73 </head>
74
webui/index.js
+216 -101
@@ -12,6 +12,7 @@ import { store as tasksStore } from "/components/sidebar/tasks/tasks-store.js";
12 import { store as chatTopStore } from "/components/chat/top-section/chat-top-store.js";
13 import { store as _tooltipsStore } from "/components/tooltips/tooltip-store.js";
14 import { store as messageQueueStore } from "/components/chat/message-queue/message-queue-store.js";
15 +import { store as syncStore } from "/components/sync/sync-store.js"
16
17 globalThis.fetchApi = api.fetchApi; // TODO - backward compatibility for non-modular scripts, remove once refactored to alpine
18
@@ -36,13 +37,8 @@ let skipOneSpeech = false;
37 // Sidebar toggle logic is now handled by sidebar-store.js
38
39 export async function sendMessage() {
39 - const chatInputEl = document.getElementById("chat-input");
40 - if (!chatInputEl) {
41 - console.warn("chatInput not available, cannot send message");
42 - return;
43 - }
40 try {
45 - const message = chatInputEl.value.trim();
41 + const message = inputStore.message.trim();
42 const attachmentsWithUrls = attachmentsStore.getAttachmentsForSending();
43 const hasAttachments = attachmentsWithUrls.length > 0;
44
@@ -54,13 +50,11 @@ export async function sendMessage() {
50
51 if (message || hasAttachments) {
52 // Check if agent is busy - queue instead of sending
57 - if (chatTopStore.running || messageQueueStore.hasQueue) {
53 + if (chatsStore.selectedContext.running || messageQueueStore.hasQueue) {
54 const success = messageQueueStore.addToQueue(message, attachmentsWithUrls);
55 // no await for the queue
56 // if (success) {
61 - chatInputEl.value = "";
62 - attachmentsStore.clearAttachments();
63 - adjustTextareaHeight();
57 + inputStore.reset();
58 // }
59 return;
60 }
@@ -72,9 +66,7 @@ export async function sendMessage() {
66 const messageId = generateGUID();
67
68 // Clear input and attachments
75 - chatInputEl.value = "";
76 - attachmentsStore.clearAttachments();
77 - adjustTextareaHeight();
69 + inputStore.reset();
70
71 // Include attachments in the user message
72 if (hasAttachments) {
@@ -283,95 +275,102 @@ let lastLogVersion = 0;
275 let lastLogGuid = "";
276 let lastSpokenNo = 0;
277
286 -export async function poll() {
287 - let updated = false;
288 - try {
289 - // Get timezone from navigator
290 - const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
278 +export function buildStateRequestPayload(options = {}) {
279 + const { forceFull = false } = options || {};
280 + const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
281 + return {
282 + context: context || null,
283 + log_from: forceFull ? 0 : lastLogVersion,
284 + notifications_from: forceFull ? 0 : notificationStore.lastNotificationVersion || 0,
285 + timezone,
286 + };
287 +}
288
292 - const log_from = lastLogVersion;
293 - const response = await sendJsonData("/poll", {
294 - log_from: log_from,
295 - notifications_from: notificationStore.lastNotificationVersion || 0,
296 - context: context || null,
297 - timezone: timezone,
298 - });
289 +export async function applySnapshot(snapshot, options = {}) {
290 + const { touchConnectionStatus = false, onLogGuidReset = null } = options || {};
291
300 - // Check if the response is valid
301 - if (!response) {
302 - console.error("Invalid response from poll endpoint");
303 - return false;
304 - }
292 + let updated = false;
293
306 - // deselect chat if it is requested by the backend
307 - if (response.deselect_chat) {
308 - chatsStore.deselectChat();
309 - return
310 - }
294 + // Check if the snapshot is valid
295 + if (!snapshot || typeof snapshot !== "object") {
296 + console.error("Invalid snapshot payload");
297 + return { updated: false };
298 + }
299
312 - if (
313 - response.context != context &&
314 - !(response.context === null && context === null) &&
315 - context !== null
316 - ) {
317 - return;
318 - }
300 + // deselect chat if it is requested by the backend
301 + if (snapshot.deselect_chat) {
302 + chatsStore.deselectChat();
303 + return { updated: false };
304 + }
305 +
306 + if (
307 + snapshot.context != context &&
308 + context !== null
309 + ) {
310 + return { updated: false };
311 + }
312
320 - // if the chat has been reset, restart this poll as it may have been called with incorrect log_from
321 - if (lastLogGuid != response.log_guid) {
313 + // If the chat has been reset, reset cursors and request a resync from the caller.
314 + // Note: on first snapshot after a context switch, lastLogGuid is intentionally empty,
315 + // so the mismatch is expected and should not trigger a second state_request/poll.
316 + if (lastLogGuid != snapshot.log_guid) {
317 + if (lastLogGuid) {
318 const chatHistoryEl = document.getElementById("chat-history");
319 if (chatHistoryEl) chatHistoryEl.innerHTML = "";
320 + msgs.resetProcessGroups(); // Reset process groups on chat reset
321 lastLogVersion = 0;
325 - lastLogGuid = response.log_guid;
326 - await poll();
327 - return;
322 + lastLogGuid = snapshot.log_guid;
323 + if (typeof onLogGuidReset === "function") {
324 + await onLogGuidReset();
325 + }
326 + return { updated: false, resynced: true };
327 }
328 + // First guid observed for this context: accept it and continue applying snapshot.
329 + lastLogVersion = 0;
330 + lastLogGuid = snapshot.log_guid;
331 + }
332
330 - if (lastLogVersion != response.log_version) {
331 - updated = true;
332 - setMessages(response.logs);
333 - afterMessagesUpdate(response.logs);
334 - }
333 + if (lastLogVersion != snapshot.log_version) {
334 + updated = true;
335 + setMessages(snapshot.logs);
336 + afterMessagesUpdate(snapshot.logs);
337 + }
338
336 - lastLogVersion = response.log_version;
337 - lastLogGuid = response.log_guid;
339 + lastLogVersion = snapshot.log_version;
340 + lastLogGuid = snapshot.log_guid;
341
339 - updateProgress(response.log_progress, response.log_progress_active);
340 -
341 - // Update agent busy state for queue logic
342 - chatTopStore.running = response.running;
343 -
344 - // Update message queue from poll
345 - messageQueueStore.updateFromPoll(response.message_queue);
342 + updateProgress(snapshot.log_progress, snapshot.log_progress_active);
343
347 - // Update notifications from response
348 - notificationStore.updateFromPoll(response);
344 + // Update notifications from snapshot
345 + notificationStore.updateFromPoll(snapshot);
346
350 - //set ui model vars from backend
351 - inputStore.paused = response.paused;
347 + // set ui model vars from backend
348 + inputStore.paused = snapshot.paused;
349
353 - // Update status icon state
350 + // Optional: treat snapshot application as proof of connectivity (poll path)
351 + if (touchConnectionStatus) {
352 setConnectionStatus(true);
353 + }
354
356 - // Update chats list using store
357 - let contexts = response.contexts || [];
358 - chatsStore.applyContexts(contexts);
355 + // Update chats list using store
356 + let contexts = snapshot.contexts || [];
357 + chatsStore.applyContexts(contexts);
358
360 - // Update tasks list using store
361 - let tasks = response.tasks || [];
362 - tasksStore.applyTasks(tasks);
359 + // Update tasks list using store
360 + let tasks = snapshot.tasks || [];
361 + tasksStore.applyTasks(tasks);
362
364 - // Make sure the active context is properly selected in both lists
365 - if (context) {
366 - // Update selection in both stores
367 - chatsStore.setSelected(context);
363 + // Make sure the active context is properly selected in both lists
364 + if (context) {
365 + // Update selection in both stores
366 + chatsStore.setSelected(context);
367
369 - const contextInChats = chatsStore.contains(context);
370 - const contextInTasks = tasksStore.contains(context);
368 + const contextInChats = chatsStore.contains(context);
369 + const contextInTasks = tasksStore.contains(context);
370
372 - if (contextInTasks) {
373 - tasksStore.setSelected(context);
374 - }
371 + if (contextInTasks) {
372 + tasksStore.setSelected(context);
373 + }
374
375 if (!contextInChats && !contextInTasks) {
376 if (chatsStore.contexts.length > 0) {
@@ -390,21 +389,40 @@ export async function poll() {
389 // No context selected: keep it that way so the welcome screen stays visible.
390 }
391
393 - lastLogVersion = response.log_version;
394 - lastLogGuid = response.log_guid;
392 + // update message queue
393 + messageQueueStore.updateFromPoll();
394 +
395 + return { updated };
396 + }
397 +
398 +export async function poll() {
399 + try {
400 + // Get timezone from navigator
401 + const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
402 +
403 + const log_from = lastLogVersion;
404 + const response = await sendJsonData("/poll", {
405 + log_from: log_from,
406 + notifications_from: notificationStore.lastNotificationVersion || 0,
407 + context: context || null,
408 + timezone: timezone,
409 + });
410 +
411 + const result = await applySnapshot(response, {
412 + touchConnectionStatus: true,
413 + onLogGuidReset: poll,
414 + });
415 + return { ok: true, updated: Boolean(result && result.updated) };
416 } catch (error) {
417 console.error("Error:", error);
418 setConnectionStatus(false);
419 + return { ok: false, updated: false };
420 }
399 -
400 - return updated;
421 }
422 globalThis.poll = poll;
423
424 function afterMessagesUpdate(logs) {
405 - if (localStorage.getItem("speech") == "true") {
406 - speakMessages(logs);
407 - }
425 + if (preferencesStore.speech) speakMessages(logs);
426 }
427
428 function speakMessages(logs) {
@@ -507,8 +525,20 @@ export const setContext = function (id) {
525 chatsStore.setSelected(id);
526 tasksStore.setSelected(id);
527
528 + // Trigger a new WS handshake for the newly selected context (push-based sync).
529 + // This keeps the UI current without needing /poll during healthy operation.
530 + try {
531 + if (typeof syncStore.sendStateRequest === "function") {
532 + syncStore.sendStateRequest({ forceFull: true }).catch((error) => {
533 + console.error("[index] syncStore.sendStateRequest failed:", error);
534 + });
535 + }
536 + } catch (_error) {
537 + // no-op: sync store may not be initialized yet
538 + }
539 +
540 //skip one speech if enabled when switching context
511 - if (localStorage.getItem("speech") == "true") skipOneSpeech = true;
541 + if (preferencesStore.speech) skipOneSpeech = true;
542 };
543
544 export const deselectChat = function () {
@@ -518,8 +548,6 @@ export const deselectChat = function () {
548 // Clear selections so we don't auto-restore
549 sessionStorage.removeItem("lastSelectedChat");
550 sessionStorage.removeItem("lastSelectedTask");
521 - localStorage.removeItem("lastSelectedChat");
522 - localStorage.removeItem("lastSelectedTask");
551
552 // Clear the chat history
553 chatHistory.innerHTML = "";
@@ -579,25 +607,112 @@ import { store as _chatNavigationStore } from "/components/chat/navigation/chat-
607 // setInterval(poll, 250);
608
609 async function startPolling() {
582 - const shortInterval = 25;
583 - const longInterval = 250;
584 - const shortIntervalPeriod = 100;
585 - let shortIntervalCount = 0;
610 + // Fallback polling cadence:
611 + // - DISCONNECTED: do not poll (transport down, avoid request spam)
612 + // - HANDSHAKE_PENDING/DEGRADED: steady fallback cadence to keep UI responsive
613 + const degradedIntervalMs = 250;
614 + let missingSyncSinceMs = null;
615 + let consecutivePollFailures = 0;
616 + let lastHandshakeKickMs = 0;
617 + const startedAtMs = Date.now();
618 + const initialNoPollGraceMs = 2000;
619 + let pollInFlight = false;
620
621 async function _doPoll() {
588 - let nextInterval = longInterval;
622 + const tickStartedAt = Date.now();
623 + let nextInterval = degradedIntervalMs;
624
625 try {
591 - const result = await poll();
592 - if (result) shortIntervalCount = shortIntervalPeriod; // Reset the counter when the result is true
593 - if (shortIntervalCount > 0) shortIntervalCount--; // Decrease the counter on each call
594 - nextInterval = shortIntervalCount > 0 ? shortInterval : longInterval;
626 + const syncMode = typeof syncStore.mode === "string" ? syncStore.mode : null;
627 + // Polling is a fallback. In V1:
628 + // - DEGRADED: poll at fallback cadence to keep the UI usable while WS sync is unavailable.
629 + // - DISCONNECTED: do not poll; rely on Socket.IO reconnect and avoid console/network spam.
630 + // Safety net: if the sync store never loads, start polling after a short grace period.
631 + if (!syncStore || !syncMode) {
632 + if (missingSyncSinceMs == null) {
633 + missingSyncSinceMs = Date.now();
634 + }
635 + } else {
636 + missingSyncSinceMs = null;
637 + }
638 +
639 + const shouldPoll =
640 + syncMode === "DEGRADED" ||
641 + (missingSyncSinceMs != null && Date.now() - missingSyncSinceMs > 2000);
642 + if (!shouldPoll) {
643 + setTimeout(_doPoll.bind(this), nextInterval);
644 + return;
645 + }
646 +
647 + if (pollInFlight) {
648 + setTimeout(_doPoll.bind(this), nextInterval);
649 + return;
650 + }
651 +
652 + // Avoid a “single poll on boot” while the websocket handshake is racing to take over.
653 + if (Date.now() - startedAtMs < initialNoPollGraceMs && (!syncStore || !syncMode)) {
654 + setTimeout(_doPoll.bind(this), nextInterval);
655 + return;
656 + }
657 +
658 + // Call through `globalThis.poll` so test harnesses (and future instrumentation)
659 + // can wrap/spy on polling behaviour. Fall back to the module-local function
660 + // if the global is unavailable.
661 + const pollFn = typeof globalThis.poll === "function" ? globalThis.poll : poll;
662 + pollInFlight = true;
663 + let result;
664 + try {
665 + result = await pollFn();
666 + } finally {
667 + pollInFlight = false;
668 + }
669 + const pollOk = Boolean(result && result.ok);
670 +
671 + if (!pollOk) {
672 + consecutivePollFailures += 1;
673 + } else {
674 + consecutivePollFailures = 0;
675 + }
676 +
677 + // If we are degraded but polling repeatedly fails, upgrade to DISCONNECTED.
678 + if (
679 + syncStore &&
680 + syncMode === "DEGRADED" &&
681 + !pollOk &&
682 + consecutivePollFailures >= 3
683 + ) {
684 + syncStore.mode = "DISCONNECTED";
685 + }
686 +
687 + // If we're polling and the backend responds, try to re-establish push sync immediately.
688 + if (syncStore && pollOk) {
689 + const now = Date.now();
690 + const modeNow = typeof syncStore.mode === "string" ? syncStore.mode : null;
691 + const kickCooldownMs = modeNow === "DISCONNECTED" ? 0 : 3000;
692 + const eligible =
693 + (modeNow === "DISCONNECTED" || modeNow === "DEGRADED") &&
694 + typeof syncStore.sendStateRequest === "function" &&
695 + now - lastHandshakeKickMs >= kickCooldownMs;
696 + if (eligible) {
697 + lastHandshakeKickMs = now;
698 + syncStore.sendStateRequest({ forceFull: true }).catch(() => {});
699 + }
700 + }
701 +
702 + const effectiveMode =
703 + syncStore && typeof syncStore.mode === "string" ? syncStore.mode : syncMode;
704 + nextInterval =
705 + effectiveMode === "DEGRADED" || effectiveMode === "HANDSHAKE_PENDING"
706 + ? degradedIntervalMs
707 + : degradedIntervalMs;
708 } catch (error) {
709 console.error("Error:", error);
710 }
711
712 // Call the function again after the selected interval
600 - setTimeout(_doPoll.bind(this), nextInterval);
713 + const elapsedMs = Date.now() - tickStartedAt;
714 + const delayMs = Math.max(0, nextInterval - elapsedMs);
715 + setTimeout(_doPoll.bind(this), delayMs);
716 }
717
718 _doPoll();
webui/js/api.js
+105 -17
@@ -71,29 +71,117 @@ export async function fetchApi(url, request) {
71
72 // csrf token stored locally
73 let csrfToken = null;
74 +let csrfTokenPromise = null;
75 +let runtimeIdCache = null;
76 +const CSRF_TIMEOUT_MS = 5000;
77 +const CSRF_SLOW_WARN_MS = 1500;
78 +
79 +export function getRuntimeId() {
80 + if (runtimeIdCache) return runtimeIdCache;
81 + const injected =
82 + window.runtimeInfo &&
83 + typeof window.runtimeInfo.id === "string" &&
84 + window.runtimeInfo.id.length > 0
85 + ? window.runtimeInfo.id
86 + : null;
87 + return injected;
88 +}
89 +
90 +export function invalidateCsrfToken() {
91 + csrfToken = null;
92 + csrfTokenPromise = null;
93 +}
94
95 /**
96 * Get the CSRF token for API requests
97 * Caches the token after first request
98 * @returns {Promise<string>} The CSRF token
99 */
80 -async function getCsrfToken() {
100 +export async function getCsrfToken() {
101 if (csrfToken) return csrfToken;
82 - const response = await fetch("/csrf_token", {
83 - credentials: "same-origin",
84 - });
85 - if (response.redirected && response.url.endsWith("/login")) {
86 - // redirect to login
87 - window.location.href = response.url;
88 - return;
89 - }
90 - const json = await response.json();
91 - if (json.ok) {
92 - csrfToken = json.token;
93 - document.cookie = `csrf_token_${json.runtime_id}=${csrfToken}; SameSite=Strict; Path=/`;
94 - return csrfToken;
95 - } else {
96 - if (json.error) alert(json.error);
97 - throw new Error(json.error || "Failed to get CSRF token");
102 + if (csrfTokenPromise) return await csrfTokenPromise;
103 +
104 + csrfTokenPromise = (async () => {
105 + const startedAt = Date.now();
106 + const controller =
107 + typeof AbortController !== "undefined" ? new AbortController() : null;
108 + let timeoutId = null;
109 + let timeoutPromise = null;
110 + let response;
111 +
112 + try {
113 + if (controller) {
114 + timeoutId = setTimeout(() => controller.abort(), CSRF_TIMEOUT_MS);
115 + } else {
116 + timeoutPromise = new Promise((_, reject) => {
117 + timeoutId = setTimeout(() => {
118 + reject(new Error("CSRF token request timed out"));
119 + }, CSRF_TIMEOUT_MS);
120 + });
121 + }
122 +
123 + const fetchOptions = { credentials: "same-origin" };
124 + if (controller) {
125 + fetchOptions.signal = controller.signal;
126 + }
127 +
128 + const fetchPromise = fetch("/csrf_token", fetchOptions);
129 + response = timeoutPromise
130 + ? await Promise.race([fetchPromise, timeoutPromise])
131 + : await fetchPromise;
132 + } catch (error) {
133 + if (error && error.name === "AbortError") {
134 + throw new Error("CSRF token request timed out");
135 + }
136 + throw error;
137 + } finally {
138 + if (timeoutId) {
139 + clearTimeout(timeoutId);
140 + }
141 + }
142 +
143 + if (response.redirected && response.url.endsWith("/login")) {
144 + // redirect to login
145 + window.location.href = response.url;
146 + return;
147 + }
148 + const json = await response.json();
149 + if (json.ok) {
150 + const runtimeId =
151 + typeof json.runtime_id === "string" && json.runtime_id.length > 0
152 + ? json.runtime_id
153 + : null;
154 +
155 + csrfToken = json.token;
156 + if (runtimeId) {
157 + runtimeIdCache = runtimeId;
158 + }
159 + const injectedRuntimeId =
160 + window.runtimeInfo &&
161 + typeof window.runtimeInfo.id === "string" &&
162 + window.runtimeInfo.id.length > 0
163 + ? window.runtimeInfo.id
164 + : null;
165 + const cookieRuntimeId = runtimeId || injectedRuntimeId;
166 + if (cookieRuntimeId) {
167 + document.cookie = `csrf_token_${cookieRuntimeId}=${csrfToken}; SameSite=Strict; Path=/`;
168 + } else {
169 + console.warn("CSRF runtime id missing; skipping cookie name binding.");
170 + }
171 + const elapsedMs = Date.now() - startedAt;
172 + if (elapsedMs > CSRF_SLOW_WARN_MS && window.runtimeInfo?.isDevelopment) {
173 + console.warn(`CSRF token request took ${elapsedMs}ms`);
174 + }
175 + return csrfToken;
176 + } else {
177 + if (json.error) alert(json.error);
178 + throw new Error(json.error || "Failed to get CSRF token");
179 + }
180 + })();
181 +
182 + try {
183 + return await csrfTokenPromise;
184 + } finally {
185 + csrfTokenPromise = null;
186 }
187 }
webui/js/messages.js
+1 -1
@@ -1462,7 +1462,7 @@ function drawKvpsIncremental(container, kvps, latex) {
1462 }
1463 } else {
1464 // Remove table if kvps is null/empty
1465 - if (table) existingTable.remove();
1465 + if (table) table.remove();
1466 return null;
1467 }
1468 return table;
webui/js/websocket.js new
+689
@@ -0,0 +1,689 @@
1 +import { io } from "/vendor/socket.io.esm.min.js";
2 +import { getCsrfToken, getRuntimeId, invalidateCsrfToken } from "/js/api.js";
3 +
4 +const MAX_PAYLOAD_BYTES = 50 * 1024 * 1024; // 50MB hard cap per contract
5 +const DEFAULT_TIMEOUT_MS = 0;
6 +
7 +const _UUID_HEX = [..."0123456789abcdef"];
8 +const _OPTION_KEYS = new Set(["correlationId"]);
9 +
10 +/**
11 + * @param {unknown} value
12 + * @param {string} fieldName
13 + * @returns {Record<string, any>}
14 + */
15 +function assertPlainObject(value, fieldName) {
16 + if (!value || typeof value !== "object" || Array.isArray(value)) {
17 + throw new Error(`${fieldName} must be a plain object`);
18 + }
19 + return /** @type {Record<string, any>} */ (value);
20 +}
21 +
22 +/**
23 + * @returns {string}
24 + */
25 +function generateUuid() {
26 + if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
27 + return crypto.randomUUID();
28 + }
29 +
30 + const buffer = new Uint8Array(16);
31 + if (typeof crypto !== "undefined" && typeof crypto.getRandomValues === "function") {
32 + crypto.getRandomValues(buffer);
33 + } else {
34 + for (let i = 0; i < buffer.length; i += 1) {
35 + buffer[i] = Math.floor(Math.random() * 256);
36 + }
37 + }
38 +
39 + buffer[6] = (buffer[6] & 0x0f) | 0x40; // version 4
40 + buffer[8] = (buffer[8] & 0x3f) | 0x80; // variant 10
41 +
42 + let uuid = "";
43 + for (let i = 0; i < buffer.length; i += 1) {
44 + if (i === 4 || i === 6 || i === 8 || i === 10) {
45 + uuid += "-";
46 + }
47 + uuid += _UUID_HEX[buffer[i] >> 4];
48 + uuid += _UUID_HEX[buffer[i] & 0x0f];
49 + }
50 + return uuid;
51 +}
52 +
53 +/**
54 + * @param {unknown} value
55 + * @param {string} fieldName
56 + * @param {{ allowEmpty?: boolean }} [options]
57 + * @returns {string[] | undefined}
58 + */
59 +function normalizeStringList(value, fieldName, options = {}) {
60 + if (value == null) return undefined;
61 + const raw = Array.isArray(value) ? value : [value];
62 + const normalized = [];
63 + for (const item of raw) {
64 + if (typeof item !== "string" || item.trim().length === 0) {
65 + throw new Error(`${fieldName} must contain non-empty strings`);
66 + }
67 + normalized.push(item.trim());
68 + }
69 + const deduped = Array.from(new Set(normalized));
70 + if (!options.allowEmpty && deduped.length === 0) {
71 + throw new Error(`${fieldName} must contain at least one value`);
72 + }
73 + return deduped.length > 0 ? deduped : undefined;
74 +}
75 +
76 +/**
77 + * @param {unknown} value
78 + * @returns {string[] | undefined}
79 + */
80 +function normalizeSidList(value) {
81 + return normalizeStringList(value, "excludeSids", { allowEmpty: true });
82 +}
83 +
84 +/**
85 + * @param {unknown} value
86 + * @returns {string | undefined}
87 + */
88 +function normalizeCorrelationId(value) {
89 + if (value == null) return undefined;
90 + if (typeof value !== "string") {
91 + throw new Error("correlationId must be a non-empty string");
92 + }
93 + const trimmed = value.trim();
94 + if (!trimmed) {
95 + throw new Error("correlationId must be a non-empty string");
96 + }
97 + return trimmed;
98 +}
99 +
100 +/**
101 + * @param {unknown} value
102 + * @returns {string}
103 + */
104 +function normalizeNamespace(value) {
105 + if (typeof value !== "string") {
106 + throw new Error("namespace must be a non-empty string");
107 + }
108 + const trimmed = value.trim();
109 + if (!trimmed) {
110 + throw new Error("namespace must be a non-empty string");
111 + }
112 + if (trimmed === "/") {
113 + return "/";
114 + }
115 + return trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
116 +}
117 +
118 +/**
119 + * Generate a correlation identifier using UUIDv4 semantics.
120 + *
121 + * @param {string} [prefix]
122 + * @returns {string}
123 + */
124 +export function createCorrelationId(prefix) {
125 + const uuid = generateUuid();
126 + if (typeof prefix !== "string" || prefix.trim().length === 0) {
127 + return uuid;
128 + }
129 +
130 + const normalizedPrefix = prefix.trim();
131 + const suffix = normalizedPrefix.endsWith("-") ? "" : "-";
132 + return `${normalizedPrefix}${suffix}${uuid}`;
133 +}
134 +
135 +/**
136 + * @typedef {Object} NormalizedProducerOptions
137 + * @property {string[]=} includeHandlers
138 + * @property {string[]=} excludeHandlers
139 + * @property {string[]=} excludeSids
140 + * @property {string=} correlationId
141 + */
142 +
143 +/**
144 + * Normalise producer options used for emit/request/broadcast helpers.
145 + *
146 + * @param {Record<string, any> | undefined} options
147 + * @returns {NormalizedProducerOptions}
148 + */
149 +export function normalizeProducerOptions(options) {
150 + if (options == null) return {};
151 + const source = assertPlainObject(options, "options");
152 +
153 + const unknownKeys = Object.keys(source).filter((key) => !_OPTION_KEYS.has(key));
154 + if (unknownKeys.length > 0) {
155 + throw new Error(`Unsupported producer option(s): ${unknownKeys.join(", ")}`);
156 + }
157 +
158 + const normalized = {};
159 +
160 + const includeHandlers = normalizeStringList(source.includeHandlers, "includeHandlers");
161 + if (includeHandlers) {
162 + normalized.includeHandlers = includeHandlers;
163 + }
164 +
165 + const excludeHandlers = normalizeStringList(
166 + source.excludeHandlers,
167 + "excludeHandlers",
168 + { allowEmpty: true },
169 + );
170 + if (excludeHandlers && excludeHandlers.length > 0) {
171 + normalized.excludeHandlers = excludeHandlers;
172 + }
173 +
174 + const excludeSids = normalizeSidList(source.excludeSids);
175 + if (excludeSids && excludeSids.length > 0) {
176 + normalized.excludeSids = excludeSids;
177 + }
178 +
179 + const correlationId = normalizeCorrelationId(source.correlationId);
180 + if (correlationId) {
181 + normalized.correlationId = correlationId;
182 + }
183 +
184 + if (normalized.includeHandlers && normalized.excludeHandlers) {
185 + throw new Error("includeHandlers and excludeHandlers cannot be used together");
186 + }
187 +
188 + return normalized;
189 +}
190 +
191 +/**
192 + * @typedef {Object} ServerDeliveryEnvelope
193 + * @property {string} handlerId
194 + * @property {string} eventId
195 + * @property {string} correlationId
196 + * @property {string} ts
197 + * @property {Record<string, any>} data
198 + */
199 +
200 +/**
201 + * Validate a server-sent delivery envelope before invoking subscribers.
202 + *
203 + * @param {unknown} envelope
204 + * @returns {ServerDeliveryEnvelope}
205 + */
206 +export function validateServerEnvelope(envelope) {
207 + const value = assertPlainObject(envelope, "envelope");
208 +
209 + const handlerId = normalizeCorrelationId(value.handlerId)?.trim();
210 + if (!handlerId) {
211 + throw new Error("Server envelope missing handlerId");
212 + }
213 +
214 + const eventId = normalizeCorrelationId(value.eventId)?.trim();
215 + if (!eventId) {
216 + throw new Error("Server envelope missing eventId");
217 + }
218 +
219 + const correlationId = normalizeCorrelationId(value.correlationId);
220 + if (!correlationId) {
221 + throw new Error("Server envelope missing correlationId");
222 + }
223 +
224 + if (typeof value.ts !== "string" || value.ts.trim().length === 0) {
225 + throw new Error("Server envelope missing timestamp");
226 + }
227 + const timestamp = value.ts.trim();
228 + if (Number.isNaN(Date.parse(timestamp))) {
229 + throw new Error("Server envelope timestamp is invalid");
230 + }
231 +
232 + let data = value.data;
233 + if (data == null) {
234 + data = {};
235 + } else if (typeof data !== "object" || Array.isArray(data)) {
236 + throw new Error("Server envelope data must be a plain object");
237 + }
238 +
239 + const normalized = {
240 + handlerId,
241 + eventId,
242 + correlationId,
243 + ts: timestamp,
244 + data: Object.freeze({ ...data }),
245 + };
246 +
247 + return Object.freeze(normalized);
248 +}
249 +
250 +class WebSocketClient {
251 + constructor(namespace = "/") {
252 + this.namespace = normalizeNamespace(namespace);
253 + this.socket = null;
254 + this.connected = false;
255 + this.connecting = false;
256 + this.connectPromise = null;
257 + this.subscriptions = new Map(); // eventType -> { handler, callbacks: Set<Function> }
258 + this.connectCallbacks = new Set();
259 + this.disconnectCallbacks = new Set();
260 + this.errorCallbacks = new Set();
261 + this.isDevelopment = Boolean(window.runtimeInfo?.isDevelopment);
262 + this._manualDisconnect = false;
263 + this._hasConnectedOnce = false;
264 + this._lastRuntimeId = null;
265 + this._csrfInvalidatedForConnectError = false;
266 + this._connectErrorRetryTimer = null;
267 + this._connectErrorRetryAttempt = 0;
268 + }
269 +
270 + _clearConnectErrorRetryTimer() {
271 + if (this._connectErrorRetryTimer) {
272 + clearTimeout(this._connectErrorRetryTimer);
273 + this._connectErrorRetryTimer = null;
274 + }
275 + }
276 +
277 + _scheduleConnectErrorRetry(reason) {
278 + if (this._manualDisconnect) return;
279 + if (this.connected) return;
280 + if (!this.socket) return;
281 + if (this.socket.connected) return;
282 + if (this._connectErrorRetryTimer) return;
283 +
284 + const attempt = Math.max(0, Number(this._connectErrorRetryAttempt) || 0);
285 + const baseMs = 250;
286 + const capMs = 10000;
287 + const delayMs = Math.min(capMs, baseMs * 2 ** attempt);
288 + this._connectErrorRetryAttempt = attempt + 1;
289 +
290 + this.debugLog("schedule connect retry", { reason, attempt, delayMs });
291 + this._connectErrorRetryTimer = setTimeout(() => {
292 + this._connectErrorRetryTimer = null;
293 + if (this._manualDisconnect) return;
294 + if (this.connected) return;
295 + this.connect().catch(() => {});
296 + }, delayMs);
297 + }
298 +
299 + buildPayload(data) {
300 + const ts = new Date().toISOString();
301 + if (data == null) {
302 + return { ts, data: {} };
303 + }
304 + if (typeof data !== "object" || Array.isArray(data)) {
305 + throw new Error("WebSocket payload must be a plain object");
306 + }
307 + return { ts, data: { ...data } };
308 + }
309 +
310 + applyProducerOptions(payload, normalizedOptions, allowances) {
311 + const result = payload;
312 +
313 + if (normalizedOptions.includeHandlers) {
314 + if (!allowances.includeHandlers) {
315 + throw new Error("This operation does not support includeHandlers");
316 + }
317 + result.includeHandlers = [...normalizedOptions.includeHandlers];
318 + }
319 +
320 + if (normalizedOptions.excludeHandlers) {
321 + if (!allowances.excludeHandlers) {
322 + throw new Error("This operation does not support excludeHandlers");
323 + }
324 + result.excludeHandlers = [...normalizedOptions.excludeHandlers];
325 + }
326 +
327 + if (normalizedOptions.excludeSids) {
328 + if (!allowances.excludeSids) {
329 + throw new Error("This operation does not support excludeSids");
330 + }
331 + result.excludeSids = [...normalizedOptions.excludeSids];
332 + }
333 +
334 + if (normalizedOptions.correlationId) {
335 + result.correlationId = normalizedOptions.correlationId;
336 + }
337 +
338 + return result;
339 + }
340 +
341 + setDevelopmentFlag(value) {
342 + const normalized = Boolean(value);
343 + this.isDevelopment = normalized;
344 + window.runtimeInfo = { ...(window.runtimeInfo || {}), isDevelopment: normalized };
345 + }
346 +
347 + debugLog(...args) {
348 + if (this.isDevelopment) {
349 + console.debug(`[websocket:${this.namespace}]`, ...args);
350 + }
351 + }
352 +
353 + async connect() {
354 + if (this.connected) return;
355 + if (this.connectPromise) return this.connectPromise;
356 +
357 + this._manualDisconnect = false;
358 + this.connecting = true;
359 + this.connectPromise = (async () => {
360 + if (!this.socket) {
361 + this.initializeSocket();
362 + }
363 +
364 + if (this.socket.connected) return;
365 +
366 + // Ensure the current runtime-bound session + CSRF cookies exist before initiating
367 + // the Engine.IO handshake. This is required for seamless reconnect after backend
368 + // restarts that rotate runtime_id and session cookie names.
369 + try {
370 + await getCsrfToken();
371 + } catch (error) {
372 + this.debugLog("csrf prefetch failed - continuing", {
373 + error: error instanceof Error ? error.message : String(error),
374 + });
375 + }
376 +
377 + await new Promise((resolve, reject) => {
378 + const onConnect = () => {
379 + this.socket.off("connect_error", onError);
380 + resolve();
381 + };
382 + const onError = (error) => {
383 + this.socket.off("connect", onConnect);
384 + reject(error instanceof Error ? error : new Error(String(error)));
385 + };
386 +
387 + this.socket.once("connect", onConnect);
388 + this.socket.once("connect_error", onError);
389 + this.socket.connect();
390 + });
391 + })()
392 + .catch((error) => {
393 + throw new Error(`WebSocket connection failed: ${error.message || error}`);
394 + })
395 + .finally(() => {
396 + this.connecting = false;
397 + this.connectPromise = null;
398 + });
399 +
400 + return this.connectPromise;
401 + }
402 +
403 + async disconnect() {
404 + if (!this.socket) return;
405 + this._manualDisconnect = true;
406 + this.socket.disconnect();
407 + this.connected = false;
408 + }
409 +
410 + isConnected() {
411 + return this.connected;
412 + }
413 +
414 + async emit(eventType, data, options = {}) {
415 + const correlationId =
416 + normalizeCorrelationId(options?.correlationId) || createCorrelationId("emit");
417 + const payload = this.buildPayload(data);
418 + payload.correlationId = correlationId;
419 +
420 + this.debugLog("emit", {
421 + eventType,
422 + correlationId,
423 + });
424 + this.ensurePayloadSize(payload);
425 + await this.connect();
426 + if (!this.isConnected()) {
427 + throw new Error("Not connected");
428 + }
429 + this.socket.emit(eventType, payload);
430 + }
431 +
432 + async request(eventType, data, options = {}) {
433 + const correlationId =
434 + normalizeCorrelationId(options?.correlationId) ||
435 + createCorrelationId("request");
436 + const payload = this.buildPayload(data);
437 + payload.correlationId = correlationId;
438 +
439 + const timeoutMs = Number(options.timeoutMs ?? DEFAULT_TIMEOUT_MS);
440 + this.debugLog("request", { eventType, correlationId, timeoutMs });
441 + if (!Number.isFinite(timeoutMs) || timeoutMs < 0) {
442 + throw new Error("timeoutMs must be a non-negative number");
443 + }
444 + this.ensurePayloadSize(payload);
445 + await this.connect();
446 + if (!this.isConnected()) {
447 + throw new Error("Not connected");
448 + }
449 +
450 + return new Promise((resolve, reject) => {
451 + if (timeoutMs > 0) {
452 + this.socket
453 + .timeout(timeoutMs)
454 + .emit(eventType, payload, (err, response) => {
455 + if (err) {
456 + reject(new Error("Request timeout"));
457 + return;
458 + }
459 + resolve(this.normalizeRequestResponse(response));
460 + });
461 + return;
462 + }
463 +
464 + this.socket.emit(eventType, payload, (response) => {
465 + resolve(this.normalizeRequestResponse(response));
466 + });
467 + });
468 + }
469 +
470 + normalizeRequestResponse(response) {
471 + if (!response || typeof response !== "object") {
472 + return { correlationId: null, results: [] };
473 + }
474 + const correlationId =
475 + typeof response.correlationId === "string" && response.correlationId.trim().length > 0
476 + ? response.correlationId.trim()
477 + : null;
478 + const results = Array.isArray(response.results) ? response.results : [];
479 + return { correlationId, results };
480 + }
481 +
482 + async on(eventType, callback) {
483 + if (typeof callback !== "function") {
484 + throw new Error("Callback must be a function");
485 + }
486 +
487 + await this.connect();
488 +
489 + if (!this.subscriptions.has(eventType)) {
490 + const handler = (payload) => {
491 + const entry = this.subscriptions.get(eventType);
492 + if (!entry) return;
493 + let envelope;
494 + try {
495 + envelope = validateServerEnvelope(payload);
496 + } catch (error) {
497 + console.error("WebSocket envelope validation failed:", error);
498 + this.invokeErrorCallbacks(error);
499 + return;
500 + }
501 +
502 + entry.callbacks.forEach((cb) => {
503 + try {
504 + cb(envelope);
505 + } catch (error) {
506 + console.error("WebSocket callback error:", error);
507 + }
508 + });
509 + };
510 +
511 + this.subscriptions.set(eventType, {
512 + handler,
513 + callbacks: new Set(),
514 + });
515 +
516 + this.socket.on(eventType, handler);
517 + }
518 +
519 + const entry = this.subscriptions.get(eventType);
520 + entry.callbacks.add(callback);
521 + }
522 +
523 + off(eventType, callback) {
524 + const entry = this.subscriptions.get(eventType);
525 + if (!entry) return;
526 +
527 + if (callback) {
528 + entry.callbacks.delete(callback);
529 + } else {
530 + entry.callbacks.clear();
531 + }
532 +
533 + if (entry.callbacks.size === 0) {
534 + if (this.socket) {
535 + this.socket.off(eventType, entry.handler);
536 + }
537 + this.subscriptions.delete(eventType);
538 + }
539 + }
540 +
541 + onConnect(callback) {
542 + if (typeof callback === "function") {
543 + this.connectCallbacks.add(callback);
544 + }
545 + }
546 +
547 + onDisconnect(callback) {
548 + if (typeof callback === "function") {
549 + this.disconnectCallbacks.add(callback);
550 + }
551 + }
552 +
553 + onError(callback) {
554 + if (typeof callback === "function") {
555 + this.errorCallbacks.add(callback);
556 + }
557 + }
558 +
559 + initializeSocket() {
560 + this.socket = io(this.namespace, {
561 + autoConnect: false,
562 + reconnection: true,
563 + transports: ["websocket", "polling"],
564 + withCredentials: true,
565 + auth: (cb) => {
566 + getCsrfToken()
567 + .then((token) => cb({ csrf_token: token }))
568 + .catch((error) => {
569 + console.error("[websocket] failed to fetch CSRF token for connect", error);
570 + cb({});
571 + });
572 + },
573 + });
574 +
575 + this.socket.on("connect", () => {
576 + this.connected = true;
577 + this._csrfInvalidatedForConnectError = false;
578 + this._connectErrorRetryAttempt = 0;
579 + this._clearConnectErrorRetryTimer();
580 +
581 + const runtimeId = getRuntimeId();
582 + const runtimeChanged = Boolean(
583 + this._lastRuntimeId &&
584 + runtimeId &&
585 + this._lastRuntimeId !== runtimeId
586 + );
587 + const firstConnect = !this._hasConnectedOnce;
588 + this._hasConnectedOnce = true;
589 + this._lastRuntimeId = runtimeId;
590 +
591 + this.debugLog("socket connected", {
592 + sid: this.socket.id,
593 + runtimeId,
594 + runtimeChanged,
595 + firstConnect,
596 + });
597 + this.connectCallbacks.forEach((cb) => {
598 + try {
599 + cb({ runtimeId, runtimeChanged, firstConnect });
600 + } catch (error) {
601 + console.error("WebSocket onConnect callback error:", error);
602 + }
603 + });
604 + });
605 +
606 + this.socket.on("disconnect", (reason) => {
607 + this.connected = false;
608 + this.debugLog("socket disconnected", { reason });
609 + this.disconnectCallbacks.forEach((cb) => {
610 + try {
611 + cb(reason);
612 + } catch (error) {
613 + console.error("WebSocket onDisconnect callback error:", error);
614 + }
615 + });
616 + });
617 +
618 + this.socket.on("connect_error", (error) => {
619 + this.debugLog("socket connect_error", error);
620 + this.invokeErrorCallbacks(error);
621 + if (!this._csrfInvalidatedForConnectError) {
622 + this._csrfInvalidatedForConnectError = true;
623 + invalidateCsrfToken();
624 + }
625 + this._scheduleConnectErrorRetry("connect_error");
626 + });
627 +
628 + this.socket.on("error", (error) => {
629 + this.debugLog("socket error", error);
630 + this.invokeErrorCallbacks(error);
631 + });
632 + }
633 +
634 + invokeErrorCallbacks(error) {
635 + this.errorCallbacks.forEach((cb) => {
636 + try {
637 + cb(error);
638 + } catch (err) {
639 + console.error("WebSocket onError callback error:", err);
640 + }
641 + });
642 + }
643 +
644 + ensurePayloadSize(data) {
645 + const size = this.calculatePayloadSize(data);
646 + if (size > MAX_PAYLOAD_BYTES) {
647 + throw new Error("Payload too large");
648 + }
649 + }
650 +
651 + calculatePayloadSize(data) {
652 + try {
653 + return new TextEncoder().encode(JSON.stringify(data ?? null)).length;
654 + } catch (_error) {
655 + // Fallback: rough estimate if stringify fails
656 + const stringified = String(data);
657 + return stringified.length * 2;
658 + }
659 + }
660 +}
661 +
662 +const _namespacedClients = new Map();
663 +
664 +/**
665 + * Create a new Socket.IO client bound to a specific namespace.
666 + *
667 + * @param {string} namespace
668 + * @returns {WebSocketClient}
669 + */
670 +export function createNamespacedClient(namespace) {
671 + return new WebSocketClient(namespace);
672 +}
673 +
674 +/**
675 + * Return a cached Socket.IO client for the given namespace (one per browser tab/window).
676 + *
677 + * @param {string} namespace
678 + * @returns {WebSocketClient}
679 + */
680 +export function getNamespacedClient(namespace) {
681 + const key = normalizeNamespace(namespace);
682 + const existing = _namespacedClients.get(key);
683 + if (existing) return existing;
684 + const client = new WebSocketClient(key);
685 + _namespacedClients.set(key, client);
686 + return client;
687 +}
688 +
689 +export const websocket = getNamespacedClient("/");
webui/public/dev_testing.svg new
+2
@@ -0,0 +1,2 @@
1 +<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
2 +<svg fill="#000000" width="800px" height="800px" viewBox="0 0 24 24" id="check-mark-square-2" data-name="Flat Line" xmlns="http://www.w3.org/2000/svg" class="icon flat-line"><polyline id="primary" points="21 5 12 14 8 10" style="fill: none; stroke: rgb(0, 0, 0); stroke-linecap: round; stroke-linejoin: round; stroke-width: 2;"></polyline><path id="primary-2" data-name="primary" d="M21,11v9a1,1,0,0,1-1,1H4a1,1,0,0,1-1-1V4A1,1,0,0,1,4,3H16" style="fill: none; stroke: rgb(0, 0, 0); stroke-linecap: round; stroke-linejoin: round; stroke-width: 2;"></path></svg>
\ No newline at end of file
webui/vendor/_ace/ace.js renamed
webui/vendor/_ace/ace.min.css renamed
webui/vendor/_ace/mode-javascript.js renamed
webui/vendor/_ace/mode-json.js renamed
webui/vendor/_ace/mode-markdown.js renamed
webui/vendor/_ace/text.js renamed
webui/vendor/_ace/theme-github_dark.js renamed
webui/vendor/_ace/worker-json.js renamed
webui/vendor/ace-min/ace.min.css new
+8
@@ -0,0 +1,8 @@
1 +/**
2 + * Minified by jsDelivr using clean-css v5.3.2.
3 + * Original file: /npm/ace-builds@1.36.5/css/ace.css
4 + *
5 + * Do NOT use SRI with dynamically generated files! More information: https://www.jsdelivr.com/using-sri-with-dynamic-files
6 + */
7 +.ace_editor>.ace_sb-h div,.ace_editor>.ace_sb-v div{position:absolute;background:rgba(128,128,128,.6);-moz-box-sizing:border-box;box-sizing:border-box;border:1px solid #bbb;border-radius:2px;z-index:8}.ace_editor>.ace_sb-h,.ace_editor>.ace_sb-v{position:absolute;z-index:6;background:0 0;overflow:hidden!important}.ace_editor>.ace_sb-v{z-index:6;right:0;top:0;width:12px}.ace_editor>.ace_sb-v div{z-index:8;right:0;width:100%}.ace_editor>.ace_sb-h{bottom:0;left:0;height:12px}.ace_editor>.ace_sb-h div{bottom:0;height:100%}.ace_editor>.ace_sb_grabbed{z-index:8;background:#000}.ace_br1{border-top-left-radius:3px}.ace_br2{border-top-right-radius:3px}.ace_br3{border-top-left-radius:3px;border-top-right-radius:3px}.ace_br4{border-bottom-right-radius:3px}.ace_br5{border-top-left-radius:3px;border-bottom-right-radius:3px}.ace_br6{border-top-right-radius:3px;border-bottom-right-radius:3px}.ace_br7{border-top-left-radius:3px;border-top-right-radius:3px;border-bottom-right-radius:3px}.ace_br8{border-bottom-left-radius:3px}.ace_br9{border-top-left-radius:3px;border-bottom-left-radius:3px}.ace_br10{border-top-right-radius:3px;border-bottom-left-radius:3px}.ace_br11{border-top-left-radius:3px;border-top-right-radius:3px;border-bottom-left-radius:3px}.ace_br12{border-bottom-right-radius:3px;border-bottom-left-radius:3px}.ace_br13{border-top-left-radius:3px;border-bottom-right-radius:3px;border-bottom-left-radius:3px}.ace_br14{border-top-right-radius:3px;border-bottom-right-radius:3px;border-bottom-left-radius:3px}.ace_br15{border-top-left-radius:3px;border-top-right-radius:3px;border-bottom-right-radius:3px;border-bottom-left-radius:3px}.ace_editor{position:relative;overflow:hidden;padding:0;font:12px/normal Monaco,Menlo,'Ubuntu Mono',Consolas,'Source Code Pro',source-code-pro,monospace;direction:ltr;text-align:left;-webkit-tap-highlight-color:transparent;forced-color-adjust:none}.ace_scroller{position:absolute;overflow:hidden;top:0;bottom:0;background-color:inherit;-ms-user-select:none;-moz-user-select:none;-webkit-user-select:none;user-select:none;cursor:text}.ace_content{position:absolute;box-sizing:border-box;min-width:100%;contain:style size layout;font-variant-ligatures:no-common-ligatures}.ace_keyboard-focus:focus{box-shadow:inset 0 0 0 2px #5e9ed6;outline:0}.ace_dragging .ace_scroller:before{position:absolute;top:0;left:0;right:0;bottom:0;content:'';background:rgba(250,250,250,.01);z-index:1000}.ace_dragging.ace_dark .ace_scroller:before{background:rgba(0,0,0,.01)}.ace_gutter{position:absolute;overflow:hidden;width:auto;top:0;bottom:0;left:0;cursor:default;z-index:4;-ms-user-select:none;-moz-user-select:none;-webkit-user-select:none;user-select:none;contain:style size layout}.ace_gutter-active-line{position:absolute;left:0;right:0}.ace_scroller.ace_scroll-left:after{content:"";position:absolute;top:0;right:0;bottom:0;left:0;box-shadow:17px 0 16px -16px rgba(0,0,0,.4) inset;pointer-events:none}.ace_gutter-cell,.ace_gutter-cell_svg-icons{position:absolute;top:0;left:0;right:0;padding-left:19px;padding-right:6px;background-repeat:no-repeat}.ace_gutter-cell_svg-icons .ace_gutter_annotation{margin-left:-14px;float:left}.ace_gutter-cell .ace_gutter_annotation{margin-left:-19px;float:left}.ace_gutter-cell.ace_error,.ace_gutter-cell.ace_security,.ace_icon.ace_error,.ace_icon.ace_error_fold,.ace_icon.ace_security,.ace_icon.ace_security_fold{background-image:url("main-1.png");background-repeat:no-repeat;background-position:2px center}.ace_gutter-cell.ace_warning,.ace_icon.ace_warning,.ace_icon.ace_warning_fold{background-image:url("main-2.png");background-repeat:no-repeat;background-position:2px center}.ace_gutter-cell.ace_hint,.ace_gutter-cell.ace_info,.ace_icon.ace_hint,.ace_icon.ace_info{background-image:url("main-3.png");background-repeat:no-repeat;background-position:2px center}.ace_dark .ace_gutter-cell.ace_hint,.ace_dark .ace_gutter-cell.ace_info,.ace_dark .ace_icon.ace_hint,.ace_dark .ace_icon.ace_info{background-image:url("main-4.png")}.ace_icon_svg.ace_error{-webkit-mask-image:url("main-5.svg");background-color:#dc143c}.ace_icon_svg.ace_security{-webkit-mask-image:url("main-6.svg");background-color:#dc143c}.ace_icon_svg.ace_warning{-webkit-mask-image:url("main-7.svg");background-color:#ff8c00}.ace_icon_svg.ace_info{-webkit-mask-image:url("main-8.svg");background-color:#4169e1}.ace_icon_svg.ace_hint{-webkit-mask-image:url("main-9.svg");background-color:silver}.ace_icon_svg.ace_error_fold{-webkit-mask-image:url("main-10.svg");background-color:#dc143c}.ace_icon_svg.ace_security_fold{-webkit-mask-image:url("main-11.svg");background-color:#dc143c}.ace_icon_svg.ace_warning_fold{-webkit-mask-image:url("main-12.svg");background-color:#ff8c00}.ace_scrollbar{contain:strict;position:absolute;right:0;bottom:0;z-index:6}.ace_scrollbar-inner{position:absolute;cursor:text;left:0;top:0}.ace_scrollbar-v{overflow-x:hidden;overflow-y:scroll;top:0}.ace_scrollbar-h{overflow-x:scroll;overflow-y:hidden;left:0}.ace_print-margin{position:absolute;height:100%}.ace_text-input{position:absolute;z-index:0;width:.5em;height:1em;opacity:0;background:0 0;-moz-appearance:none;appearance:none;border:none;resize:none;outline:0;overflow:hidden;font:inherit;padding:0 1px;margin:0 -1px;contain:strict;-ms-user-select:text;-moz-user-select:text;-webkit-user-select:text;user-select:text;white-space:pre!important}.ace_text-input.ace_composition{background:0 0;color:inherit;z-index:1000;opacity:1}.ace_composition_placeholder{color:transparent}.ace_composition_marker{border-bottom:1px solid;position:absolute;border-radius:0;margin-top:1px}[ace_nocontext=true]{transform:none!important;filter:none!important;clip-path:none!important;mask:none!important;contain:none!important;perspective:none!important;mix-blend-mode:initial!important;z-index:auto}.ace_layer{z-index:1;position:absolute;overflow:hidden;word-wrap:normal;white-space:pre;height:100%;width:100%;box-sizing:border-box;pointer-events:none}.ace_gutter-layer{position:relative;width:auto;text-align:right;pointer-events:auto;height:1000000px;contain:style size layout}.ace_text-layer{font:inherit!important;position:absolute;height:1000000px;width:1000000px;contain:style size layout}.ace_text-layer>.ace_line,.ace_text-layer>.ace_line_group{contain:style size layout;position:absolute;top:0;left:0;right:0}.ace_hidpi .ace_content,.ace_hidpi .ace_gutter,.ace_hidpi .ace_gutter-layer,.ace_hidpi .ace_text-layer{contain:strict}.ace_hidpi .ace_text-layer>.ace_line,.ace_hidpi .ace_text-layer>.ace_line_group{contain:strict}.ace_cjk{display:inline-block;text-align:center}.ace_cursor-layer{z-index:4}.ace_cursor{z-index:4;position:absolute;box-sizing:border-box;border-left:2px solid;transform:translatez(0)}.ace_multiselect .ace_cursor{border-left-width:1px}.ace_slim-cursors .ace_cursor{border-left-width:1px}.ace_overwrite-cursors .ace_cursor{border-left-width:0;border-bottom:1px solid}.ace_hidden-cursors .ace_cursor{opacity:.2}.ace_hasPlaceholder .ace_hidden-cursors .ace_cursor{opacity:0}.ace_smooth-blinking .ace_cursor{transition:opacity .18s}.ace_animate-blinking .ace_cursor{animation-duration:1s;animation-timing-function:step-end;animation-name:blink-ace-animate;animation-iteration-count:infinite}.ace_animate-blinking.ace_smooth-blinking .ace_cursor{animation-duration:1s;animation-timing-function:ease-in-out;animation-name:blink-ace-animate-smooth}@keyframes blink-ace-animate{from,to{opacity:1}60%{opacity:0}}@keyframes blink-ace-animate-smooth{from,to{opacity:1}45%{opacity:1}60%{opacity:0}85%{opacity:0}}.ace_marker-layer .ace_stack,.ace_marker-layer .ace_step{position:absolute;z-index:3}.ace_marker-layer .ace_selection{position:absolute;z-index:5}.ace_marker-layer .ace_bracket{position:absolute;z-index:6}.ace_marker-layer .ace_error_bracket{position:absolute;border-bottom:1px solid #de5555;border-radius:0}.ace_marker-layer .ace_active-line{position:absolute;z-index:2}.ace_marker-layer .ace_selected-word{position:absolute;z-index:4;box-sizing:border-box}.ace_line .ace_fold{box-sizing:border-box;display:inline-block;height:11px;margin-top:-2px;vertical-align:middle;background-image:url("main-13.png"),url("main-14.png");background-repeat:no-repeat,repeat-x;background-position:center center,top left;color:transparent;border:1px solid #000;border-radius:2px;cursor:pointer;pointer-events:auto}.ace_fold:hover{background-image:url("main-15.png"),url("main-16.png")}.ace_tooltip{background-color:#f5f5f5;border:1px solid gray;border-radius:1px;box-shadow:0 1px 2px rgba(0,0,0,.3);color:#000;max-width:100%;padding:3px 4px;position:fixed;z-index:999999;box-sizing:border-box;cursor:default;white-space:pre-wrap;word-wrap:break-word;line-height:normal;font-style:normal;font-weight:400;letter-spacing:normal;pointer-events:none;overflow:auto;max-width:min(60em,66vw);overscroll-behavior:contain}.ace_tooltip pre{white-space:pre-wrap}.ace_tooltip.ace_dark{background-color:#636363;color:#fff}.ace_tooltip:focus{outline:1px solid #5E9ED6}.ace_icon{display:inline-block;width:18px;vertical-align:top}.ace_icon_svg{display:inline-block;width:12px;vertical-align:top;-webkit-mask-repeat:no-repeat;-webkit-mask-size:12px;-webkit-mask-position:center}.ace_folding-enabled>.ace_gutter-cell,.ace_folding-enabled>.ace_gutter-cell_svg-icons{padding-right:13px}.ace_fold-widget{box-sizing:border-box;margin:0 -12px 0 1px;display:none;width:11px;vertical-align:top;background-image:url("main-17.png");background-repeat:no-repeat;background-position:center;border-radius:3px;border:1px solid transparent;cursor:pointer}.ace_folding-enabled .ace_fold-widget{display:inline-block}.ace_fold-widget.ace_end{background-image:url("main-18.png")}.ace_fold-widget.ace_closed{background-image:url("main-19.png")}.ace_fold-widget:hover{border:1px solid rgba(0,0,0,.3);background-color:rgba(255,255,255,.2);box-shadow:0 1px 1px rgba(255,255,255,.7)}.ace_fold-widget:active{border:1px solid rgba(0,0,0,.4);background-color:rgba(0,0,0,.05);box-shadow:0 1px 1px rgba(255,255,255,.8)}.ace_dark .ace_fold-widget{background-image:url("main-20.png")}.ace_dark .ace_fold-widget.ace_end{background-image:url("main-21.png")}.ace_dark .ace_fold-widget.ace_closed{background-image:url("main-22.png")}.ace_dark .ace_fold-widget:hover{box-shadow:0 1px 1px rgba(255,255,255,.2);background-color:rgba(255,255,255,.1)}.ace_dark .ace_fold-widget:active{box-shadow:0 1px 1px rgba(255,255,255,.2)}.ace_inline_button{border:1px solid #d3d3d3;display:inline-block;margin:-1px 8px;padding:0 5px;pointer-events:auto;cursor:pointer}.ace_inline_button:hover{border-color:gray;background:rgba(200,200,200,.2);display:inline-block;pointer-events:auto}.ace_fold-widget.ace_invalid{background-color:#ffb4b4;border-color:#de5555}.ace_fade-fold-widgets .ace_fold-widget{transition:opacity .4s ease 50ms;opacity:0}.ace_fade-fold-widgets:hover .ace_fold-widget{transition:opacity 50ms ease 50ms;opacity:1}.ace_underline{text-decoration:underline}.ace_bold{font-weight:700}.ace_nobold .ace_bold{font-weight:400}.ace_italic{font-style:italic}.ace_error-marker{background-color:rgba(255,0,0,.2);position:absolute;z-index:9}.ace_highlight-marker{background-color:rgba(255,255,0,.2);position:absolute;z-index:8}.ace_mobile-menu{position:absolute;line-height:1.5;border-radius:4px;-ms-user-select:none;-moz-user-select:none;-webkit-user-select:none;user-select:none;background:#fff;box-shadow:1px 3px 2px grey;border:1px solid #dcdcdc;color:#000}.ace_dark>.ace_mobile-menu{background:#333;color:#ccc;box-shadow:1px 3px 2px grey;border:1px solid #444}.ace_mobile-button{padding:2px;cursor:pointer;overflow:hidden}.ace_mobile-button:hover{background-color:#eee;opacity:1}.ace_mobile-button:active{background-color:#ddd}.ace_placeholder{position:relative;font-family:arial;transform:scale(.9);transform-origin:left;white-space:pre;opacity:.7;margin:0 10px;z-index:1}.ace_ghost_text{opacity:.5;font-style:italic}.ace_ghost_text_container>div{white-space:pre}.ghost_text_line_wrapped::after{content:"↩";position:absolute}.ace_lineWidgetContainer.ace_ghost_text{margin:0 4px}.ace_screenreader-only{position:absolute;left:-10000px;top:auto;width:1px;height:1px;overflow:hidden}.ace_hidden_token{display:none}.ace-tm .ace_gutter{background:#f0f0f0;color:#333}.ace-tm .ace_print-margin{width:1px;background:#e8e8e8}.ace-tm .ace_fold{background-color:#6b72e6}.ace-tm{background-color:#fff;color:#000}.ace-tm .ace_cursor{color:#000}.ace-tm .ace_invisible{color:#bfbfbf}.ace-tm .ace_keyword,.ace-tm .ace_storage{color:#00f}.ace-tm .ace_constant{color:#c5060b}.ace-tm .ace_constant.ace_buildin{color:#5848f6}.ace-tm .ace_constant.ace_language{color:#585cf6}.ace-tm .ace_constant.ace_library{color:#06960e}.ace-tm .ace_invalid{background-color:rgba(255,0,0,.1);color:red}.ace-tm .ace_support.ace_function{color:#3c4c72}.ace-tm .ace_support.ace_constant{color:#06960e}.ace-tm .ace_support.ace_class,.ace-tm .ace_support.ace_type{color:#6d79de}.ace-tm .ace_keyword.ace_operator{color:#687687}.ace-tm .ace_string{color:#036a07}.ace-tm .ace_comment{color:#4c886b}.ace-tm .ace_comment.ace_doc{color:#06f}.ace-tm .ace_comment.ace_doc.ace_tag{color:#809fbf}.ace-tm .ace_constant.ace_numeric{color:#0000cd}.ace-tm .ace_variable{color:#318495}.ace-tm .ace_xml-pe{color:#68685b}.ace-tm .ace_entity.ace_name.ace_function{color:#0000a2}.ace-tm .ace_heading{color:#0c07ff}.ace-tm .ace_list{color:#b90690}.ace-tm .ace_meta.ace_tag{color:#00168e}.ace-tm .ace_string.ace_regex{color:red}.ace-tm .ace_marker-layer .ace_selection{background:#b5d5ff}.ace-tm.ace_multiselect .ace_selection.ace_start{box-shadow:0 0 3px 0 #fff}.ace-tm .ace_marker-layer .ace_step{background:#fcff00}.ace-tm .ace_marker-layer .ace_stack{background:#a4e565}.ace-tm .ace_marker-layer .ace_bracket{margin:-1px 0 0 -1px;border:1px solid silver}.ace-tm .ace_marker-layer .ace_active-line{background:rgba(0,0,0,.07)}.ace-tm .ace_gutter-active-line{background-color:#dcdcdc}.ace-tm .ace_marker-layer .ace_selected-word{background:#fafaff;border:1px solid #c8c8fa}.ace-tm .ace_indent-guide{background:url("main-23.png") right repeat-y}.ace-tm .ace_indent-guide-active{background:url("main-24.png") right repeat-y}.error_widget_wrapper{background:inherit;color:inherit;border:none}.error_widget{border-top:solid 2px;border-bottom:solid 2px;margin:5px 0;padding:10px 40px;white-space:pre-wrap}.error_widget.ace_error,.error_widget_arrow.ace_error{border-color:#ff5a5a}.error_widget.ace_warning,.error_widget_arrow.ace_warning{border-color:#f1d817}.error_widget.ace_info,.error_widget_arrow.ace_info{border-color:#5a5a5a}.error_widget.ace_ok,.error_widget_arrow.ace_ok{border-color:#5aaa5a}.error_widget_arrow{position:absolute;border:solid 5px;border-top-color:transparent!important;border-right-color:transparent!important;border-left-color:transparent!important;top:-5px}.ace_codeLens{position:absolute;color:#aaa;font-size:88%;background:inherit;width:100%;display:flex;align-items:flex-end;pointer-events:none}.ace_codeLens>a{cursor:pointer;pointer-events:auto}.ace_codeLens>a:hover{color:#00f;text-decoration:underline}.ace_dark>.ace_codeLens>a:hover{color:#4e94ce}.ace_tooltip.command_bar_tooltip_wrapper{padding:0}.ace_tooltip .command_bar_tooltip{padding:1px 5px;display:flex;pointer-events:auto}.ace_tooltip .command_bar_tooltip.tooltip_more_options{padding:1px;flex-direction:column}div.command_bar_tooltip_button{display:inline-flex;cursor:pointer;margin:1px;border-radius:2px;padding:2px 5px;align-items:center}div.command_bar_tooltip_button.ace_selected,div.command_bar_tooltip_button:hover:not(.ace_disabled){background-color:rgba(0,0,0,.1)}div.command_bar_tooltip_button.ace_disabled{color:#777;pointer-events:none}div.command_bar_tooltip_button .ace_icon_svg{height:12px;background-color:#000}div.command_bar_tooltip_button.ace_disabled .ace_icon_svg{background-color:#777}.command_bar_tooltip.tooltip_more_options .command_bar_tooltip_button{display:flex}.command_bar_tooltip.command_bar_button_value{display:none}.command_bar_tooltip.tooltip_more_options .command_bar_button_value{display:inline-block;width:12px}.command_bar_button_caption{display:inline-block}.command_bar_keybinding{margin:0 2px;display:inline-block;font-size:8px}.command_bar_tooltip.tooltip_more_options .command_bar_keybinding{margin-left:auto}.command_bar_keybinding div{display:inline-block;min-width:8px;padding:2px;margin:0 1px;border-radius:2px;background-color:#ccc;text-align:center}.ace_dark.ace_tooltip .command_bar_tooltip{background-color:#373737;color:#eee}.ace_dark div.command_bar_tooltip_button.ace_disabled{color:#979797}.ace_dark div.command_bar_tooltip_button.ace_selected,.ace_dark div.command_bar_tooltip_button:hover:not(.ace_disabled){background-color:rgba(255,255,255,.1)}.ace_dark div.command_bar_tooltip_button .ace_icon_svg{background-color:#eee}.ace_dark div.command_bar_tooltip_button.ace_disabled .ace_icon_svg{background-color:#979797}.ace_dark .command_bar_tooltip_button.ace_disabled{color:#979797}.ace_dark .command_bar_keybinding div{background-color:#575757}.ace_checkmark::before{content:'✓'}.ace_snippet-marker{-moz-box-sizing:border-box;box-sizing:border-box;background:rgba(194,193,208,.09);border:1px dotted rgba(211,208,235,.62);position:absolute}.ace_editor.ace_autocomplete .ace_marker-layer .ace_active-line{background-color:#cad6fa;z-index:1}.ace_dark.ace_editor.ace_autocomplete .ace_marker-layer .ace_active-line{background-color:#3a674e}.ace_editor.ace_autocomplete .ace_line-hover{border:1px solid #abbffe;margin-top:-1px;background:rgba(233,233,253,.4);position:absolute;z-index:2}.ace_dark.ace_editor.ace_autocomplete .ace_line-hover{border:1px solid rgba(109,150,13,.8);background:rgba(58,103,78,.62)}.ace_completion-meta{opacity:.5;margin-left:.9em}.ace_completion-message{margin-left:.9em;color:#00f}.ace_editor.ace_autocomplete .ace_completion-highlight{color:#2d69c7}.ace_dark.ace_editor.ace_autocomplete .ace_completion-highlight{color:#93ca12}.ace_editor.ace_autocomplete{width:300px;z-index:200000;border:1px #d3d3d3 solid;position:fixed;box-shadow:2px 3px 5px rgba(0,0,0,.2);line-height:1.4;background:#fefefe;color:#111}.ace_dark.ace_editor.ace_autocomplete{border:1px #484747 solid;box-shadow:2px 3px 5px rgba(0,0,0,.51);line-height:1.4;background:#25282c;color:#c1c1c1}.ace_autocomplete .ace_text-layer{width:calc(100% - 8px)}.ace_autocomplete .ace_line{display:flex;align-items:center}.ace_autocomplete .ace_line>*{min-width:0;flex:0 0 auto}.ace_autocomplete .ace_line .ace_{flex:0 1 auto;overflow:hidden;text-overflow:ellipsis}.ace_autocomplete .ace_completion-spacer{flex:1}.ace_autocomplete.ace_loading:after{content:"";position:absolute;top:0;height:2px;width:8%;background:#00f;z-index:100;animation:ace_progress 3s infinite linear;animation-delay:.3s;transform:translateX(-100%) scaleX(1)}@keyframes ace_progress{0%{transform:translateX(-100%) scaleX(1)}50%{transform:translateX(625%) scaleX(2)}100%{transform:translateX(1500%) scaleX(3)}}@media (prefers-reduced-motion){.ace_autocomplete.ace_loading:after{transform:translateX(625%) scaleX(2);animation:none}}.ace_icon_svg.ace_arrow,.ace_icon_svg.ace_arrow_rotated{-webkit-mask-image:url("main-25.svg")}.ace_icon_svg.ace_arrow_rotated{transform:rotate(180deg)}div.command_bar_tooltip_button.completion_position{padding:0}#ace_settingsmenu,#kbshortcutmenu{background-color:#f7f7f7;color:#000;box-shadow:-5px 4px 5px rgba(126,126,126,.55);padding:1em .5em 2em 1em;overflow:auto;position:absolute;margin:0;bottom:0;right:0;top:0;z-index:9991;cursor:default}.ace_dark #ace_settingsmenu,.ace_dark #kbshortcutmenu{box-shadow:-20px 10px 25px rgba(126,126,126,.25);background-color:rgba(255,255,255,.6);color:#000}.ace_optionsMenuEntry:hover{background-color:rgba(100,100,100,.1);transition:all .3s}.ace_closeButton{background:rgba(245,146,146,.5);border:1px solid #f48a8a;border-radius:50%;padding:7px;position:absolute;right:-8px;top:-8px;z-index:100000}.ace_closeButton{background:rgba(245,146,146,.9)}.ace_optionsMenuKey{color:#483d8b;font-weight:700}.ace_optionsMenuCommand{color:#008b8b;font-weight:400}.ace_optionsMenuEntry button,.ace_optionsMenuEntry input{vertical-align:middle}.ace_optionsMenuEntry button[ace_selected_button=true]{background:#e7e7e7;box-shadow:1px 0 2px 0 #adadad inset;border-color:#adadad}.ace_optionsMenuEntry button{background:#fff;border:1px solid #d3d3d3;margin:0}.ace_optionsMenuEntry button:hover{background:#f0f0f0}.ace_prompt_container{max-width:603px;width:100%;margin:20px auto;padding:3px;background:#fff;border-radius:2px;box-shadow:0 2px 3px 0 #555}.ace_search{background-color:#ddd;color:#666;border:1px solid #cbcbcb;border-top:0 none;overflow:hidden;margin:0;padding:4px 6px 0 4px;position:absolute;top:0;z-index:99;white-space:normal}.ace_search.left{border-left:0 none;border-radius:0 0 5px 0;left:0}.ace_search.right{border-radius:0 0 0 5px;border-right:0 none;right:0}.ace_replace_form,.ace_search_form{margin:0 20px 4px 0;overflow:hidden;line-height:1.9}.ace_replace_form{margin-right:0}.ace_search_form.ace_nomatch{outline:1px solid red}.ace_search_field{border-radius:3px 0 0 3px;background-color:#fff;color:#000;border:1px solid #cbcbcb;border-right:0 none;outline:0;padding:0;font-size:inherit;margin:0;line-height:inherit;padding:0 6px;min-width:17em;vertical-align:top;min-height:1.8em;box-sizing:content-box}.ace_searchbtn{border:1px solid #cbcbcb;line-height:inherit;display:inline-block;padding:0 6px;background:#fff;border-right:0 none;border-left:1px solid #dcdcdc;cursor:pointer;margin:0;position:relative;color:#666}.ace_searchbtn:last-child{border-radius:0 3px 3px 0;border-right:1px solid #cbcbcb}.ace_searchbtn:disabled{background:0 0;cursor:default}.ace_searchbtn:hover{background-color:#eef1f6}.ace_searchbtn.next,.ace_searchbtn.prev{padding:0 .7em}.ace_searchbtn.next:after,.ace_searchbtn.prev:after{content:"";border:solid 2px #888;width:.5em;height:.5em;border-width:2px 0 0 2px;display:inline-block;transform:rotate(-45deg)}.ace_searchbtn.next:after{border-width:0 2px 2px 0}.ace_searchbtn_close{background:url("main-26.png") no-repeat 50% 0;border-radius:50%;border:0 none;color:#656565;cursor:pointer;font:16px/16px Arial;padding:0;height:14px;width:14px;top:9px;right:7px;position:absolute}.ace_searchbtn_close:hover{background-color:#656565;background-position:50% 100%;color:#fff}.ace_button{margin-left:2px;cursor:pointer;-webkit-user-select:none;-moz-user-select:none;-o-user-select:none;-ms-user-select:none;user-select:none;overflow:hidden;opacity:.7;border:1px solid rgba(100,100,100,.23);padding:1px;box-sizing:border-box!important;color:#000}.ace_button:hover{background-color:#eee;opacity:1}.ace_button:active{background-color:#ddd}.ace_button.checked{border-color:#39f;opacity:1}.ace_search_options{margin-bottom:3px;text-align:right;-webkit-user-select:none;-moz-user-select:none;-o-user-select:none;-ms-user-select:none;user-select:none;clear:both}.ace_search_counter{float:left;font-family:arial;padding:0 8px}.ace_occur-highlight{border-radius:4px;background-color:rgba(87,255,8,.25);position:absolute;z-index:4;box-sizing:border-box;box-shadow:0 0 4px #5bff32}.ace_dark .ace_occur-highlight{background-color:#508c55;box-shadow:0 0 4px #3c7846}.ace_marker-layer .ace_isearch-result{position:absolute;z-index:6;box-sizing:border-box}div.ace_isearch-result{border-radius:4px;background-color:rgba(255,200,0,.5);box-shadow:0 0 4px #ffc800}.ace_dark div.ace_isearch-result{background-color:#646ea0;box-shadow:0 0 4px #505a8c}.emacs-mode .ace_cursor{border:1px rgba(50,250,50,.8) solid!important;box-sizing:border-box!important;background-color:rgba(0,250,0,.9);opacity:.5}.emacs-mode .ace_hidden-cursors .ace_cursor{opacity:1;background-color:transparent}.emacs-mode .ace_overwrite-cursors .ace_cursor{opacity:1;background-color:transparent;border-width:0 0 2px 2px!important}.emacs-mode .ace_text-layer{z-index:4}.emacs-mode .ace_cursor-layer{z-index:2}.normal-mode .ace_cursor{border:none;background-color:rgba(255,0,0,.5)}.normal-mode .ace_hidden-cursors .ace_cursor{background-color:transparent;border:1px solid red;opacity:.7}.ace_dialog{position:absolute;left:0;right:0;background:inherit;z-index:15;padding:.1em .8em;overflow:hidden;color:inherit}.ace_dialog-top{border-bottom:1px solid #444;top:0}.ace_dialog-bottom{border-top:1px solid #444;bottom:0}.ace_dialog input{border:none;outline:0;background:0 0;width:20em;color:inherit;font-family:monospace}
8 +/*# sourceMappingURL=/sm/51590284bb802d5fb686f3a4c22ec5a8f0eefefb51088aaa9b3816f6c7e618c5.map */
\ No newline at end of file
webui/vendor/ace-min/ext-beautify [conflicted].js deleted
-8
@@ -1,8 +0,0 @@
1 -define("ace/ext/beautify",["require","exports","module","ace/token_iterator"],function(e,t,n){"use strict";function i(e,t){return e.type.lastIndexOf(t+".xml")>-1}var r=e("../token_iterator").TokenIterator;t.singletonTags=["area","base","br","col","command","embed","hr","html","img","input","keygen","link","meta","param","source","track","wbr"],t.blockTags=["article","aside","blockquote","body","div","dl","fieldset","footer","form","head","header","html","nav","ol","p","script","section","style","table","tbody","tfoot","thead","ul"],t.formatOptions={lineBreaksAfterCommasInCurlyBlock:!0},t.beautify=function(e){var n=new r(e,0,0),s=n.getCurrentToken(),o=e.getTabString(),u=t.singletonTags,a=t.blockTags,f=t.formatOptions||{},l,c=!1,h=!1,p=!1,d="",v="",m="",g=0,y=0,b=0,w=0,E=0,S=0,x=0,T,N=0,C=0,k=[],L=!1,A,O=!1,M=!1,_=!1,D=!1,P={0:0},H=[],B=!1,j=function(){l&&l.value&&l.type!=="string.regexp"&&(l.value=l.value.replace(/^\s*/,""))},F=function(){var e=d.length-1;for(;;){if(e==0)break;if(d[e]!==" ")break;e-=1}d=d.slice(0,e+1)},I=function(){d=d.trimRight(),c=!1};while(s!==null){N=n.getCurrentTokenRow(),k=n.$rowTokens,l=n.stepForward();if(typeof s!="undefined"){v=s.value,E=0,_=m==="style"||e.$modeId==="ace/mode/css",i(s,"tag-open")?(M=!0,l&&(D=a.indexOf(l.value)!==-1),v==="</"&&(D&&!c&&C<1&&C++,_&&(C=1),E=1,D=!1)):i(s,"tag-close")?M=!1:i(s,"comment.start")?D=!0:i(s,"comment.end")&&(D=!1),!M&&!C&&s.type==="paren.rparen"&&s.value.substr(0,1)==="}"&&C++,N!==T&&(C=N,T&&(C-=T));if(C){I();for(;C>0;C--)d+="\n";c=!0,!i(s,"comment")&&!s.type.match(/^(comment|string)$/)&&(v=v.trimLeft())}if(v){s.type==="keyword"&&v.match(/^(if|else|elseif|for|foreach|while|switch)$/)?(H[g]=v,j(),p=!0,v.match(/^(else|elseif)$/)&&d.match(/\}[\s]*$/)&&(I(),h=!0)):s.type==="paren.lparen"?(j(),v.substr(-1)==="{"&&(p=!0,O=!1,M||(C=1)),v.substr(0,1)==="{"&&(h=!0,d.substr(-1)!=="["&&d.trimRight().substr(-1)==="["?(I(),h=!1):d.trimRight().substr(-1)===")"?I():F())):s.type==="paren.rparen"?(E=1,v.substr(0,1)==="}"&&(H[g-1]==="case"&&E++,d.trimRight().substr(-1)==="{"?I():(h=!0,_&&(C+=2))),v.substr(0,1)==="]"&&d.substr(-1)!=="}"&&d.trimRight().substr(-1)==="}"&&(h=!1,w++,I()),v.substr(0,1)===")"&&d.substr(-1)!=="("&&d.trimRight().substr(-1)==="("&&(h=!1,w++,I()),F()):s.type!=="keyword.operator"&&s.type!=="keyword"||!v.match(/^(=|==|===|!=|!==|&&|\|\||and|or|xor|\+=|.=|>|>=|<|<=|=>)$/)?s.type==="punctuation.operator"&&v===";"?(I(),j(),p=!0,_&&C++):s.type==="punctuation.operator"&&v.match(/^(:|,)$/)?(I(),j(),v.match(/^(,)$/)&&x>0&&S===0&&f.lineBreaksAfterCommasInCurlyBlock?C++:(p=!0,c=!1)):s.type==="support.php_tag"&&v==="?>"&&!c?(I(),h=!0):i(s,"attribute-name")&&d.substr(-1).match(/^\s$/)?h=!0:i(s,"attribute-equals")?(F(),j()):i(s,"tag-close")?(F(),v==="/>"&&(h=!0)):s.type==="keyword"&&v.match(/^(case|default)$/)&&B&&(E=1):(I(),j(),h=!0,p=!0);if(c&&(!s.type.match(/^(comment)$/)||!!v.substr(0,1).match(/^[/#]$/))&&(!s.type.match(/^(string)$/)||!!v.substr(0,1).match(/^['"@]$/))){w=b;if(g>y){w++;for(A=g;A>y;A--)P[A]=w}else g<y&&(w=P[g]);y=g,b=w,E&&(w-=E),O&&!S&&(w++,O=!1);for(A=0;A<w;A++)d+=o}s.type==="keyword"&&v.match(/^(case|default)$/)?B===!1&&(H[g]=v,g++,B=!0):s.type==="keyword"&&v.match(/^(break)$/)&&H[g-1]&&H[g-1].match(/^(case|default)$/)&&(g--,B=!1),s.type==="paren.lparen"&&(S+=(v.match(/\(/g)||[]).length,x+=(v.match(/\{/g)||[]).length,g+=v.length),s.type==="keyword"&&v.match(/^(if|else|elseif|for|while)$/)?(O=!0,S=0):!S&&v.trim()&&s.type!=="comment"&&(O=!1);if(s.type==="paren.rparen"){S-=(v.match(/\)/g)||[]).length,x-=(v.match(/\}/g)||[]).length;for(A=0;A<v.length;A++)g--,v.substr(A,1)==="}"&&H[g]==="case"&&g--}s.type=="text"&&(v=v.replace(/\s+$/," ")),h&&!c&&(F(),d.substr(-1)!=="\n"&&(d+=" ")),d+=v,p&&(d+=" "),c=!1,h=!1,p=!1;if(i(s,"tag-close")&&(D||a.indexOf(m)!==-1)||i(s,"doctype")&&v===">")D&&l&&l.value==="</"?C=-1:C=1;l&&u.indexOf(l.value)===-1&&(i(s,"tag-open")&&v==="</"?g--:i(s,"tag-open")&&v==="<"?g++:i(s,"tag-close")&&v==="/>"&&g--),i(s,"tag-name")&&(m=v),T=N}}s=l}d=d.trim(),e.doc.setValue(d)},t.commands=[{name:"beautify",description:"Format selection (Beautify)",exec:function(e){t.beautify(e.session)},bindKey:"Ctrl-Shift-B"}]}); (function() {
2 - window.require(["ace/ext/beautify"], function(m) {
3 - if (typeof module == "object" && typeof exports == "object" && module) {
4 - module.exports = m;
5 - }
6 - });
7 - })();
8 -
\ No newline at end of file
webui/vendor/ace-min/main-1.png
Binary files /dev/null and b/webui/vendor/ace-min/main-1.png differ
webui/vendor/ace-min/main-10.svg new
+4
@@ -0,0 +1,4 @@
1 +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 16" fill="none">
2 + <path d="m 18.929851,7.8298076 c 0.146353,6.3374604 -6.323147,7.7778444 -7.477912,7.7778444 -2.1072726,-0.12875 5.117678,0.356249 5.051698,-7.8700618 -0.604672,-8.00397349 -7.0772706,-7.5631189 -4.8573,-7.43039556 1.606,-0.11514225 6.897485,1.26254596 7.283514,7.52261296 z" fill="crimson" stroke-width="2"/>
3 + <path fill-rule="evenodd" clip-rule="evenodd" d="m 8.1147562,2.0529828 c 3.3491698,0 6.0641328,2.6768627 6.0641328,5.978953 0,3.3021122 -2.714963,5.9789202 -6.0641328,5.9789202 -3.3491473,0 -6.0641772,-2.676808 -6.0641772,-5.9789202 0.00539,-3.2998861 2.7172656,-5.9736408 6.0641772,-5.978953 z m 0,-1.73582719 c -4.3214836,0 -7.82474038,3.45401849 -7.82474038,7.71478019 0,4.2607282 3.50325678,7.7147452 7.82474038,7.7147452 4.3214498,0 7.8246998,-3.454017 7.8246998,-7.7147452 0,-2.0460914 -0.824392,-4.0083672 -2.291756,-5.4551746 C 12.180225,1.1299648 10.190013,0.31715561 8.1147562,0.31715561 Z M 6.9374563,8.2405985 4.6718685,10.485852 6.0086814,11.876728 8.3170035,9.6007911 10.625337,11.876728 11.962138,10.485852 9.6965508,8.2405985 11.962138,6.0068066 10.573246,4.6374335 8.3170035,6.8734297 6.0607607,4.6374335 4.6718685,6.0068066 Z" fill="crimson" stroke-width="2"/>
4 +</svg>
\ No newline at end of file
webui/vendor/ace-min/main-11.svg new
+5
@@ -0,0 +1,5 @@
1 +
2 +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 17 14" fill="none">
3 + <path d="M10.0001 13.6992C10.0001 13.6992 11.9241 13.4763 13 12.6992C14.4139 11.6781 16 10.5 16.1251 6.81126V2.58987C16.1251 2.54768 16.1221 2.50619 16.1164 2.46559V1.71485H15.2414L15.2307 1.71484L14.6251 1.69922V6.81123C14.6251 8.51061 14.6251 9.46461 12.7824 11.721C12.1586 12.4848 10.0001 13.6992 10.0001 13.6992Z" fill="crimson" stroke-width="2"/>
4 + <path fill-rule="evenodd" clip-rule="evenodd" d="M7.33609 0.367475C7.03214 0.152652 6.62548 0.153614 6.32253 0.369997L6.30869 0.379554C6.29553 0.388588 6.27388 0.403266 6.24417 0.422789C6.18471 0.46186 6.09321 0.520171 5.97313 0.591373C5.73251 0.734059 5.3799 0.926864 4.94279 1.12009C4.06144 1.5097 2.87541 1.88377 1.58984 1.88377H0.714844V2.75877V6.98015C0.714844 9.49374 2.28866 11.1973 3.70254 12.2185C4.41845 12.7355 5.12874 13.1053 5.65733 13.3457C5.92284 13.4664 6.14566 13.5559 6.30465 13.6161C6.38423 13.6462 6.44805 13.669 6.49349 13.6848C6.51622 13.6927 6.53438 13.6989 6.54764 13.7033L6.56382 13.7087L6.56908 13.7104L6.57099 13.711L6.83984 13.7533L6.57242 13.7115C6.74633 13.7673 6.93335 13.7673 7.10727 13.7115L7.1087 13.711L7.11061 13.7104L7.11587 13.7087L7.13205 13.7033C7.14531 13.6989 7.16346 13.6927 7.18619 13.6848C7.23164 13.669 7.29546 13.6462 7.37503 13.6161C7.53403 13.5559 7.75685 13.4664 8.02236 13.3457C8.55095 13.1053 9.26123 12.7355 9.97715 12.2185C11.391 11.1973 12.9648 9.49377 12.9648 6.98018V2.7588C12.9648 2.7166 12.9619 2.67511 12.9561 2.63451V1.88377H12.0811C12.0775 1.88377 12.074 1.88377 12.0704 1.88377C10.7979 1.88004 9.61962 1.51102 8.73894 1.12486C8.73534 1.12327 8.73174 1.12168 8.72814 1.12009C8.29103 0.926864 7.93842 0.734059 7.69779 0.591373C7.57772 0.520171 7.48622 0.46186 7.42676 0.422789C7.39705 0.403266 7.37539 0.388588 7.36224 0.379554L7.34896 0.37035C7.34896 0.37035 7.34847 0.37002 7.34563 0.374054L7.33779 0.368659L7.33609 0.367475ZM8.03471 2.72691C8.8604 3.09063 9.96066 3.46309 11.2061 3.58907V6.98015H11.2148C11.2148 8.67953 10.1637 9.92507 8.95254 10.7998C8.35595 11.2306 7.75374 11.5454 7.29796 11.7527C7.11671 11.8351 6.96062 11.8996 6.83984 11.9469C6.71906 11.8996 6.56297 11.8351 6.38173 11.7527C5.92595 11.5454 5.32373 11.2306 4.72715 10.7998C3.51603 9.92507 2.46484 8.67955 2.46484 6.98018V3.58909C3.71738 3.46239 4.82308 3.08639 5.65033 2.72071C6.14228 2.50324 6.54485 2.28537 6.83254 2.11624C7.12181 2.28535 7.527 2.50352 8.02196 2.72131C8.0262 2.72317 8.03045 2.72504 8.03471 2.72691ZM5.96484 3.40147V7.77647H7.71484V3.40147H5.96484ZM5.96484 10.4015V8.65147H7.71484V10.4015H5.96484Z" fill="crimson" stroke-width="2"/>
5 +</svg>
\ No newline at end of file
webui/vendor/ace-min/main-12.svg new
+4
@@ -0,0 +1,4 @@
1 +<svg width="20" height="16" viewBox="0 0 20 16" fill="none" xmlns="http://www.w3.org/2000/svg">
2 +<path fill-rule="evenodd" clip-rule="evenodd" d="M14.7769 14.7337L8.65192 2.48369C8.32946 1.83877 7.40913 1.83877 7.08667 2.48369L0.961669 14.7337C0.670775 15.3155 1.09383 16 1.74429 16H13.9943C14.6448 16 15.0678 15.3155 14.7769 14.7337ZM3.16007 14.25L7.86929 4.83156L12.5785 14.25H3.16007ZM8.74429 11.625V13.375H6.99429V11.625H8.74429ZM6.99429 10.75V7.25H8.74429V10.75H6.99429Z" fill="#EC7211"/>
3 +<path d="M11.1991 2.95238C10.8809 2.31467 10.3537 1.80526 9.7055 1.509L11.041 1.06978C11.6883 0.949814 12.337 1.27263 12.6317 1.86141L17.6136 11.8161C18.3527 13.2929 17.5938 15.0804 16.018 15.5745C16.4044 14.4507 16.3231 13.2188 15.7924 12.1555L11.1991 2.95238Z" fill="#EC7211"/>
4 +</svg>
\ No newline at end of file
webui/vendor/ace-min/main-13.png
Binary files /dev/null and b/webui/vendor/ace-min/main-13.png differ
webui/vendor/ace-min/main-14.png
Binary files /dev/null and b/webui/vendor/ace-min/main-14.png differ
webui/vendor/ace-min/main-15.png
Binary files /dev/null and b/webui/vendor/ace-min/main-15.png differ
webui/vendor/ace-min/main-16.png
Binary files /dev/null and b/webui/vendor/ace-min/main-16.png differ
webui/vendor/ace-min/main-17.png
Binary files /dev/null and b/webui/vendor/ace-min/main-17.png differ
webui/vendor/ace-min/main-18.png
Binary files /dev/null and b/webui/vendor/ace-min/main-18.png differ
webui/vendor/ace-min/main-19.png
Binary files /dev/null and b/webui/vendor/ace-min/main-19.png differ
webui/vendor/ace-min/main-2.png
Binary files /dev/null and b/webui/vendor/ace-min/main-2.png differ
webui/vendor/ace-min/main-20.png
Binary files /dev/null and b/webui/vendor/ace-min/main-20.png differ
webui/vendor/ace-min/main-21.png
Binary files /dev/null and b/webui/vendor/ace-min/main-21.png differ
webui/vendor/ace-min/main-22.png
Binary files /dev/null and b/webui/vendor/ace-min/main-22.png differ
webui/vendor/ace-min/main-23.png
Binary files /dev/null and b/webui/vendor/ace-min/main-23.png differ
webui/vendor/ace-min/main-24.png
Binary files /dev/null and b/webui/vendor/ace-min/main-24.png differ
webui/vendor/ace-min/main-25.svg new
+1
@@ -0,0 +1 @@
1 +<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" clip-rule="evenodd" d="M5.83701 15L4.58751 13.7155L10.1468 8L4.58751 2.28446L5.83701 1L12.6465 8L5.83701 15Z" fill="black"/></svg>
\ No newline at end of file
webui/vendor/ace-min/main-26.png
Binary files /dev/null and b/webui/vendor/ace-min/main-26.png differ
webui/vendor/ace-min/main-3.png
Binary files /dev/null and b/webui/vendor/ace-min/main-3.png differ
webui/vendor/ace-min/main-4.png
Binary files /dev/null and b/webui/vendor/ace-min/main-4.png differ
webui/vendor/ace-min/main-5.svg new
+7
@@ -0,0 +1,7 @@
1 +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 16">
2 +<g stroke-width="2" stroke="red" shape-rendering="geometricPrecision">
3 +<circle fill="none" cx="8" cy="8" r="7" stroke-linejoin="round"/>
4 +<line x1="11" y1="5" x2="5" y2="11"/>
5 +<line x1="11" y1="11" x2="5" y2="5"/>
6 +</g>
7 +</svg>
\ No newline at end of file
webui/vendor/ace-min/main-6.svg new
+9
@@ -0,0 +1,9 @@
1 +<svg viewBox="0 0 20 16" xmlns="http://www.w3.org/2000/svg">
2 + <g stroke-width="2" stroke="darkorange" fill="none" shape-rendering="geometricPrecision">
3 + <path class="stroke-linejoin-round" d="M8 14.8307C8 14.8307 2 12.9047 2 8.08992V3.26548C5.31 3.26548 7.98999 1.34918 7.98999 1.34918C7.98999 1.34918 10.69 3.26548 14 3.26548V8.08992C14 12.9047 8 14.8307 8 14.8307Z"/>
4 + <path d="M2 8.08992V3.26548C5.31 3.26548 7.98999 1.34918 7.98999 1.34918"/>
5 + <path d="M13.99 8.08992V3.26548C10.68 3.26548 8 1.34918 8 1.34918"/>
6 + <path class="stroke-linejoin-round" d="M8 4V9"/>
7 + <path class="stroke-linejoin-round" d="M8 10V12"/>
8 + </g>
9 +</svg>
\ No newline at end of file
webui/vendor/ace-min/main-7.svg new
+7
@@ -0,0 +1,7 @@
1 +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 16">
2 +<g stroke-width="2" stroke="darkorange" shape-rendering="geometricPrecision">
3 +<polygon stroke-linejoin="round" fill="none" points="8 1 15 15 1 15 8 1"/>
4 +<rect x="8" y="12" width="0.01" height="0.01"/>
5 +<line x1="8" y1="6" x2="8" y2="10"/>
6 +</g>
7 +</svg>
\ No newline at end of file
webui/vendor/ace-min/main-8.svg new
+9
@@ -0,0 +1,9 @@
1 +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 16">
2 +<g stroke-width="2" stroke="blue" shape-rendering="geometricPrecision">
3 +<circle fill="none" cx="8" cy="8" r="7" stroke-linejoin="round"/>
4 +<polyline points="8 11 8 8"/>
5 +<polyline points="9 8 6 8"/>
6 +<line x1="10" y1="11" x2="6" y2="11"/>
7 +<rect x="8" y="5" width="0.01" height="0.01"/>
8 +</g>
9 +</svg>
\ No newline at end of file
webui/vendor/ace-min/main-9.svg new
+6
@@ -0,0 +1,6 @@
1 +<svg viewBox="0 0 20 16" xmlns="http://www.w3.org/2000/svg">
2 + <g stroke-width="2" stroke="silver" fill="none" shape-rendering="geometricPrecision">
3 + <path class="stroke-linejoin-round" d="M6 14H10"/>
4 + <path d="M8 11H9C9 9.47002 12 8.54002 12 5.76002C12.02 4.40002 11.39 3.36002 10.43 2.67002C9 1.64002 7.00001 1.64002 5.57001 2.67002C4.61001 3.36002 3.98 4.40002 4 5.76002C4 8.54002 7.00001 9.47002 7.00001 11H8Z"/>
5 + </g>
6 +</svg>
\ No newline at end of file
webui/vendor/socket.io.esm.min.js new
+7
@@ -0,0 +1,7 @@
1 +/*!
2 + * Socket.IO v4.8.1
3 + * (c) 2014-2024 Guillermo Rauch
4 + * Released under the MIT License.
5 + */
6 +const t=Object.create(null);t.open="0",t.close="1",t.ping="2",t.pong="3",t.message="4",t.upgrade="5",t.noop="6";const s=Object.create(null);Object.keys(t).forEach((i=>{s[t[i]]=i}));const i={type:"error",data:"parser error"},e="function"==typeof Blob||"undefined"!=typeof Blob&&"[object BlobConstructor]"===Object.prototype.toString.call(Blob),n="function"==typeof ArrayBuffer,r=t=>"function"==typeof ArrayBuffer.isView?ArrayBuffer.isView(t):t&&t.buffer instanceof ArrayBuffer,o=({type:s,data:i},o,c)=>e&&i instanceof Blob?o?c(i):h(i,c):n&&(i instanceof ArrayBuffer||r(i))?o?c(i):h(new Blob([i]),c):c(t[s]+(i||"")),h=(t,s)=>{const i=new FileReader;return i.onload=function(){const t=i.result.split(",")[1];s("b"+(t||""))},i.readAsDataURL(t)};function c(t){return t instanceof Uint8Array?t:t instanceof ArrayBuffer?new Uint8Array(t):new Uint8Array(t.buffer,t.byteOffset,t.byteLength)}let a;const u="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/",f="undefined"==typeof Uint8Array?[]:new Uint8Array(256);for(let t=0;t<64;t++)f[u.charCodeAt(t)]=t;const l="function"==typeof ArrayBuffer,d=(t,e)=>{if("string"!=typeof t)return{type:"message",data:y(t,e)};const n=t.charAt(0);if("b"===n)return{type:"message",data:p(t.substring(1),e)};return s[n]?t.length>1?{type:s[n],data:t.substring(1)}:{type:s[n]}:i},p=(t,s)=>{if(l){const i=(t=>{let s,i,e,n,r,o=.75*t.length,h=t.length,c=0;"="===t[t.length-1]&&(o--,"="===t[t.length-2]&&o--);const a=new ArrayBuffer(o),u=new Uint8Array(a);for(s=0;s<h;s+=4)i=f[t.charCodeAt(s)],e=f[t.charCodeAt(s+1)],n=f[t.charCodeAt(s+2)],r=f[t.charCodeAt(s+3)],u[c++]=i<<2|e>>4,u[c++]=(15&e)<<4|n>>2,u[c++]=(3&n)<<6|63&r;return a})(t);return y(i,s)}return{base64:!0,data:t}},y=(t,s)=>"blob"===s?t instanceof Blob?t:new Blob([t]):t instanceof ArrayBuffer?t:t.buffer,b=String.fromCharCode(30);function g(){return new TransformStream({transform(t,s){!function(t,s){e&&t.data instanceof Blob?t.data.arrayBuffer().then(c).then(s):n&&(t.data instanceof ArrayBuffer||r(t.data))?s(c(t.data)):o(t,!1,(t=>{a||(a=new TextEncoder),s(a.encode(t))}))}(t,(i=>{const e=i.length;let n;if(e<126)n=new Uint8Array(1),new DataView(n.buffer).setUint8(0,e);else if(e<65536){n=new Uint8Array(3);const t=new DataView(n.buffer);t.setUint8(0,126),t.setUint16(1,e)}else{n=new Uint8Array(9);const t=new DataView(n.buffer);t.setUint8(0,127),t.setBigUint64(1,BigInt(e))}t.data&&"string"!=typeof t.data&&(n[0]|=128),s.enqueue(n),s.enqueue(i)}))}})}let w;function v(t){return t.reduce(((t,s)=>t+s.length),0)}function m(t,s){if(t[0].length===s)return t.shift();const i=new Uint8Array(s);let e=0;for(let n=0;n<s;n++)i[n]=t[0][e++],e===t[0].length&&(t.shift(),e=0);return t.length&&e<t[0].length&&(t[0]=t[0].slice(e)),i}function k(t){if(t)return function(t){for(var s in k.prototype)t[s]=k.prototype[s];return t}(t)}k.prototype.on=k.prototype.addEventListener=function(t,s){return this.t=this.t||{},(this.t["$"+t]=this.t["$"+t]||[]).push(s),this},k.prototype.once=function(t,s){function i(){this.off(t,i),s.apply(this,arguments)}return i.fn=s,this.on(t,i),this},k.prototype.off=k.prototype.removeListener=k.prototype.removeAllListeners=k.prototype.removeEventListener=function(t,s){if(this.t=this.t||{},0==arguments.length)return this.t={},this;var i,e=this.t["$"+t];if(!e)return this;if(1==arguments.length)return delete this.t["$"+t],this;for(var n=0;n<e.length;n++)if((i=e[n])===s||i.fn===s){e.splice(n,1);break}return 0===e.length&&delete this.t["$"+t],this},k.prototype.emit=function(t){this.t=this.t||{};for(var s=new Array(arguments.length-1),i=this.t["$"+t],e=1;e<arguments.length;e++)s[e-1]=arguments[e];if(i){e=0;for(var n=(i=i.slice(0)).length;e<n;++e)i[e].apply(this,s)}return this},k.prototype.emitReserved=k.prototype.emit,k.prototype.listeners=function(t){return this.t=this.t||{},this.t["$"+t]||[]},k.prototype.hasListeners=function(t){return!!this.listeners(t).length};const A="function"==typeof Promise&&"function"==typeof Promise.resolve?t=>Promise.resolve().then(t):(t,s)=>s(t,0),E="undefined"!=typeof self?self:"undefined"!=typeof window?window:Function("return this")();function O(t,...s){return s.reduce(((s,i)=>(t.hasOwnProperty(i)&&(s[i]=t[i]),s)),{})}const _=E.setTimeout,j=E.clearTimeout;function x(t,s){s.useNativeTimers?(t.setTimeoutFn=_.bind(E),t.clearTimeoutFn=j.bind(E)):(t.setTimeoutFn=E.setTimeout.bind(E),t.clearTimeoutFn=E.clearTimeout.bind(E))}function B(){return Date.now().toString(36).substring(3)+Math.random().toString(36).substring(2,5)}class C extends Error{constructor(t,s,i){super(t),this.description=s,this.context=i,this.type="TransportError"}}class T extends k{constructor(t){super(),this.writable=!1,x(this,t),this.opts=t,this.query=t.query,this.socket=t.socket,this.supportsBinary=!t.forceBase64}onError(t,s,i){return super.emitReserved("error",new C(t,s,i)),this}open(){return this.readyState="opening",this.doOpen(),this}close(){return"opening"!==this.readyState&&"open"!==this.readyState||(this.doClose(),this.onClose()),this}send(t){"open"===this.readyState&&this.write(t)}onOpen(){this.readyState="open",this.writable=!0,super.emitReserved("open")}onData(t){const s=d(t,this.socket.binaryType);this.onPacket(s)}onPacket(t){super.emitReserved("packet",t)}onClose(t){this.readyState="closed",super.emitReserved("close",t)}pause(t){}createUri(t,s={}){return t+"://"+this.i()+this.o()+this.opts.path+this.h(s)}i(){const t=this.opts.hostname;return-1===t.indexOf(":")?t:"["+t+"]"}o(){return this.opts.port&&(this.opts.secure&&Number(443!==this.opts.port)||!this.opts.secure&&80!==Number(this.opts.port))?":"+this.opts.port:""}h(t){const s=function(t){let s="";for(let i in t)t.hasOwnProperty(i)&&(s.length&&(s+="&"),s+=encodeURIComponent(i)+"="+encodeURIComponent(t[i]));return s}(t);return s.length?"?"+s:""}}class N extends T{constructor(){super(...arguments),this.u=!1}get name(){return"polling"}doOpen(){this.l()}pause(t){this.readyState="pausing";const s=()=>{this.readyState="paused",t()};if(this.u||!this.writable){let t=0;this.u&&(t++,this.once("pollComplete",(function(){--t||s()}))),this.writable||(t++,this.once("drain",(function(){--t||s()})))}else s()}l(){this.u=!0,this.doPoll(),this.emitReserved("poll")}onData(t){((t,s)=>{const i=t.split(b),e=[];for(let t=0;t<i.length;t++){const n=d(i[t],s);if(e.push(n),"error"===n.type)break}return e})(t,this.socket.binaryType).forEach((t=>{if("opening"===this.readyState&&"open"===t.type&&this.onOpen(),"close"===t.type)return this.onClose({description:"transport closed by the server"}),!1;this.onPacket(t)})),"closed"!==this.readyState&&(this.u=!1,this.emitReserved("pollComplete"),"open"===this.readyState&&this.l())}doClose(){const t=()=>{this.write([{type:"close"}])};"open"===this.readyState?t():this.once("open",t)}write(t){this.writable=!1,((t,s)=>{const i=t.length,e=new Array(i);let n=0;t.forEach(((t,r)=>{o(t,!1,(t=>{e[r]=t,++n===i&&s(e.join(b))}))}))})(t,(t=>{this.doWrite(t,(()=>{this.writable=!0,this.emitReserved("drain")}))}))}uri(){const t=this.opts.secure?"https":"http",s=this.query||{};return!1!==this.opts.timestampRequests&&(s[this.opts.timestampParam]=B()),this.supportsBinary||s.sid||(s.b64=1),this.createUri(t,s)}}let U=!1;try{U="undefined"!=typeof XMLHttpRequest&&"withCredentials"in new XMLHttpRequest}catch(t){}const P=U;function D(){}class M extends N{constructor(t){if(super(t),"undefined"!=typeof location){const s="https:"===location.protocol;let i=location.port;i||(i=s?"443":"80"),this.xd="undefined"!=typeof location&&t.hostname!==location.hostname||i!==t.port}}doWrite(t,s){const i=this.request({method:"POST",data:t});i.on("success",s),i.on("error",((t,s)=>{this.onError("xhr post error",t,s)}))}doPoll(){const t=this.request();t.on("data",this.onData.bind(this)),t.on("error",((t,s)=>{this.onError("xhr poll error",t,s)})),this.pollXhr=t}}class S extends k{constructor(t,s,i){super(),this.createRequest=t,x(this,i),this.p=i,this.v=i.method||"GET",this.m=s,this.k=void 0!==i.data?i.data:null,this.A()}A(){var t;const s=O(this.p,"agent","pfx","key","passphrase","cert","ca","ciphers","rejectUnauthorized","autoUnref");s.xdomain=!!this.p.xd;const i=this.O=this.createRequest(s);try{i.open(this.v,this.m,!0);try{if(this.p.extraHeaders){i.setDisableHeaderCheck&&i.setDisableHeaderCheck(!0);for(let t in this.p.extraHeaders)this.p.extraHeaders.hasOwnProperty(t)&&i.setRequestHeader(t,this.p.extraHeaders[t])}}catch(t){}if("POST"===this.v)try{i.setRequestHeader("Content-type","text/plain;charset=UTF-8")}catch(t){}try{i.setRequestHeader("Accept","*/*")}catch(t){}null===(t=this.p.cookieJar)||void 0===t||t.addCookies(i),"withCredentials"in i&&(i.withCredentials=this.p.withCredentials),this.p.requestTimeout&&(i.timeout=this.p.requestTimeout),i.onreadystatechange=()=>{var t;3===i.readyState&&(null===(t=this.p.cookieJar)||void 0===t||t.parseCookies(i.getResponseHeader("set-cookie"))),4===i.readyState&&(200===i.status||1223===i.status?this._():this.setTimeoutFn((()=>{this.j("number"==typeof i.status?i.status:0)}),0))},i.send(this.k)}catch(t){return void this.setTimeoutFn((()=>{this.j(t)}),0)}"undefined"!=typeof document&&(this.B=S.requestsCount++,S.requests[this.B]=this)}j(t){this.emitReserved("error",t,this.O),this.C(!0)}C(t){if(void 0!==this.O&&null!==this.O){if(this.O.onreadystatechange=D,t)try{this.O.abort()}catch(t){}"undefined"!=typeof document&&delete S.requests[this.B],this.O=null}}_(){const t=this.O.responseText;null!==t&&(this.emitReserved("data",t),this.emitReserved("success"),this.C())}abort(){this.C()}}if(S.requestsCount=0,S.requests={},"undefined"!=typeof document)if("function"==typeof attachEvent)attachEvent("onunload",L);else if("function"==typeof addEventListener){addEventListener("onpagehide"in E?"pagehide":"unload",L,!1)}function L(){for(let t in S.requests)S.requests.hasOwnProperty(t)&&S.requests[t].abort()}const R=function(){const t=F({xdomain:!1});return t&&null!==t.responseType}();class I extends M{constructor(t){super(t);const s=t&&t.forceBase64;this.supportsBinary=R&&!s}request(t={}){return Object.assign(t,{xd:this.xd},this.opts),new S(F,this.uri(),t)}}function F(t){const s=t.xdomain;try{if("undefined"!=typeof XMLHttpRequest&&(!s||P))return new XMLHttpRequest}catch(t){}if(!s)try{return new(E[["Active"].concat("Object").join("X")])("Microsoft.XMLHTTP")}catch(t){}}const $="undefined"!=typeof navigator&&"string"==typeof navigator.product&&"reactnative"===navigator.product.toLowerCase();class V extends T{get name(){return"websocket"}doOpen(){const t=this.uri(),s=this.opts.protocols,i=$?{}:O(this.opts,"agent","perMessageDeflate","pfx","key","passphrase","cert","ca","ciphers","rejectUnauthorized","localAddress","protocolVersion","origin","maxPayload","family","checkServerIdentity");this.opts.extraHeaders&&(i.headers=this.opts.extraHeaders);try{this.ws=this.createSocket(t,s,i)}catch(t){return this.emitReserved("error",t)}this.ws.binaryType=this.socket.binaryType,this.addEventListeners()}addEventListeners(){this.ws.onopen=()=>{this.opts.autoUnref&&this.ws.T.unref(),this.onOpen()},this.ws.onclose=t=>this.onClose({description:"websocket connection closed",context:t}),this.ws.onmessage=t=>this.onData(t.data),this.ws.onerror=t=>this.onError("websocket error",t)}write(t){this.writable=!1;for(let s=0;s<t.length;s++){const i=t[s],e=s===t.length-1;o(i,this.supportsBinary,(t=>{try{this.doWrite(i,t)}catch(t){}e&&A((()=>{this.writable=!0,this.emitReserved("drain")}),this.setTimeoutFn)}))}}doClose(){void 0!==this.ws&&(this.ws.onerror=()=>{},this.ws.close(),this.ws=null)}uri(){const t=this.opts.secure?"wss":"ws",s=this.query||{};return this.opts.timestampRequests&&(s[this.opts.timestampParam]=B()),this.supportsBinary||(s.b64=1),this.createUri(t,s)}}const H=E.WebSocket||E.MozWebSocket;class W extends V{createSocket(t,s,i){return $?new H(t,s,i):s?new H(t,s):new H(t)}doWrite(t,s){this.ws.send(s)}}class q extends T{get name(){return"webtransport"}doOpen(){try{this.N=new WebTransport(this.createUri("https"),this.opts.transportOptions[this.name])}catch(t){return this.emitReserved("error",t)}this.N.closed.then((()=>{this.onClose()})).catch((t=>{this.onError("webtransport error",t)})),this.N.ready.then((()=>{this.N.createBidirectionalStream().then((t=>{const s=function(t,s){w||(w=new TextDecoder);const e=[];let n=0,r=-1,o=!1;return new TransformStream({transform(h,c){for(e.push(h);;){if(0===n){if(v(e)<1)break;const t=m(e,1);o=!(128&~t[0]),r=127&t[0],n=r<126?3:126===r?1:2}else if(1===n){if(v(e)<2)break;const t=m(e,2);r=new DataView(t.buffer,t.byteOffset,t.length).getUint16(0),n=3}else if(2===n){if(v(e)<8)break;const t=m(e,8),s=new DataView(t.buffer,t.byteOffset,t.length),o=s.getUint32(0);if(o>Math.pow(2,21)-1){c.enqueue(i);break}r=o*Math.pow(2,32)+s.getUint32(4),n=3}else{if(v(e)<r)break;const t=m(e,r);c.enqueue(d(o?t:w.decode(t),s)),n=0}if(0===r||r>t){c.enqueue(i);break}}}})}(Number.MAX_SAFE_INTEGER,this.socket.binaryType),e=t.readable.pipeThrough(s).getReader(),n=g();n.readable.pipeTo(t.writable),this.U=n.writable.getWriter();const r=()=>{e.read().then((({done:t,value:s})=>{t||(this.onPacket(s),r())})).catch((t=>{}))};r();const o={type:"open"};this.query.sid&&(o.data=`{"sid":"${this.query.sid}"}`),this.U.write(o).then((()=>this.onOpen()))}))}))}write(t){this.writable=!1;for(let s=0;s<t.length;s++){const i=t[s],e=s===t.length-1;this.U.write(i).then((()=>{e&&A((()=>{this.writable=!0,this.emitReserved("drain")}),this.setTimeoutFn)}))}}doClose(){var t;null===(t=this.N)||void 0===t||t.close()}}const X={websocket:W,webtransport:q,polling:I},z=/^(?:(?![^:@\/?#]+:[^:@\/]*@)(http|https|ws|wss):\/\/)?((?:(([^:@\/?#]*)(?::([^:@\/?#]*))?)?@)?((?:[a-f0-9]{0,4}:){2,7}[a-f0-9]{0,4}|[^:\/?#]*)(?::(\d*))?)(((\/(?:[^?#](?![^?#\/]*\.[^?#\/.]+(?:[?#]|$)))*\/?)?([^?#\/]*))(?:\?([^#]*))?(?:#(.*))?)/,J=["source","protocol","authority","userInfo","user","password","host","port","relative","path","directory","file","query","anchor"];function Q(t){if(t.length>8e3)throw"URI too long";const s=t,i=t.indexOf("["),e=t.indexOf("]");-1!=i&&-1!=e&&(t=t.substring(0,i)+t.substring(i,e).replace(/:/g,";")+t.substring(e,t.length));let n=z.exec(t||""),r={},o=14;for(;o--;)r[J[o]]=n[o]||"";return-1!=i&&-1!=e&&(r.source=s,r.host=r.host.substring(1,r.host.length-1).replace(/;/g,":"),r.authority=r.authority.replace("[","").replace("]","").replace(/;/g,":"),r.ipv6uri=!0),r.pathNames=function(t,s){const i=/\/{2,9}/g,e=s.replace(i,"/").split("/");"/"!=s.slice(0,1)&&0!==s.length||e.splice(0,1);"/"==s.slice(-1)&&e.splice(e.length-1,1);return e}(0,r.path),r.queryKey=function(t,s){const i={};return s.replace(/(?:^|&)([^&=]*)=?([^&]*)/g,(function(t,s,e){s&&(i[s]=e)})),i}(0,r.query),r}const G="function"==typeof addEventListener&&"function"==typeof removeEventListener,K=[];G&&addEventListener("offline",(()=>{K.forEach((t=>t()))}),!1);class Y extends k{constructor(t,s){if(super(),this.binaryType="arraybuffer",this.writeBuffer=[],this.P=0,this.D=-1,this.M=-1,this.S=-1,this.L=1/0,t&&"object"==typeof t&&(s=t,t=null),t){const i=Q(t);s.hostname=i.host,s.secure="https"===i.protocol||"wss"===i.protocol,s.port=i.port,i.query&&(s.query=i.query)}else s.host&&(s.hostname=Q(s.host).host);x(this,s),this.secure=null!=s.secure?s.secure:"undefined"!=typeof location&&"https:"===location.protocol,s.hostname&&!s.port&&(s.port=this.secure?"443":"80"),this.hostname=s.hostname||("undefined"!=typeof location?location.hostname:"localhost"),this.port=s.port||("undefined"!=typeof location&&location.port?location.port:this.secure?"443":"80"),this.transports=[],this.R={},s.transports.forEach((t=>{const s=t.prototype.name;this.transports.push(s),this.R[s]=t})),this.opts=Object.assign({path:"/engine.io",agent:!1,withCredentials:!1,upgrade:!0,timestampParam:"t",rememberUpgrade:!1,addTrailingSlash:!0,rejectUnauthorized:!0,perMessageDeflate:{threshold:1024},transportOptions:{},closeOnBeforeunload:!1},s),this.opts.path=this.opts.path.replace(/\/$/,"")+(this.opts.addTrailingSlash?"/":""),"string"==typeof this.opts.query&&(this.opts.query=function(t){let s={},i=t.split("&");for(let t=0,e=i.length;t<e;t++){let e=i[t].split("=");s[decodeURIComponent(e[0])]=decodeURIComponent(e[1])}return s}(this.opts.query)),G&&(this.opts.closeOnBeforeunload&&(this.I=()=>{this.transport&&(this.transport.removeAllListeners(),this.transport.close())},addEventListener("beforeunload",this.I,!1)),"localhost"!==this.hostname&&(this.F=()=>{this.$("transport close",{description:"network connection lost"})},K.push(this.F))),this.opts.withCredentials&&(this.V=void 0),this.H()}createTransport(t){const s=Object.assign({},this.opts.query);s.EIO=4,s.transport=t,this.id&&(s.sid=this.id);const i=Object.assign({},this.opts,{query:s,socket:this,hostname:this.hostname,secure:this.secure,port:this.port},this.opts.transportOptions[t]);return new this.R[t](i)}H(){if(0===this.transports.length)return void this.setTimeoutFn((()=>{this.emitReserved("error","No transports available")}),0);const t=this.opts.rememberUpgrade&&Y.priorWebsocketSuccess&&-1!==this.transports.indexOf("websocket")?"websocket":this.transports[0];this.readyState="opening";const s=this.createTransport(t);s.open(),this.setTransport(s)}setTransport(t){this.transport&&this.transport.removeAllListeners(),this.transport=t,t.on("drain",this.W.bind(this)).on("packet",this.q.bind(this)).on("error",this.j.bind(this)).on("close",(t=>this.$("transport close",t)))}onOpen(){this.readyState="open",Y.priorWebsocketSuccess="websocket"===this.transport.name,this.emitReserved("open"),this.flush()}q(t){if("opening"===this.readyState||"open"===this.readyState||"closing"===this.readyState)switch(this.emitReserved("packet",t),this.emitReserved("heartbeat"),t.type){case"open":this.onHandshake(JSON.parse(t.data));break;case"ping":this.X("pong"),this.emitReserved("ping"),this.emitReserved("pong"),this.J();break;case"error":const s=new Error("server error");s.code=t.data,this.j(s);break;case"message":this.emitReserved("data",t.data),this.emitReserved("message",t.data)}}onHandshake(t){this.emitReserved("handshake",t),this.id=t.sid,this.transport.query.sid=t.sid,this.D=t.pingInterval,this.M=t.pingTimeout,this.S=t.maxPayload,this.onOpen(),"closed"!==this.readyState&&this.J()}J(){this.clearTimeoutFn(this.G);const t=this.D+this.M;this.L=Date.now()+t,this.G=this.setTimeoutFn((()=>{this.$("ping timeout")}),t),this.opts.autoUnref&&this.G.unref()}W(){this.writeBuffer.splice(0,this.P),this.P=0,0===this.writeBuffer.length?this.emitReserved("drain"):this.flush()}flush(){if("closed"!==this.readyState&&this.transport.writable&&!this.upgrading&&this.writeBuffer.length){const t=this.K();this.transport.send(t),this.P=t.length,this.emitReserved("flush")}}K(){if(!(this.S&&"polling"===this.transport.name&&this.writeBuffer.length>1))return this.writeBuffer;let t=1;for(let i=0;i<this.writeBuffer.length;i++){const e=this.writeBuffer[i].data;if(e&&(t+="string"==typeof(s=e)?function(t){let s=0,i=0;for(let e=0,n=t.length;e<n;e++)s=t.charCodeAt(e),s<128?i+=1:s<2048?i+=2:s<55296||s>=57344?i+=3:(e++,i+=4);return i}(s):Math.ceil(1.33*(s.byteLength||s.size))),i>0&&t>this.S)return this.writeBuffer.slice(0,i);t+=2}var s;return this.writeBuffer}Y(){if(!this.L)return!0;const t=Date.now()>this.L;return t&&(this.L=0,A((()=>{this.$("ping timeout")}),this.setTimeoutFn)),t}write(t,s,i){return this.X("message",t,s,i),this}send(t,s,i){return this.X("message",t,s,i),this}X(t,s,i,e){if("function"==typeof s&&(e=s,s=void 0),"function"==typeof i&&(e=i,i=null),"closing"===this.readyState||"closed"===this.readyState)return;(i=i||{}).compress=!1!==i.compress;const n={type:t,data:s,options:i};this.emitReserved("packetCreate",n),this.writeBuffer.push(n),e&&this.once("flush",e),this.flush()}close(){const t=()=>{this.$("forced close"),this.transport.close()},s=()=>{this.off("upgrade",s),this.off("upgradeError",s),t()},i=()=>{this.once("upgrade",s),this.once("upgradeError",s)};return"opening"!==this.readyState&&"open"!==this.readyState||(this.readyState="closing",this.writeBuffer.length?this.once("drain",(()=>{this.upgrading?i():t()})):this.upgrading?i():t()),this}j(t){if(Y.priorWebsocketSuccess=!1,this.opts.tryAllTransports&&this.transports.length>1&&"opening"===this.readyState)return this.transports.shift(),this.H();this.emitReserved("error",t),this.$("transport error",t)}$(t,s){if("opening"===this.readyState||"open"===this.readyState||"closing"===this.readyState){if(this.clearTimeoutFn(this.G),this.transport.removeAllListeners("close"),this.transport.close(),this.transport.removeAllListeners(),G&&(this.I&&removeEventListener("beforeunload",this.I,!1),this.F)){const t=K.indexOf(this.F);-1!==t&&K.splice(t,1)}this.readyState="closed",this.id=null,this.emitReserved("close",t,s),this.writeBuffer=[],this.P=0}}}Y.protocol=4;class Z extends Y{constructor(){super(...arguments),this.Z=[]}onOpen(){if(super.onOpen(),"open"===this.readyState&&this.opts.upgrade)for(let t=0;t<this.Z.length;t++)this.tt(this.Z[t])}tt(t){let s=this.createTransport(t),i=!1;Y.priorWebsocketSuccess=!1;const e=()=>{i||(s.send([{type:"ping",data:"probe"}]),s.once("packet",(t=>{if(!i)if("pong"===t.type&&"probe"===t.data){if(this.upgrading=!0,this.emitReserved("upgrading",s),!s)return;Y.priorWebsocketSuccess="websocket"===s.name,this.transport.pause((()=>{i||"closed"!==this.readyState&&(a(),this.setTransport(s),s.send([{type:"upgrade"}]),this.emitReserved("upgrade",s),s=null,this.upgrading=!1,this.flush())}))}else{const t=new Error("probe error");t.transport=s.name,this.emitReserved("upgradeError",t)}})))};function n(){i||(i=!0,a(),s.close(),s=null)}const r=t=>{const i=new Error("probe error: "+t);i.transport=s.name,n(),this.emitReserved("upgradeError",i)};function o(){r("transport closed")}function h(){r("socket closed")}function c(t){s&&t.name!==s.name&&n()}const a=()=>{s.removeListener("open",e),s.removeListener("error",r),s.removeListener("close",o),this.off("close",h),this.off("upgrading",c)};s.once("open",e),s.once("error",r),s.once("close",o),this.once("close",h),this.once("upgrading",c),-1!==this.Z.indexOf("webtransport")&&"webtransport"!==t?this.setTimeoutFn((()=>{i||s.open()}),200):s.open()}onHandshake(t){this.Z=this.st(t.upgrades),super.onHandshake(t)}st(t){const s=[];for(let i=0;i<t.length;i++)~this.transports.indexOf(t[i])&&s.push(t[i]);return s}}class tt extends Z{constructor(t,s={}){const i="object"==typeof t?t:s;(!i.transports||i.transports&&"string"==typeof i.transports[0])&&(i.transports=(i.transports||["polling","websocket","webtransport"]).map((t=>X[t])).filter((t=>!!t))),super(t,i)}}class st extends N{doPoll(){this.it().then((t=>{if(!t.ok)return this.onError("fetch read error",t.status,t);t.text().then((t=>this.onData(t)))})).catch((t=>{this.onError("fetch read error",t)}))}doWrite(t,s){this.it(t).then((t=>{if(!t.ok)return this.onError("fetch write error",t.status,t);s()})).catch((t=>{this.onError("fetch write error",t)}))}it(t){var s;const i=void 0!==t,e=new Headers(this.opts.extraHeaders);return i&&e.set("content-type","text/plain;charset=UTF-8"),null===(s=this.socket.V)||void 0===s||s.appendCookies(e),fetch(this.uri(),{method:i?"POST":"GET",body:i?t:null,headers:e,credentials:this.opts.withCredentials?"include":"omit"}).then((t=>{var s;return null===(s=this.socket.V)||void 0===s||s.parseCookies(t.headers.getSetCookie()),t}))}}const it="function"==typeof ArrayBuffer,et=t=>"function"==typeof ArrayBuffer.isView?ArrayBuffer.isView(t):t.buffer instanceof ArrayBuffer,nt=Object.prototype.toString,rt="function"==typeof Blob||"undefined"!=typeof Blob&&"[object BlobConstructor]"===nt.call(Blob),ot="function"==typeof File||"undefined"!=typeof File&&"[object FileConstructor]"===nt.call(File);function ht(t){return it&&(t instanceof ArrayBuffer||et(t))||rt&&t instanceof Blob||ot&&t instanceof File}function ct(t,s){if(!t||"object"!=typeof t)return!1;if(Array.isArray(t)){for(let s=0,i=t.length;s<i;s++)if(ct(t[s]))return!0;return!1}if(ht(t))return!0;if(t.toJSON&&"function"==typeof t.toJSON&&1===arguments.length)return ct(t.toJSON(),!0);for(const s in t)if(Object.prototype.hasOwnProperty.call(t,s)&&ct(t[s]))return!0;return!1}function at(t){const s=[],i=t.data,e=t;return e.data=ut(i,s),e.attachments=s.length,{packet:e,buffers:s}}function ut(t,s){if(!t)return t;if(ht(t)){const i={et:!0,num:s.length};return s.push(t),i}if(Array.isArray(t)){const i=new Array(t.length);for(let e=0;e<t.length;e++)i[e]=ut(t[e],s);return i}if("object"==typeof t&&!(t instanceof Date)){const i={};for(const e in t)Object.prototype.hasOwnProperty.call(t,e)&&(i[e]=ut(t[e],s));return i}return t}function ft(t,s){return t.data=lt(t.data,s),delete t.attachments,t}function lt(t,s){if(!t)return t;if(t&&!0===t.et){if("number"==typeof t.num&&t.num>=0&&t.num<s.length)return s[t.num];throw new Error("illegal attachments")}if(Array.isArray(t))for(let i=0;i<t.length;i++)t[i]=lt(t[i],s);else if("object"==typeof t)for(const i in t)Object.prototype.hasOwnProperty.call(t,i)&&(t[i]=lt(t[i],s));return t}const dt=["connect","connect_error","disconnect","disconnecting","newListener","removeListener"],pt=5;var yt;!function(t){t[t.CONNECT=0]="CONNECT",t[t.DISCONNECT=1]="DISCONNECT",t[t.EVENT=2]="EVENT",t[t.ACK=3]="ACK",t[t.CONNECT_ERROR=4]="CONNECT_ERROR",t[t.BINARY_EVENT=5]="BINARY_EVENT",t[t.BINARY_ACK=6]="BINARY_ACK"}(yt||(yt={}));class bt extends k{constructor(t){super(),this.reviver=t}add(t){let s;if("string"==typeof t){if(this.reconstructor)throw new Error("got plaintext data when reconstructing a packet");s=this.decodeString(t);const i=s.type===yt.BINARY_EVENT;i||s.type===yt.BINARY_ACK?(s.type=i?yt.EVENT:yt.ACK,this.reconstructor=new gt(s),0===s.attachments&&super.emitReserved("decoded",s)):super.emitReserved("decoded",s)}else{if(!ht(t)&&!t.base64)throw new Error("Unknown type: "+t);if(!this.reconstructor)throw new Error("got binary data when not reconstructing a packet");s=this.reconstructor.takeBinaryData(t),s&&(this.reconstructor=null,super.emitReserved("decoded",s))}}decodeString(t){let s=0;const i={type:Number(t.charAt(0))};if(void 0===yt[i.type])throw new Error("unknown packet type "+i.type);if(i.type===yt.BINARY_EVENT||i.type===yt.BINARY_ACK){const e=s+1;for(;"-"!==t.charAt(++s)&&s!=t.length;);const n=t.substring(e,s);if(n!=Number(n)||"-"!==t.charAt(s))throw new Error("Illegal attachments");i.attachments=Number(n)}if("/"===t.charAt(s+1)){const e=s+1;for(;++s;){if(","===t.charAt(s))break;if(s===t.length)break}i.nsp=t.substring(e,s)}else i.nsp="/";const e=t.charAt(s+1);if(""!==e&&Number(e)==e){const e=s+1;for(;++s;){const i=t.charAt(s);if(null==i||Number(i)!=i){--s;break}if(s===t.length)break}i.id=Number(t.substring(e,s+1))}if(t.charAt(++s)){const e=this.tryParse(t.substr(s));if(!bt.isPayloadValid(i.type,e))throw new Error("invalid payload");i.data=e}return i}tryParse(t){try{return JSON.parse(t,this.reviver)}catch(t){return!1}}static isPayloadValid(t,s){switch(t){case yt.CONNECT:return vt(s);case yt.DISCONNECT:return void 0===s;case yt.CONNECT_ERROR:return"string"==typeof s||vt(s);case yt.EVENT:case yt.BINARY_EVENT:return Array.isArray(s)&&("number"==typeof s[0]||"string"==typeof s[0]&&-1===dt.indexOf(s[0]));case yt.ACK:case yt.BINARY_ACK:return Array.isArray(s)}}destroy(){this.reconstructor&&(this.reconstructor.finishedReconstruction(),this.reconstructor=null)}}class gt{constructor(t){this.packet=t,this.buffers=[],this.reconPack=t}takeBinaryData(t){if(this.buffers.push(t),this.buffers.length===this.reconPack.attachments){const t=ft(this.reconPack,this.buffers);return this.finishedReconstruction(),t}return null}finishedReconstruction(){this.reconPack=null,this.buffers=[]}}const wt=Number.isInteger||function(t){return"number"==typeof t&&isFinite(t)&&Math.floor(t)===t};function vt(t){return"[object Object]"===Object.prototype.toString.call(t)}var mt=Object.freeze({__proto__:null,protocol:5,get PacketType(){return yt},Encoder:class{constructor(t){this.replacer=t}encode(t){return t.type!==yt.EVENT&&t.type!==yt.ACK||!ct(t)?[this.encodeAsString(t)]:this.encodeAsBinary({type:t.type===yt.EVENT?yt.BINARY_EVENT:yt.BINARY_ACK,nsp:t.nsp,data:t.data,id:t.id})}encodeAsString(t){let s=""+t.type;return t.type!==yt.BINARY_EVENT&&t.type!==yt.BINARY_ACK||(s+=t.attachments+"-"),t.nsp&&"/"!==t.nsp&&(s+=t.nsp+","),null!=t.id&&(s+=t.id),null!=t.data&&(s+=JSON.stringify(t.data,this.replacer)),s}encodeAsBinary(t){const s=at(t),i=this.encodeAsString(s.packet),e=s.buffers;return e.unshift(i),e}},Decoder:bt,isPacketValid:function(t){return"string"==typeof t.nsp&&(void 0===(s=t.id)||wt(s))&&function(t,s){switch(t){case yt.CONNECT:return void 0===s||vt(s);case yt.DISCONNECT:return void 0===s;case yt.EVENT:return Array.isArray(s)&&("number"==typeof s[0]||"string"==typeof s[0]&&-1===dt.indexOf(s[0]));case yt.ACK:return Array.isArray(s);case yt.CONNECT_ERROR:return"string"==typeof s||vt(s);default:return!1}}(t.type,t.data);var s}});function kt(t,s,i){return t.on(s,i),function(){t.off(s,i)}}const At=Object.freeze({connect:1,connect_error:1,disconnect:1,disconnecting:1,newListener:1,removeListener:1});class Et extends k{constructor(t,s,i){super(),this.connected=!1,this.recovered=!1,this.receiveBuffer=[],this.sendBuffer=[],this.nt=[],this.rt=0,this.ids=0,this.acks={},this.flags={},this.io=t,this.nsp=s,i&&i.auth&&(this.auth=i.auth),this.p=Object.assign({},i),this.io.ot&&this.open()}get disconnected(){return!this.connected}subEvents(){if(this.subs)return;const t=this.io;this.subs=[kt(t,"open",this.onopen.bind(this)),kt(t,"packet",this.onpacket.bind(this)),kt(t,"error",this.onerror.bind(this)),kt(t,"close",this.onclose.bind(this))]}get active(){return!!this.subs}connect(){return this.connected||(this.subEvents(),this.io.ht||this.io.open(),"open"===this.io.ct&&this.onopen()),this}open(){return this.connect()}send(...t){return t.unshift("message"),this.emit.apply(this,t),this}emit(t,...s){var i,e,n;if(At.hasOwnProperty(t))throw new Error('"'+t.toString()+'" is a reserved event name');if(s.unshift(t),this.p.retries&&!this.flags.fromQueue&&!this.flags.volatile)return this.ut(s),this;const r={type:yt.EVENT,data:s,options:{}};if(r.options.compress=!1!==this.flags.compress,"function"==typeof s[s.length-1]){const t=this.ids++,i=s.pop();this.ft(t,i),r.id=t}const o=null===(e=null===(i=this.io.engine)||void 0===i?void 0:i.transport)||void 0===e?void 0:e.writable,h=this.connected&&!(null===(n=this.io.engine)||void 0===n?void 0:n.Y());return this.flags.volatile&&!o||(h?(this.notifyOutgoingListeners(r),this.packet(r)):this.sendBuffer.push(r)),this.flags={},this}ft(t,s){var i;const e=null!==(i=this.flags.timeout)&&void 0!==i?i:this.p.ackTimeout;if(void 0===e)return void(this.acks[t]=s);const n=this.io.setTimeoutFn((()=>{delete this.acks[t];for(let s=0;s<this.sendBuffer.length;s++)this.sendBuffer[s].id===t&&this.sendBuffer.splice(s,1);s.call(this,new Error("operation has timed out"))}),e),r=(...t)=>{this.io.clearTimeoutFn(n),s.apply(this,t)};r.withError=!0,this.acks[t]=r}emitWithAck(t,...s){return new Promise(((i,e)=>{const n=(t,s)=>t?e(t):i(s);n.withError=!0,s.push(n),this.emit(t,...s)}))}ut(t){let s;"function"==typeof t[t.length-1]&&(s=t.pop());const i={id:this.rt++,tryCount:0,pending:!1,args:t,flags:Object.assign({fromQueue:!0},this.flags)};t.push(((t,...e)=>{if(i!==this.nt[0])return;return null!==t?i.tryCount>this.p.retries&&(this.nt.shift(),s&&s(t)):(this.nt.shift(),s&&s(null,...e)),i.pending=!1,this.lt()})),this.nt.push(i),this.lt()}lt(t=!1){if(!this.connected||0===this.nt.length)return;const s=this.nt[0];s.pending&&!t||(s.pending=!0,s.tryCount++,this.flags=s.flags,this.emit.apply(this,s.args))}packet(t){t.nsp=this.nsp,this.io.dt(t)}onopen(){"function"==typeof this.auth?this.auth((t=>{this.yt(t)})):this.yt(this.auth)}yt(t){this.packet({type:yt.CONNECT,data:this.bt?Object.assign({pid:this.bt,offset:this.gt},t):t})}onerror(t){this.connected||this.emitReserved("connect_error",t)}onclose(t,s){this.connected=!1,delete this.id,this.emitReserved("disconnect",t,s),this.wt()}wt(){Object.keys(this.acks).forEach((t=>{if(!this.sendBuffer.some((s=>String(s.id)===t))){const s=this.acks[t];delete this.acks[t],s.withError&&s.call(this,new Error("socket has been disconnected"))}}))}onpacket(t){if(t.nsp===this.nsp)switch(t.type){case yt.CONNECT:t.data&&t.data.sid?this.onconnect(t.data.sid,t.data.pid):this.emitReserved("connect_error",new Error("It seems you are trying to reach a Socket.IO server in v2.x with a v3.x client, but they are not compatible (more information here: https://socket.io/docs/v3/migrating-from-2-x-to-3-0/)"));break;case yt.EVENT:case yt.BINARY_EVENT:this.onevent(t);break;case yt.ACK:case yt.BINARY_ACK:this.onack(t);break;case yt.DISCONNECT:this.ondisconnect();break;case yt.CONNECT_ERROR:this.destroy();const s=new Error(t.data.message);s.data=t.data.data,this.emitReserved("connect_error",s)}}onevent(t){const s=t.data||[];null!=t.id&&s.push(this.ack(t.id)),this.connected?this.emitEvent(s):this.receiveBuffer.push(Object.freeze(s))}emitEvent(t){if(this.vt&&this.vt.length){const s=this.vt.slice();for(const i of s)i.apply(this,t)}super.emit.apply(this,t),this.bt&&t.length&&"string"==typeof t[t.length-1]&&(this.gt=t[t.length-1])}ack(t){const s=this;let i=!1;return function(...e){i||(i=!0,s.packet({type:yt.ACK,id:t,data:e}))}}onack(t){const s=this.acks[t.id];"function"==typeof s&&(delete this.acks[t.id],s.withError&&t.data.unshift(null),s.apply(this,t.data))}onconnect(t,s){this.id=t,this.recovered=s&&this.bt===s,this.bt=s,this.connected=!0,this.emitBuffered(),this.emitReserved("connect"),this.lt(!0)}emitBuffered(){this.receiveBuffer.forEach((t=>this.emitEvent(t))),this.receiveBuffer=[],this.sendBuffer.forEach((t=>{this.notifyOutgoingListeners(t),this.packet(t)})),this.sendBuffer=[]}ondisconnect(){this.destroy(),this.onclose("io server disconnect")}destroy(){this.subs&&(this.subs.forEach((t=>t())),this.subs=void 0),this.io.kt(this)}disconnect(){return this.connected&&this.packet({type:yt.DISCONNECT}),this.destroy(),this.connected&&this.onclose("io client disconnect"),this}close(){return this.disconnect()}compress(t){return this.flags.compress=t,this}get volatile(){return this.flags.volatile=!0,this}timeout(t){return this.flags.timeout=t,this}onAny(t){return this.vt=this.vt||[],this.vt.push(t),this}prependAny(t){return this.vt=this.vt||[],this.vt.unshift(t),this}offAny(t){if(!this.vt)return this;if(t){const s=this.vt;for(let i=0;i<s.length;i++)if(t===s[i])return s.splice(i,1),this}else this.vt=[];return this}listenersAny(){return this.vt||[]}onAnyOutgoing(t){return this.At=this.At||[],this.At.push(t),this}prependAnyOutgoing(t){return this.At=this.At||[],this.At.unshift(t),this}offAnyOutgoing(t){if(!this.At)return this;if(t){const s=this.At;for(let i=0;i<s.length;i++)if(t===s[i])return s.splice(i,1),this}else this.At=[];return this}listenersAnyOutgoing(){return this.At||[]}notifyOutgoingListeners(t){if(this.At&&this.At.length){const s=this.At.slice();for(const i of s)i.apply(this,t.data)}}}function Ot(t){t=t||{},this.ms=t.min||100,this.max=t.max||1e4,this.factor=t.factor||2,this.jitter=t.jitter>0&&t.jitter<=1?t.jitter:0,this.attempts=0}Ot.prototype.duration=function(){var t=this.ms*Math.pow(this.factor,this.attempts++);if(this.jitter){var s=Math.random(),i=Math.floor(s*this.jitter*t);t=1&Math.floor(10*s)?t+i:t-i}return 0|Math.min(t,this.max)},Ot.prototype.reset=function(){this.attempts=0},Ot.prototype.setMin=function(t){this.ms=t},Ot.prototype.setMax=function(t){this.max=t},Ot.prototype.setJitter=function(t){this.jitter=t};class _t extends k{constructor(t,s){var i;super(),this.nsps={},this.subs=[],t&&"object"==typeof t&&(s=t,t=void 0),(s=s||{}).path=s.path||"/socket.io",this.opts=s,x(this,s),this.reconnection(!1!==s.reconnection),this.reconnectionAttempts(s.reconnectionAttempts||1/0),this.reconnectionDelay(s.reconnectionDelay||1e3),this.reconnectionDelayMax(s.reconnectionDelayMax||5e3),this.randomizationFactor(null!==(i=s.randomizationFactor)&&void 0!==i?i:.5),this.backoff=new Ot({min:this.reconnectionDelay(),max:this.reconnectionDelayMax(),jitter:this.randomizationFactor()}),this.timeout(null==s.timeout?2e4:s.timeout),this.ct="closed",this.uri=t;const e=s.parser||mt;this.encoder=new e.Encoder,this.decoder=new e.Decoder,this.ot=!1!==s.autoConnect,this.ot&&this.open()}reconnection(t){return arguments.length?(this.Et=!!t,t||(this.skipReconnect=!0),this):this.Et}reconnectionAttempts(t){return void 0===t?this.Ot:(this.Ot=t,this)}reconnectionDelay(t){var s;return void 0===t?this._t:(this._t=t,null===(s=this.backoff)||void 0===s||s.setMin(t),this)}randomizationFactor(t){var s;return void 0===t?this.jt:(this.jt=t,null===(s=this.backoff)||void 0===s||s.setJitter(t),this)}reconnectionDelayMax(t){var s;return void 0===t?this.xt:(this.xt=t,null===(s=this.backoff)||void 0===s||s.setMax(t),this)}timeout(t){return arguments.length?(this.Bt=t,this):this.Bt}maybeReconnectOnOpen(){!this.ht&&this.Et&&0===this.backoff.attempts&&this.reconnect()}open(t){if(~this.ct.indexOf("open"))return this;this.engine=new tt(this.uri,this.opts);const s=this.engine,i=this;this.ct="opening",this.skipReconnect=!1;const e=kt(s,"open",(function(){i.onopen(),t&&t()})),n=s=>{this.cleanup(),this.ct="closed",this.emitReserved("error",s),t?t(s):this.maybeReconnectOnOpen()},r=kt(s,"error",n);if(!1!==this.Bt){const t=this.Bt,i=this.setTimeoutFn((()=>{e(),n(new Error("timeout")),s.close()}),t);this.opts.autoUnref&&i.unref(),this.subs.push((()=>{this.clearTimeoutFn(i)}))}return this.subs.push(e),this.subs.push(r),this}connect(t){return this.open(t)}onopen(){this.cleanup(),this.ct="open",this.emitReserved("open");const t=this.engine;this.subs.push(kt(t,"ping",this.onping.bind(this)),kt(t,"data",this.ondata.bind(this)),kt(t,"error",this.onerror.bind(this)),kt(t,"close",this.onclose.bind(this)),kt(this.decoder,"decoded",this.ondecoded.bind(this)))}onping(){this.emitReserved("ping")}ondata(t){try{this.decoder.add(t)}catch(t){this.onclose("parse error",t)}}ondecoded(t){A((()=>{this.emitReserved("packet",t)}),this.setTimeoutFn)}onerror(t){this.emitReserved("error",t)}socket(t,s){let i=this.nsps[t];return i?this.ot&&!i.active&&i.connect():(i=new Et(this,t,s),this.nsps[t]=i),i}kt(t){const s=Object.keys(this.nsps);for(const t of s){if(this.nsps[t].active)return}this.Ct()}dt(t){const s=this.encoder.encode(t);for(let i=0;i<s.length;i++)this.engine.write(s[i],t.options)}cleanup(){this.subs.forEach((t=>t())),this.subs.length=0,this.decoder.destroy()}Ct(){this.skipReconnect=!0,this.ht=!1,this.onclose("forced close")}disconnect(){return this.Ct()}onclose(t,s){var i;this.cleanup(),null===(i=this.engine)||void 0===i||i.close(),this.backoff.reset(),this.ct="closed",this.emitReserved("close",t,s),this.Et&&!this.skipReconnect&&this.reconnect()}reconnect(){if(this.ht||this.skipReconnect)return this;const t=this;if(this.backoff.attempts>=this.Ot)this.backoff.reset(),this.emitReserved("reconnect_failed"),this.ht=!1;else{const s=this.backoff.duration();this.ht=!0;const i=this.setTimeoutFn((()=>{t.skipReconnect||(this.emitReserved("reconnect_attempt",t.backoff.attempts),t.skipReconnect||t.open((s=>{s?(t.ht=!1,t.reconnect(),this.emitReserved("reconnect_error",s)):t.onreconnect()})))}),s);this.opts.autoUnref&&i.unref(),this.subs.push((()=>{this.clearTimeoutFn(i)}))}}onreconnect(){const t=this.backoff.attempts;this.ht=!1,this.backoff.reset(),this.emitReserved("reconnect",t)}}const jt={};function xt(t,s){"object"==typeof t&&(s=t,t=void 0);const i=function(t,s="",i){let e=t;i=i||"undefined"!=typeof location&&location,null==t&&(t=i.protocol+"//"+i.host),"string"==typeof t&&("/"===t.charAt(0)&&(t="/"===t.charAt(1)?i.protocol+t:i.host+t),/^(https?|wss?):\/\//.test(t)||(t=void 0!==i?i.protocol+"//"+t:"https://"+t),e=Q(t)),e.port||(/^(http|ws)$/.test(e.protocol)?e.port="80":/^(http|ws)s$/.test(e.protocol)&&(e.port="443")),e.path=e.path||"/";const n=-1!==e.host.indexOf(":")?"["+e.host+"]":e.host;return e.id=e.protocol+"://"+n+":"+e.port+s,e.href=e.protocol+"://"+n+(i&&i.port===e.port?"":":"+e.port),e}(t,(s=s||{}).path||"/socket.io"),e=i.source,n=i.id,r=i.path,o=jt[n]&&r in jt[n].nsps;let h;return s.forceNew||s["force new connection"]||!1===s.multiplex||o?h=new _t(e,s):(jt[n]||(jt[n]=new _t(e,s)),h=jt[n]),i.query&&!s.query&&(s.query=i.queryKey),h.socket(i.path,s)}Object.assign(xt,{Manager:_t,Socket:Et,io:xt,connect:xt});export{st as Fetch,_t as Manager,W as NodeWebSocket,I as NodeXHR,Et as Socket,W as WebSocket,q as WebTransport,I as XHR,xt as connect,xt as default,xt as io,pt as protocol};
7 +//# sourceMappingURL=socket.io.esm.min.js.map
webui/vendor/socket.io.min.js new
+7
@@ -0,0 +1,7 @@
1 +/*!
2 + * Socket.IO v4.8.1
3 + * (c) 2014-2024 Guillermo Rauch
4 + * Released under the MIT License.
5 + */
6 +!function(t,n){"object"==typeof exports&&"undefined"!=typeof module?module.exports=n():"function"==typeof define&&define.amd?define(n):(t="undefined"!=typeof globalThis?globalThis:t||self).io=n()}(this,(function(){"use strict";function t(t,n){(null==n||n>t.length)&&(n=t.length);for(var i=0,r=Array(n);i<n;i++)r[i]=t[i];return r}function n(t,n){for(var i=0;i<n.length;i++){var r=n[i];r.enumerable=r.enumerable||!1,r.configurable=!0,"value"in r&&(r.writable=!0),Object.defineProperty(t,f(r.key),r)}}function i(t,i,r){return i&&n(t.prototype,i),r&&n(t,r),Object.defineProperty(t,"prototype",{writable:!1}),t}function r(n,i){var r="undefined"!=typeof Symbol&&n[Symbol.iterator]||n["@@iterator"];if(!r){if(Array.isArray(n)||(r=function(n,i){if(n){if("string"==typeof n)return t(n,i);var r={}.toString.call(n).slice(8,-1);return"Object"===r&&n.constructor&&(r=n.constructor.name),"Map"===r||"Set"===r?Array.from(n):"Arguments"===r||/^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(r)?t(n,i):void 0}}(n))||i&&n&&"number"==typeof n.length){r&&(n=r);var e=0,o=function(){};return{s:o,n:function(){return e>=n.length?{done:!0}:{done:!1,value:n[e++]}},e:function(t){throw t},f:o}}throw new TypeError("Invalid attempt to iterate non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}var s,u=!0,h=!1;return{s:function(){r=r.call(n)},n:function(){var t=r.next();return u=t.done,t},e:function(t){h=!0,s=t},f:function(){try{u||null==r.return||r.return()}finally{if(h)throw s}}}}function e(){return e=Object.assign?Object.assign.bind():function(t){for(var n=1;n<arguments.length;n++){var i=arguments[n];for(var r in i)({}).hasOwnProperty.call(i,r)&&(t[r]=i[r])}return t},e.apply(null,arguments)}function o(t){return o=Object.setPrototypeOf?Object.getPrototypeOf.bind():function(t){return t.__proto__||Object.getPrototypeOf(t)},o(t)}function s(t,n){t.prototype=Object.create(n.prototype),t.prototype.constructor=t,h(t,n)}function u(){try{var t=!Boolean.prototype.valueOf.call(Reflect.construct(Boolean,[],(function(){})))}catch(t){}return(u=function(){return!!t})()}function h(t,n){return h=Object.setPrototypeOf?Object.setPrototypeOf.bind():function(t,n){return t.__proto__=n,t},h(t,n)}function f(t){var n=function(t,n){if("object"!=typeof t||!t)return t;var i=t[Symbol.toPrimitive];if(void 0!==i){var r=i.call(t,n||"default");if("object"!=typeof r)return r;throw new TypeError("@@toPrimitive must return a primitive value.")}return("string"===n?String:Number)(t)}(t,"string");return"symbol"==typeof n?n:n+""}function c(t){return c="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(t){return typeof t}:function(t){return t&&"function"==typeof Symbol&&t.constructor===Symbol&&t!==Symbol.prototype?"symbol":typeof t},c(t)}function a(t){var n="function"==typeof Map?new Map:void 0;return a=function(t){if(null===t||!function(t){try{return-1!==Function.toString.call(t).indexOf("[native code]")}catch(n){return"function"==typeof t}}(t))return t;if("function"!=typeof t)throw new TypeError("Super expression must either be null or a function");if(void 0!==n){if(n.has(t))return n.get(t);n.set(t,i)}function i(){return function(t,n,i){if(u())return Reflect.construct.apply(null,arguments);var r=[null];r.push.apply(r,n);var e=new(t.bind.apply(t,r));return i&&h(e,i.prototype),e}(t,arguments,o(this).constructor)}return i.prototype=Object.create(t.prototype,{constructor:{value:i,enumerable:!1,writable:!0,configurable:!0}}),h(i,t)},a(t)}var v=Object.create(null);v.open="0",v.close="1",v.ping="2",v.pong="3",v.message="4",v.upgrade="5",v.noop="6";var l=Object.create(null);Object.keys(v).forEach((function(t){l[v[t]]=t}));var p,d={type:"error",data:"parser error"},y="function"==typeof Blob||"undefined"!=typeof Blob&&"[object BlobConstructor]"===Object.prototype.toString.call(Blob),b="function"==typeof ArrayBuffer,w=function(t){return"function"==typeof ArrayBuffer.isView?ArrayBuffer.isView(t):t&&t.buffer instanceof ArrayBuffer},g=function(t,n,i){var r=t.type,e=t.data;return y&&e instanceof Blob?n?i(e):m(e,i):b&&(e instanceof ArrayBuffer||w(e))?n?i(e):m(new Blob([e]),i):i(v[r]+(e||""))},m=function(t,n){var i=new FileReader;return i.onload=function(){var t=i.result.split(",")[1];n("b"+(t||""))},i.readAsDataURL(t)};function k(t){return t instanceof Uint8Array?t:t instanceof ArrayBuffer?new Uint8Array(t):new Uint8Array(t.buffer,t.byteOffset,t.byteLength)}for(var A="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/",j="undefined"==typeof Uint8Array?[]:new Uint8Array(256),E=0;E<64;E++)j[A.charCodeAt(E)]=E;var O,B="function"==typeof ArrayBuffer,S=function(t,n){if("string"!=typeof t)return{type:"message",data:C(t,n)};var i=t.charAt(0);return"b"===i?{type:"message",data:N(t.substring(1),n)}:l[i]?t.length>1?{type:l[i],data:t.substring(1)}:{type:l[i]}:d},N=function(t,n){if(B){var i=function(t){var n,i,r,e,o,s=.75*t.length,u=t.length,h=0;"="===t[t.length-1]&&(s--,"="===t[t.length-2]&&s--);var f=new ArrayBuffer(s),c=new Uint8Array(f);for(n=0;n<u;n+=4)i=j[t.charCodeAt(n)],r=j[t.charCodeAt(n+1)],e=j[t.charCodeAt(n+2)],o=j[t.charCodeAt(n+3)],c[h++]=i<<2|r>>4,c[h++]=(15&r)<<4|e>>2,c[h++]=(3&e)<<6|63&o;return f}(t);return C(i,n)}return{base64:!0,data:t}},C=function(t,n){return"blob"===n?t instanceof Blob?t:new Blob([t]):t instanceof ArrayBuffer?t:t.buffer},T=String.fromCharCode(30);function U(){return new TransformStream({transform:function(t,n){!function(t,n){y&&t.data instanceof Blob?t.data.arrayBuffer().then(k).then(n):b&&(t.data instanceof ArrayBuffer||w(t.data))?n(k(t.data)):g(t,!1,(function(t){p||(p=new TextEncoder),n(p.encode(t))}))}(t,(function(i){var r,e=i.length;if(e<126)r=new Uint8Array(1),new DataView(r.buffer).setUint8(0,e);else if(e<65536){r=new Uint8Array(3);var o=new DataView(r.buffer);o.setUint8(0,126),o.setUint16(1,e)}else{r=new Uint8Array(9);var s=new DataView(r.buffer);s.setUint8(0,127),s.setBigUint64(1,BigInt(e))}t.data&&"string"!=typeof t.data&&(r[0]|=128),n.enqueue(r),n.enqueue(i)}))}})}function M(t){return t.reduce((function(t,n){return t+n.length}),0)}function x(t,n){if(t[0].length===n)return t.shift();for(var i=new Uint8Array(n),r=0,e=0;e<n;e++)i[e]=t[0][r++],r===t[0].length&&(t.shift(),r=0);return t.length&&r<t[0].length&&(t[0]=t[0].slice(r)),i}function I(t){if(t)return function(t){for(var n in I.prototype)t[n]=I.prototype[n];return t}(t)}I.prototype.on=I.prototype.addEventListener=function(t,n){return this.t=this.t||{},(this.t["$"+t]=this.t["$"+t]||[]).push(n),this},I.prototype.once=function(t,n){function i(){this.off(t,i),n.apply(this,arguments)}return i.fn=n,this.on(t,i),this},I.prototype.off=I.prototype.removeListener=I.prototype.removeAllListeners=I.prototype.removeEventListener=function(t,n){if(this.t=this.t||{},0==arguments.length)return this.t={},this;var i,r=this.t["$"+t];if(!r)return this;if(1==arguments.length)return delete this.t["$"+t],this;for(var e=0;e<r.length;e++)if((i=r[e])===n||i.fn===n){r.splice(e,1);break}return 0===r.length&&delete this.t["$"+t],this},I.prototype.emit=function(t){this.t=this.t||{};for(var n=new Array(arguments.length-1),i=this.t["$"+t],r=1;r<arguments.length;r++)n[r-1]=arguments[r];if(i){r=0;for(var e=(i=i.slice(0)).length;r<e;++r)i[r].apply(this,n)}return this},I.prototype.emitReserved=I.prototype.emit,I.prototype.listeners=function(t){return this.t=this.t||{},this.t["$"+t]||[]},I.prototype.hasListeners=function(t){return!!this.listeners(t).length};var R="function"==typeof Promise&&"function"==typeof Promise.resolve?function(t){return Promise.resolve().then(t)}:function(t,n){return n(t,0)},L="undefined"!=typeof self?self:"undefined"!=typeof window?window:Function("return this")();function _(t){for(var n=arguments.length,i=new Array(n>1?n-1:0),r=1;r<n;r++)i[r-1]=arguments[r];return i.reduce((function(n,i){return t.hasOwnProperty(i)&&(n[i]=t[i]),n}),{})}var D=L.setTimeout,P=L.clearTimeout;function $(t,n){n.useNativeTimers?(t.setTimeoutFn=D.bind(L),t.clearTimeoutFn=P.bind(L)):(t.setTimeoutFn=L.setTimeout.bind(L),t.clearTimeoutFn=L.clearTimeout.bind(L))}function F(){return Date.now().toString(36).substring(3)+Math.random().toString(36).substring(2,5)}var V=function(t){function n(n,i,r){var e;return(e=t.call(this,n)||this).description=i,e.context=r,e.type="TransportError",e}return s(n,t),n}(a(Error)),q=function(t){function n(n){var i;return(i=t.call(this)||this).writable=!1,$(i,n),i.opts=n,i.query=n.query,i.socket=n.socket,i.supportsBinary=!n.forceBase64,i}s(n,t);var i=n.prototype;return i.onError=function(n,i,r){return t.prototype.emitReserved.call(this,"error",new V(n,i,r)),this},i.open=function(){return this.readyState="opening",this.doOpen(),this},i.close=function(){return"opening"!==this.readyState&&"open"!==this.readyState||(this.doClose(),this.onClose()),this},i.send=function(t){"open"===this.readyState&&this.write(t)},i.onOpen=function(){this.readyState="open",this.writable=!0,t.prototype.emitReserved.call(this,"open")},i.onData=function(t){var n=S(t,this.socket.binaryType);this.onPacket(n)},i.onPacket=function(n){t.prototype.emitReserved.call(this,"packet",n)},i.onClose=function(n){this.readyState="closed",t.prototype.emitReserved.call(this,"close",n)},i.pause=function(t){},i.createUri=function(t){var n=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{};return t+"://"+this.i()+this.o()+this.opts.path+this.u(n)},i.i=function(){var t=this.opts.hostname;return-1===t.indexOf(":")?t:"["+t+"]"},i.o=function(){return this.opts.port&&(this.opts.secure&&Number(443!==this.opts.port)||!this.opts.secure&&80!==Number(this.opts.port))?":"+this.opts.port:""},i.u=function(t){var n=function(t){var n="";for(var i in t)t.hasOwnProperty(i)&&(n.length&&(n+="&"),n+=encodeURIComponent(i)+"="+encodeURIComponent(t[i]));return n}(t);return n.length?"?"+n:""},n}(I),X=function(t){function n(){var n;return(n=t.apply(this,arguments)||this).h=!1,n}s(n,t);var r=n.prototype;return r.doOpen=function(){this.v()},r.pause=function(t){var n=this;this.readyState="pausing";var i=function(){n.readyState="paused",t()};if(this.h||!this.writable){var r=0;this.h&&(r++,this.once("pollComplete",(function(){--r||i()}))),this.writable||(r++,this.once("drain",(function(){--r||i()})))}else i()},r.v=function(){this.h=!0,this.doPoll(),this.emitReserved("poll")},r.onData=function(t){var n=this;(function(t,n){for(var i=t.split(T),r=[],e=0;e<i.length;e++){var o=S(i[e],n);if(r.push(o),"error"===o.type)break}return r})(t,this.socket.binaryType).forEach((function(t){if("opening"===n.readyState&&"open"===t.type&&n.onOpen(),"close"===t.type)return n.onClose({description:"transport closed by the server"}),!1;n.onPacket(t)})),"closed"!==this.readyState&&(this.h=!1,this.emitReserved("pollComplete"),"open"===this.readyState&&this.v())},r.doClose=function(){var t=this,n=function(){t.write([{type:"close"}])};"open"===this.readyState?n():this.once("open",n)},r.write=function(t){var n=this;this.writable=!1,function(t,n){var i=t.length,r=new Array(i),e=0;t.forEach((function(t,o){g(t,!1,(function(t){r[o]=t,++e===i&&n(r.join(T))}))}))}(t,(function(t){n.doWrite(t,(function(){n.writable=!0,n.emitReserved("drain")}))}))},r.uri=function(){var t=this.opts.secure?"https":"http",n=this.query||{};return!1!==this.opts.timestampRequests&&(n[this.opts.timestampParam]=F()),this.supportsBinary||n.sid||(n.b64=1),this.createUri(t,n)},i(n,[{key:"name",get:function(){return"polling"}}])}(q),H=!1;try{H="undefined"!=typeof XMLHttpRequest&&"withCredentials"in new XMLHttpRequest}catch(t){}var z=H;function J(){}var K=function(t){function n(n){var i;if(i=t.call(this,n)||this,"undefined"!=typeof location){var r="https:"===location.protocol,e=location.port;e||(e=r?"443":"80"),i.xd="undefined"!=typeof location&&n.hostname!==location.hostname||e!==n.port}return i}s(n,t);var i=n.prototype;return i.doWrite=function(t,n){var i=this,r=this.request({method:"POST",data:t});r.on("success",n),r.on("error",(function(t,n){i.onError("xhr post error",t,n)}))},i.doPoll=function(){var t=this,n=this.request();n.on("data",this.onData.bind(this)),n.on("error",(function(n,i){t.onError("xhr poll error",n,i)})),this.pollXhr=n},n}(X),Y=function(t){function n(n,i,r){var e;return(e=t.call(this)||this).createRequest=n,$(e,r),e.l=r,e.p=r.method||"GET",e.m=i,e.k=void 0!==r.data?r.data:null,e.A(),e}s(n,t);var i=n.prototype;return i.A=function(){var t,i=this,r=_(this.l,"agent","pfx","key","passphrase","cert","ca","ciphers","rejectUnauthorized","autoUnref");r.xdomain=!!this.l.xd;var e=this.j=this.createRequest(r);try{e.open(this.p,this.m,!0);try{if(this.l.extraHeaders)for(var o in e.setDisableHeaderCheck&&e.setDisableHeaderCheck(!0),this.l.extraHeaders)this.l.extraHeaders.hasOwnProperty(o)&&e.setRequestHeader(o,this.l.extraHeaders[o])}catch(t){}if("POST"===this.p)try{e.setRequestHeader("Content-type","text/plain;charset=UTF-8")}catch(t){}try{e.setRequestHeader("Accept","*/*")}catch(t){}null===(t=this.l.cookieJar)||void 0===t||t.addCookies(e),"withCredentials"in e&&(e.withCredentials=this.l.withCredentials),this.l.requestTimeout&&(e.timeout=this.l.requestTimeout),e.onreadystatechange=function(){var t;3===e.readyState&&(null===(t=i.l.cookieJar)||void 0===t||t.parseCookies(e.getResponseHeader("set-cookie"))),4===e.readyState&&(200===e.status||1223===e.status?i.O():i.setTimeoutFn((function(){i.B("number"==typeof e.status?e.status:0)}),0))},e.send(this.k)}catch(t){return void this.setTimeoutFn((function(){i.B(t)}),0)}"undefined"!=typeof document&&(this.S=n.requestsCount++,n.requests[this.S]=this)},i.B=function(t){this.emitReserved("error",t,this.j),this.N(!0)},i.N=function(t){if(void 0!==this.j&&null!==this.j){if(this.j.onreadystatechange=J,t)try{this.j.abort()}catch(t){}"undefined"!=typeof document&&delete n.requests[this.S],this.j=null}},i.O=function(){var t=this.j.responseText;null!==t&&(this.emitReserved("data",t),this.emitReserved("success"),this.N())},i.abort=function(){this.N()},n}(I);if(Y.requestsCount=0,Y.requests={},"undefined"!=typeof document)if("function"==typeof attachEvent)attachEvent("onunload",G);else if("function"==typeof addEventListener){addEventListener("onpagehide"in L?"pagehide":"unload",G,!1)}function G(){for(var t in Y.requests)Y.requests.hasOwnProperty(t)&&Y.requests[t].abort()}var Q,W=(Q=tt({xdomain:!1}))&&null!==Q.responseType,Z=function(t){function n(n){var i;i=t.call(this,n)||this;var r=n&&n.forceBase64;return i.supportsBinary=W&&!r,i}return s(n,t),n.prototype.request=function(){var t=arguments.length>0&&void 0!==arguments[0]?arguments[0]:{};return e(t,{xd:this.xd},this.opts),new Y(tt,this.uri(),t)},n}(K);function tt(t){var n=t.xdomain;try{if("undefined"!=typeof XMLHttpRequest&&(!n||z))return new XMLHttpRequest}catch(t){}if(!n)try{return new(L[["Active"].concat("Object").join("X")])("Microsoft.XMLHTTP")}catch(t){}}var nt="undefined"!=typeof navigator&&"string"==typeof navigator.product&&"reactnative"===navigator.product.toLowerCase(),it=function(t){function n(){return t.apply(this,arguments)||this}s(n,t);var r=n.prototype;return r.doOpen=function(){var t=this.uri(),n=this.opts.protocols,i=nt?{}:_(this.opts,"agent","perMessageDeflate","pfx","key","passphrase","cert","ca","ciphers","rejectUnauthorized","localAddress","protocolVersion","origin","maxPayload","family","checkServerIdentity");this.opts.extraHeaders&&(i.headers=this.opts.extraHeaders);try{this.ws=this.createSocket(t,n,i)}catch(t){return this.emitReserved("error",t)}this.ws.binaryType=this.socket.binaryType,this.addEventListeners()},r.addEventListeners=function(){var t=this;this.ws.onopen=function(){t.opts.autoUnref&&t.ws.C.unref(),t.onOpen()},this.ws.onclose=function(n){return t.onClose({description:"websocket connection closed",context:n})},this.ws.onmessage=function(n){return t.onData(n.data)},this.ws.onerror=function(n){return t.onError("websocket error",n)}},r.write=function(t){var n=this;this.writable=!1;for(var i=function(){var i=t[r],e=r===t.length-1;g(i,n.supportsBinary,(function(t){try{n.doWrite(i,t)}catch(t){}e&&R((function(){n.writable=!0,n.emitReserved("drain")}),n.setTimeoutFn)}))},r=0;r<t.length;r++)i()},r.doClose=function(){void 0!==this.ws&&(this.ws.onerror=function(){},this.ws.close(),this.ws=null)},r.uri=function(){var t=this.opts.secure?"wss":"ws",n=this.query||{};return this.opts.timestampRequests&&(n[this.opts.timestampParam]=F()),this.supportsBinary||(n.b64=1),this.createUri(t,n)},i(n,[{key:"name",get:function(){return"websocket"}}])}(q),rt=L.WebSocket||L.MozWebSocket,et=function(t){function n(){return t.apply(this,arguments)||this}s(n,t);var i=n.prototype;return i.createSocket=function(t,n,i){return nt?new rt(t,n,i):n?new rt(t,n):new rt(t)},i.doWrite=function(t,n){this.ws.send(n)},n}(it),ot=function(t){function n(){return t.apply(this,arguments)||this}s(n,t);var r=n.prototype;return r.doOpen=function(){var t=this;try{this.T=new WebTransport(this.createUri("https"),this.opts.transportOptions[this.name])}catch(t){return this.emitReserved("error",t)}this.T.closed.then((function(){t.onClose()})).catch((function(n){t.onError("webtransport error",n)})),this.T.ready.then((function(){t.T.createBidirectionalStream().then((function(n){var i=function(t,n){O||(O=new TextDecoder);var i=[],r=0,e=-1,o=!1;return new TransformStream({transform:function(s,u){for(i.push(s);;){if(0===r){if(M(i)<1)break;var h=x(i,1);o=!(128&~h[0]),e=127&h[0],r=e<126?3:126===e?1:2}else if(1===r){if(M(i)<2)break;var f=x(i,2);e=new DataView(f.buffer,f.byteOffset,f.length).getUint16(0),r=3}else if(2===r){if(M(i)<8)break;var c=x(i,8),a=new DataView(c.buffer,c.byteOffset,c.length),v=a.getUint32(0);if(v>Math.pow(2,21)-1){u.enqueue(d);break}e=v*Math.pow(2,32)+a.getUint32(4),r=3}else{if(M(i)<e)break;var l=x(i,e);u.enqueue(S(o?l:O.decode(l),n)),r=0}if(0===e||e>t){u.enqueue(d);break}}}})}(Number.MAX_SAFE_INTEGER,t.socket.binaryType),r=n.readable.pipeThrough(i).getReader(),e=U();e.readable.pipeTo(n.writable),t.U=e.writable.getWriter();!function n(){r.read().then((function(i){var r=i.done,e=i.value;r||(t.onPacket(e),n())})).catch((function(t){}))}();var o={type:"open"};t.query.sid&&(o.data='{"sid":"'.concat(t.query.sid,'"}')),t.U.write(o).then((function(){return t.onOpen()}))}))}))},r.write=function(t){var n=this;this.writable=!1;for(var i=function(){var i=t[r],e=r===t.length-1;n.U.write(i).then((function(){e&&R((function(){n.writable=!0,n.emitReserved("drain")}),n.setTimeoutFn)}))},r=0;r<t.length;r++)i()},r.doClose=function(){var t;null===(t=this.T)||void 0===t||t.close()},i(n,[{key:"name",get:function(){return"webtransport"}}])}(q),st={websocket:et,webtransport:ot,polling:Z},ut=/^(?:(?![^:@\/?#]+:[^:@\/]*@)(http|https|ws|wss):\/\/)?((?:(([^:@\/?#]*)(?::([^:@\/?#]*))?)?@)?((?:[a-f0-9]{0,4}:){2,7}[a-f0-9]{0,4}|[^:\/?#]*)(?::(\d*))?)(((\/(?:[^?#](?![^?#\/]*\.[^?#\/.]+(?:[?#]|$)))*\/?)?([^?#\/]*))(?:\?([^#]*))?(?:#(.*))?)/,ht=["source","protocol","authority","userInfo","user","password","host","port","relative","path","directory","file","query","anchor"];function ft(t){if(t.length>8e3)throw"URI too long";var n=t,i=t.indexOf("["),r=t.indexOf("]");-1!=i&&-1!=r&&(t=t.substring(0,i)+t.substring(i,r).replace(/:/g,";")+t.substring(r,t.length));for(var e,o,s=ut.exec(t||""),u={},h=14;h--;)u[ht[h]]=s[h]||"";return-1!=i&&-1!=r&&(u.source=n,u.host=u.host.substring(1,u.host.length-1).replace(/;/g,":"),u.authority=u.authority.replace("[","").replace("]","").replace(/;/g,":"),u.ipv6uri=!0),u.pathNames=function(t,n){var i=/\/{2,9}/g,r=n.replace(i,"/").split("/");"/"!=n.slice(0,1)&&0!==n.length||r.splice(0,1);"/"==n.slice(-1)&&r.splice(r.length-1,1);return r}(0,u.path),u.queryKey=(e=u.query,o={},e.replace(/(?:^|&)([^&=]*)=?([^&]*)/g,(function(t,n,i){n&&(o[n]=i)})),o),u}var ct="function"==typeof addEventListener&&"function"==typeof removeEventListener,at=[];ct&&addEventListener("offline",(function(){at.forEach((function(t){return t()}))}),!1);var vt=function(t){function n(n,i){var r;if((r=t.call(this)||this).binaryType="arraybuffer",r.writeBuffer=[],r.M=0,r.I=-1,r.R=-1,r.L=-1,r._=1/0,n&&"object"===c(n)&&(i=n,n=null),n){var o=ft(n);i.hostname=o.host,i.secure="https"===o.protocol||"wss"===o.protocol,i.port=o.port,o.query&&(i.query=o.query)}else i.host&&(i.hostname=ft(i.host).host);return $(r,i),r.secure=null!=i.secure?i.secure:"undefined"!=typeof location&&"https:"===location.protocol,i.hostname&&!i.port&&(i.port=r.secure?"443":"80"),r.hostname=i.hostname||("undefined"!=typeof location?location.hostname:"localhost"),r.port=i.port||("undefined"!=typeof location&&location.port?location.port:r.secure?"443":"80"),r.transports=[],r.D={},i.transports.forEach((function(t){var n=t.prototype.name;r.transports.push(n),r.D[n]=t})),r.opts=e({path:"/engine.io",agent:!1,withCredentials:!1,upgrade:!0,timestampParam:"t",rememberUpgrade:!1,addTrailingSlash:!0,rejectUnauthorized:!0,perMessageDeflate:{threshold:1024},transportOptions:{},closeOnBeforeunload:!1},i),r.opts.path=r.opts.path.replace(/\/$/,"")+(r.opts.addTrailingSlash?"/":""),"string"==typeof r.opts.query&&(r.opts.query=function(t){for(var n={},i=t.split("&"),r=0,e=i.length;r<e;r++){var o=i[r].split("=");n[decodeURIComponent(o[0])]=decodeURIComponent(o[1])}return n}(r.opts.query)),ct&&(r.opts.closeOnBeforeunload&&(r.P=function(){r.transport&&(r.transport.removeAllListeners(),r.transport.close())},addEventListener("beforeunload",r.P,!1)),"localhost"!==r.hostname&&(r.$=function(){r.F("transport close",{description:"network connection lost"})},at.push(r.$))),r.opts.withCredentials&&(r.V=void 0),r.q(),r}s(n,t);var i=n.prototype;return i.createTransport=function(t){var n=e({},this.opts.query);n.EIO=4,n.transport=t,this.id&&(n.sid=this.id);var i=e({},this.opts,{query:n,socket:this,hostname:this.hostname,secure:this.secure,port:this.port},this.opts.transportOptions[t]);return new this.D[t](i)},i.q=function(){var t=this;if(0!==this.transports.length){var i=this.opts.rememberUpgrade&&n.priorWebsocketSuccess&&-1!==this.transports.indexOf("websocket")?"websocket":this.transports[0];this.readyState="opening";var r=this.createTransport(i);r.open(),this.setTransport(r)}else this.setTimeoutFn((function(){t.emitReserved("error","No transports available")}),0)},i.setTransport=function(t){var n=this;this.transport&&this.transport.removeAllListeners(),this.transport=t,t.on("drain",this.X.bind(this)).on("packet",this.H.bind(this)).on("error",this.B.bind(this)).on("close",(function(t){return n.F("transport close",t)}))},i.onOpen=function(){this.readyState="open",n.priorWebsocketSuccess="websocket"===this.transport.name,this.emitReserved("open"),this.flush()},i.H=function(t){if("opening"===this.readyState||"open"===this.readyState||"closing"===this.readyState)switch(this.emitReserved("packet",t),this.emitReserved("heartbeat"),t.type){case"open":this.onHandshake(JSON.parse(t.data));break;case"ping":this.J("pong"),this.emitReserved("ping"),this.emitReserved("pong"),this.K();break;case"error":var n=new Error("server error");n.code=t.data,this.B(n);break;case"message":this.emitReserved("data",t.data),this.emitReserved("message",t.data)}},i.onHandshake=function(t){this.emitReserved("handshake",t),this.id=t.sid,this.transport.query.sid=t.sid,this.I=t.pingInterval,this.R=t.pingTimeout,this.L=t.maxPayload,this.onOpen(),"closed"!==this.readyState&&this.K()},i.K=function(){var t=this;this.clearTimeoutFn(this.Y);var n=this.I+this.R;this._=Date.now()+n,this.Y=this.setTimeoutFn((function(){t.F("ping timeout")}),n),this.opts.autoUnref&&this.Y.unref()},i.X=function(){this.writeBuffer.splice(0,this.M),this.M=0,0===this.writeBuffer.length?this.emitReserved("drain"):this.flush()},i.flush=function(){if("closed"!==this.readyState&&this.transport.writable&&!this.upgrading&&this.writeBuffer.length){var t=this.G();this.transport.send(t),this.M=t.length,this.emitReserved("flush")}},i.G=function(){if(!(this.L&&"polling"===this.transport.name&&this.writeBuffer.length>1))return this.writeBuffer;for(var t,n=1,i=0;i<this.writeBuffer.length;i++){var r=this.writeBuffer[i].data;if(r&&(n+="string"==typeof(t=r)?function(t){for(var n=0,i=0,r=0,e=t.length;r<e;r++)(n=t.charCodeAt(r))<128?i+=1:n<2048?i+=2:n<55296||n>=57344?i+=3:(r++,i+=4);return i}(t):Math.ceil(1.33*(t.byteLength||t.size))),i>0&&n>this.L)return this.writeBuffer.slice(0,i);n+=2}return this.writeBuffer},i.W=function(){var t=this;if(!this._)return!0;var n=Date.now()>this._;return n&&(this._=0,R((function(){t.F("ping timeout")}),this.setTimeoutFn)),n},i.write=function(t,n,i){return this.J("message",t,n,i),this},i.send=function(t,n,i){return this.J("message",t,n,i),this},i.J=function(t,n,i,r){if("function"==typeof n&&(r=n,n=void 0),"function"==typeof i&&(r=i,i=null),"closing"!==this.readyState&&"closed"!==this.readyState){(i=i||{}).compress=!1!==i.compress;var e={type:t,data:n,options:i};this.emitReserved("packetCreate",e),this.writeBuffer.push(e),r&&this.once("flush",r),this.flush()}},i.close=function(){var t=this,n=function(){t.F("forced close"),t.transport.close()},i=function i(){t.off("upgrade",i),t.off("upgradeError",i),n()},r=function(){t.once("upgrade",i),t.once("upgradeError",i)};return"opening"!==this.readyState&&"open"!==this.readyState||(this.readyState="closing",this.writeBuffer.length?this.once("drain",(function(){t.upgrading?r():n()})):this.upgrading?r():n()),this},i.B=function(t){if(n.priorWebsocketSuccess=!1,this.opts.tryAllTransports&&this.transports.length>1&&"opening"===this.readyState)return this.transports.shift(),this.q();this.emitReserved("error",t),this.F("transport error",t)},i.F=function(t,n){if("opening"===this.readyState||"open"===this.readyState||"closing"===this.readyState){if(this.clearTimeoutFn(this.Y),this.transport.removeAllListeners("close"),this.transport.close(),this.transport.removeAllListeners(),ct&&(this.P&&removeEventListener("beforeunload",this.P,!1),this.$)){var i=at.indexOf(this.$);-1!==i&&at.splice(i,1)}this.readyState="closed",this.id=null,this.emitReserved("close",t,n),this.writeBuffer=[],this.M=0}},n}(I);vt.protocol=4;var lt=function(t){function n(){var n;return(n=t.apply(this,arguments)||this).Z=[],n}s(n,t);var i=n.prototype;return i.onOpen=function(){if(t.prototype.onOpen.call(this),"open"===this.readyState&&this.opts.upgrade)for(var n=0;n<this.Z.length;n++)this.tt(this.Z[n])},i.tt=function(t){var n=this,i=this.createTransport(t),r=!1;vt.priorWebsocketSuccess=!1;var e=function(){r||(i.send([{type:"ping",data:"probe"}]),i.once("packet",(function(t){if(!r)if("pong"===t.type&&"probe"===t.data){if(n.upgrading=!0,n.emitReserved("upgrading",i),!i)return;vt.priorWebsocketSuccess="websocket"===i.name,n.transport.pause((function(){r||"closed"!==n.readyState&&(c(),n.setTransport(i),i.send([{type:"upgrade"}]),n.emitReserved("upgrade",i),i=null,n.upgrading=!1,n.flush())}))}else{var e=new Error("probe error");e.transport=i.name,n.emitReserved("upgradeError",e)}})))};function o(){r||(r=!0,c(),i.close(),i=null)}var s=function(t){var r=new Error("probe error: "+t);r.transport=i.name,o(),n.emitReserved("upgradeError",r)};function u(){s("transport closed")}function h(){s("socket closed")}function f(t){i&&t.name!==i.name&&o()}var c=function(){i.removeListener("open",e),i.removeListener("error",s),i.removeListener("close",u),n.off("close",h),n.off("upgrading",f)};i.once("open",e),i.once("error",s),i.once("close",u),this.once("close",h),this.once("upgrading",f),-1!==this.Z.indexOf("webtransport")&&"webtransport"!==t?this.setTimeoutFn((function(){r||i.open()}),200):i.open()},i.onHandshake=function(n){this.Z=this.nt(n.upgrades),t.prototype.onHandshake.call(this,n)},i.nt=function(t){for(var n=[],i=0;i<t.length;i++)~this.transports.indexOf(t[i])&&n.push(t[i]);return n},n}(vt),pt=function(t){function n(n){var i=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{},r="object"===c(n)?n:i;return(!r.transports||r.transports&&"string"==typeof r.transports[0])&&(r.transports=(r.transports||["polling","websocket","webtransport"]).map((function(t){return st[t]})).filter((function(t){return!!t}))),t.call(this,n,r)||this}return s(n,t),n}(lt);pt.protocol;var dt="function"==typeof ArrayBuffer,yt=function(t){return"function"==typeof ArrayBuffer.isView?ArrayBuffer.isView(t):t.buffer instanceof ArrayBuffer},bt=Object.prototype.toString,wt="function"==typeof Blob||"undefined"!=typeof Blob&&"[object BlobConstructor]"===bt.call(Blob),gt="function"==typeof File||"undefined"!=typeof File&&"[object FileConstructor]"===bt.call(File);function mt(t){return dt&&(t instanceof ArrayBuffer||yt(t))||wt&&t instanceof Blob||gt&&t instanceof File}function kt(t,n){if(!t||"object"!==c(t))return!1;if(Array.isArray(t)){for(var i=0,r=t.length;i<r;i++)if(kt(t[i]))return!0;return!1}if(mt(t))return!0;if(t.toJSON&&"function"==typeof t.toJSON&&1===arguments.length)return kt(t.toJSON(),!0);for(var e in t)if(Object.prototype.hasOwnProperty.call(t,e)&&kt(t[e]))return!0;return!1}function At(t){var n=[],i=t.data,r=t;return r.data=jt(i,n),r.attachments=n.length,{packet:r,buffers:n}}function jt(t,n){if(!t)return t;if(mt(t)){var i={_placeholder:!0,num:n.length};return n.push(t),i}if(Array.isArray(t)){for(var r=new Array(t.length),e=0;e<t.length;e++)r[e]=jt(t[e],n);return r}if("object"===c(t)&&!(t instanceof Date)){var o={};for(var s in t)Object.prototype.hasOwnProperty.call(t,s)&&(o[s]=jt(t[s],n));return o}return t}function Et(t,n){return t.data=Ot(t.data,n),delete t.attachments,t}function Ot(t,n){if(!t)return t;if(t&&!0===t._placeholder){if("number"==typeof t.num&&t.num>=0&&t.num<n.length)return n[t.num];throw new Error("illegal attachments")}if(Array.isArray(t))for(var i=0;i<t.length;i++)t[i]=Ot(t[i],n);else if("object"===c(t))for(var r in t)Object.prototype.hasOwnProperty.call(t,r)&&(t[r]=Ot(t[r],n));return t}var Bt,St=["connect","connect_error","disconnect","disconnecting","newListener","removeListener"];!function(t){t[t.CONNECT=0]="CONNECT",t[t.DISCONNECT=1]="DISCONNECT",t[t.EVENT=2]="EVENT",t[t.ACK=3]="ACK",t[t.CONNECT_ERROR=4]="CONNECT_ERROR",t[t.BINARY_EVENT=5]="BINARY_EVENT",t[t.BINARY_ACK=6]="BINARY_ACK"}(Bt||(Bt={}));var Nt=function(){function t(t){this.replacer=t}var n=t.prototype;return n.encode=function(t){return t.type!==Bt.EVENT&&t.type!==Bt.ACK||!kt(t)?[this.encodeAsString(t)]:this.encodeAsBinary({type:t.type===Bt.EVENT?Bt.BINARY_EVENT:Bt.BINARY_ACK,nsp:t.nsp,data:t.data,id:t.id})},n.encodeAsString=function(t){var n=""+t.type;return t.type!==Bt.BINARY_EVENT&&t.type!==Bt.BINARY_ACK||(n+=t.attachments+"-"),t.nsp&&"/"!==t.nsp&&(n+=t.nsp+","),null!=t.id&&(n+=t.id),null!=t.data&&(n+=JSON.stringify(t.data,this.replacer)),n},n.encodeAsBinary=function(t){var n=At(t),i=this.encodeAsString(n.packet),r=n.buffers;return r.unshift(i),r},t}(),Ct=function(t){function n(n){var i;return(i=t.call(this)||this).reviver=n,i}s(n,t);var i=n.prototype;return i.add=function(n){var i;if("string"==typeof n){if(this.reconstructor)throw new Error("got plaintext data when reconstructing a packet");var r=(i=this.decodeString(n)).type===Bt.BINARY_EVENT;r||i.type===Bt.BINARY_ACK?(i.type=r?Bt.EVENT:Bt.ACK,this.reconstructor=new Tt(i),0===i.attachments&&t.prototype.emitReserved.call(this,"decoded",i)):t.prototype.emitReserved.call(this,"decoded",i)}else{if(!mt(n)&&!n.base64)throw new Error("Unknown type: "+n);if(!this.reconstructor)throw new Error("got binary data when not reconstructing a packet");(i=this.reconstructor.takeBinaryData(n))&&(this.reconstructor=null,t.prototype.emitReserved.call(this,"decoded",i))}},i.decodeString=function(t){var i=0,r={type:Number(t.charAt(0))};if(void 0===Bt[r.type])throw new Error("unknown packet type "+r.type);if(r.type===Bt.BINARY_EVENT||r.type===Bt.BINARY_ACK){for(var e=i+1;"-"!==t.charAt(++i)&&i!=t.length;);var o=t.substring(e,i);if(o!=Number(o)||"-"!==t.charAt(i))throw new Error("Illegal attachments");r.attachments=Number(o)}if("/"===t.charAt(i+1)){for(var s=i+1;++i;){if(","===t.charAt(i))break;if(i===t.length)break}r.nsp=t.substring(s,i)}else r.nsp="/";var u=t.charAt(i+1);if(""!==u&&Number(u)==u){for(var h=i+1;++i;){var f=t.charAt(i);if(null==f||Number(f)!=f){--i;break}if(i===t.length)break}r.id=Number(t.substring(h,i+1))}if(t.charAt(++i)){var c=this.tryParse(t.substr(i));if(!n.isPayloadValid(r.type,c))throw new Error("invalid payload");r.data=c}return r},i.tryParse=function(t){try{return JSON.parse(t,this.reviver)}catch(t){return!1}},n.isPayloadValid=function(t,n){switch(t){case Bt.CONNECT:return Mt(n);case Bt.DISCONNECT:return void 0===n;case Bt.CONNECT_ERROR:return"string"==typeof n||Mt(n);case Bt.EVENT:case Bt.BINARY_EVENT:return Array.isArray(n)&&("number"==typeof n[0]||"string"==typeof n[0]&&-1===St.indexOf(n[0]));case Bt.ACK:case Bt.BINARY_ACK:return Array.isArray(n)}},i.destroy=function(){this.reconstructor&&(this.reconstructor.finishedReconstruction(),this.reconstructor=null)},n}(I),Tt=function(){function t(t){this.packet=t,this.buffers=[],this.reconPack=t}var n=t.prototype;return n.takeBinaryData=function(t){if(this.buffers.push(t),this.buffers.length===this.reconPack.attachments){var n=Et(this.reconPack,this.buffers);return this.finishedReconstruction(),n}return null},n.finishedReconstruction=function(){this.reconPack=null,this.buffers=[]},t}();var Ut=Number.isInteger||function(t){return"number"==typeof t&&isFinite(t)&&Math.floor(t)===t};function Mt(t){return"[object Object]"===Object.prototype.toString.call(t)}var xt=Object.freeze({__proto__:null,protocol:5,get PacketType(){return Bt},Encoder:Nt,Decoder:Ct,isPacketValid:function(t){return"string"==typeof t.nsp&&(void 0===(n=t.id)||Ut(n))&&function(t,n){switch(t){case Bt.CONNECT:return void 0===n||Mt(n);case Bt.DISCONNECT:return void 0===n;case Bt.EVENT:return Array.isArray(n)&&("number"==typeof n[0]||"string"==typeof n[0]&&-1===St.indexOf(n[0]));case Bt.ACK:return Array.isArray(n);case Bt.CONNECT_ERROR:return"string"==typeof n||Mt(n);default:return!1}}(t.type,t.data);var n}});function It(t,n,i){return t.on(n,i),function(){t.off(n,i)}}var Rt=Object.freeze({connect:1,connect_error:1,disconnect:1,disconnecting:1,newListener:1,removeListener:1}),Lt=function(t){function n(n,i,r){var o;return(o=t.call(this)||this).connected=!1,o.recovered=!1,o.receiveBuffer=[],o.sendBuffer=[],o.it=[],o.rt=0,o.ids=0,o.acks={},o.flags={},o.io=n,o.nsp=i,r&&r.auth&&(o.auth=r.auth),o.l=e({},r),o.io.et&&o.open(),o}s(n,t);var o=n.prototype;return o.subEvents=function(){if(!this.subs){var t=this.io;this.subs=[It(t,"open",this.onopen.bind(this)),It(t,"packet",this.onpacket.bind(this)),It(t,"error",this.onerror.bind(this)),It(t,"close",this.onclose.bind(this))]}},o.connect=function(){return this.connected||(this.subEvents(),this.io.ot||this.io.open(),"open"===this.io.st&&this.onopen()),this},o.open=function(){return this.connect()},o.send=function(){for(var t=arguments.length,n=new Array(t),i=0;i<t;i++)n[i]=arguments[i];return n.unshift("message"),this.emit.apply(this,n),this},o.emit=function(t){var n,i,r;if(Rt.hasOwnProperty(t))throw new Error('"'+t.toString()+'" is a reserved event name');for(var e=arguments.length,o=new Array(e>1?e-1:0),s=1;s<e;s++)o[s-1]=arguments[s];if(o.unshift(t),this.l.retries&&!this.flags.fromQueue&&!this.flags.volatile)return this.ut(o),this;var u={type:Bt.EVENT,data:o,options:{}};if(u.options.compress=!1!==this.flags.compress,"function"==typeof o[o.length-1]){var h=this.ids++,f=o.pop();this.ht(h,f),u.id=h}var c=null===(i=null===(n=this.io.engine)||void 0===n?void 0:n.transport)||void 0===i?void 0:i.writable,a=this.connected&&!(null===(r=this.io.engine)||void 0===r?void 0:r.W());return this.flags.volatile&&!c||(a?(this.notifyOutgoingListeners(u),this.packet(u)):this.sendBuffer.push(u)),this.flags={},this},o.ht=function(t,n){var i,r=this,e=null!==(i=this.flags.timeout)&&void 0!==i?i:this.l.ackTimeout;if(void 0!==e){var o=this.io.setTimeoutFn((function(){delete r.acks[t];for(var i=0;i<r.sendBuffer.length;i++)r.sendBuffer[i].id===t&&r.sendBuffer.splice(i,1);n.call(r,new Error("operation has timed out"))}),e),s=function(){r.io.clearTimeoutFn(o);for(var t=arguments.length,i=new Array(t),e=0;e<t;e++)i[e]=arguments[e];n.apply(r,i)};s.withError=!0,this.acks[t]=s}else this.acks[t]=n},o.emitWithAck=function(t){for(var n=this,i=arguments.length,r=new Array(i>1?i-1:0),e=1;e<i;e++)r[e-1]=arguments[e];return new Promise((function(i,e){var o=function(t,n){return t?e(t):i(n)};o.withError=!0,r.push(o),n.emit.apply(n,[t].concat(r))}))},o.ut=function(t){var n,i=this;"function"==typeof t[t.length-1]&&(n=t.pop());var r={id:this.rt++,tryCount:0,pending:!1,args:t,flags:e({fromQueue:!0},this.flags)};t.push((function(t){if(r===i.it[0]){if(null!==t)r.tryCount>i.l.retries&&(i.it.shift(),n&&n(t));else if(i.it.shift(),n){for(var e=arguments.length,o=new Array(e>1?e-1:0),s=1;s<e;s++)o[s-1]=arguments[s];n.apply(void 0,[null].concat(o))}return r.pending=!1,i.ft()}})),this.it.push(r),this.ft()},o.ft=function(){var t=arguments.length>0&&void 0!==arguments[0]&&arguments[0];if(this.connected&&0!==this.it.length){var n=this.it[0];n.pending&&!t||(n.pending=!0,n.tryCount++,this.flags=n.flags,this.emit.apply(this,n.args))}},o.packet=function(t){t.nsp=this.nsp,this.io.ct(t)},o.onopen=function(){var t=this;"function"==typeof this.auth?this.auth((function(n){t.vt(n)})):this.vt(this.auth)},o.vt=function(t){this.packet({type:Bt.CONNECT,data:this.lt?e({pid:this.lt,offset:this.dt},t):t})},o.onerror=function(t){this.connected||this.emitReserved("connect_error",t)},o.onclose=function(t,n){this.connected=!1,delete this.id,this.emitReserved("disconnect",t,n),this.yt()},o.yt=function(){var t=this;Object.keys(this.acks).forEach((function(n){if(!t.sendBuffer.some((function(t){return String(t.id)===n}))){var i=t.acks[n];delete t.acks[n],i.withError&&i.call(t,new Error("socket has been disconnected"))}}))},o.onpacket=function(t){if(t.nsp===this.nsp)switch(t.type){case Bt.CONNECT:t.data&&t.data.sid?this.onconnect(t.data.sid,t.data.pid):this.emitReserved("connect_error",new Error("It seems you are trying to reach a Socket.IO server in v2.x with a v3.x client, but they are not compatible (more information here: https://socket.io/docs/v3/migrating-from-2-x-to-3-0/)"));break;case Bt.EVENT:case Bt.BINARY_EVENT:this.onevent(t);break;case Bt.ACK:case Bt.BINARY_ACK:this.onack(t);break;case Bt.DISCONNECT:this.ondisconnect();break;case Bt.CONNECT_ERROR:this.destroy();var n=new Error(t.data.message);n.data=t.data.data,this.emitReserved("connect_error",n)}},o.onevent=function(t){var n=t.data||[];null!=t.id&&n.push(this.ack(t.id)),this.connected?this.emitEvent(n):this.receiveBuffer.push(Object.freeze(n))},o.emitEvent=function(n){if(this.bt&&this.bt.length){var i,e=r(this.bt.slice());try{for(e.s();!(i=e.n()).done;){i.value.apply(this,n)}}catch(t){e.e(t)}finally{e.f()}}t.prototype.emit.apply(this,n),this.lt&&n.length&&"string"==typeof n[n.length-1]&&(this.dt=n[n.length-1])},o.ack=function(t){var n=this,i=!1;return function(){if(!i){i=!0;for(var r=arguments.length,e=new Array(r),o=0;o<r;o++)e[o]=arguments[o];n.packet({type:Bt.ACK,id:t,data:e})}}},o.onack=function(t){var n=this.acks[t.id];"function"==typeof n&&(delete this.acks[t.id],n.withError&&t.data.unshift(null),n.apply(this,t.data))},o.onconnect=function(t,n){this.id=t,this.recovered=n&&this.lt===n,this.lt=n,this.connected=!0,this.emitBuffered(),this.emitReserved("connect"),this.ft(!0)},o.emitBuffered=function(){var t=this;this.receiveBuffer.forEach((function(n){return t.emitEvent(n)})),this.receiveBuffer=[],this.sendBuffer.forEach((function(n){t.notifyOutgoingListeners(n),t.packet(n)})),this.sendBuffer=[]},o.ondisconnect=function(){this.destroy(),this.onclose("io server disconnect")},o.destroy=function(){this.subs&&(this.subs.forEach((function(t){return t()})),this.subs=void 0),this.io.wt(this)},o.disconnect=function(){return this.connected&&this.packet({type:Bt.DISCONNECT}),this.destroy(),this.connected&&this.onclose("io client disconnect"),this},o.close=function(){return this.disconnect()},o.compress=function(t){return this.flags.compress=t,this},o.timeout=function(t){return this.flags.timeout=t,this},o.onAny=function(t){return this.bt=this.bt||[],this.bt.push(t),this},o.prependAny=function(t){return this.bt=this.bt||[],this.bt.unshift(t),this},o.offAny=function(t){if(!this.bt)return this;if(t){for(var n=this.bt,i=0;i<n.length;i++)if(t===n[i])return n.splice(i,1),this}else this.bt=[];return this},o.listenersAny=function(){return this.bt||[]},o.onAnyOutgoing=function(t){return this.gt=this.gt||[],this.gt.push(t),this},o.prependAnyOutgoing=function(t){return this.gt=this.gt||[],this.gt.unshift(t),this},o.offAnyOutgoing=function(t){if(!this.gt)return this;if(t){for(var n=this.gt,i=0;i<n.length;i++)if(t===n[i])return n.splice(i,1),this}else this.gt=[];return this},o.listenersAnyOutgoing=function(){return this.gt||[]},o.notifyOutgoingListeners=function(t){if(this.gt&&this.gt.length){var n,i=r(this.gt.slice());try{for(i.s();!(n=i.n()).done;){n.value.apply(this,t.data)}}catch(t){i.e(t)}finally{i.f()}}},i(n,[{key:"disconnected",get:function(){return!this.connected}},{key:"active",get:function(){return!!this.subs}},{key:"volatile",get:function(){return this.flags.volatile=!0,this}}])}(I);function _t(t){t=t||{},this.ms=t.min||100,this.max=t.max||1e4,this.factor=t.factor||2,this.jitter=t.jitter>0&&t.jitter<=1?t.jitter:0,this.attempts=0}_t.prototype.duration=function(){var t=this.ms*Math.pow(this.factor,this.attempts++);if(this.jitter){var n=Math.random(),i=Math.floor(n*this.jitter*t);t=1&Math.floor(10*n)?t+i:t-i}return 0|Math.min(t,this.max)},_t.prototype.reset=function(){this.attempts=0},_t.prototype.setMin=function(t){this.ms=t},_t.prototype.setMax=function(t){this.max=t},_t.prototype.setJitter=function(t){this.jitter=t};var Dt=function(t){function n(n,i){var r,e;(r=t.call(this)||this).nsps={},r.subs=[],n&&"object"===c(n)&&(i=n,n=void 0),(i=i||{}).path=i.path||"/socket.io",r.opts=i,$(r,i),r.reconnection(!1!==i.reconnection),r.reconnectionAttempts(i.reconnectionAttempts||1/0),r.reconnectionDelay(i.reconnectionDelay||1e3),r.reconnectionDelayMax(i.reconnectionDelayMax||5e3),r.randomizationFactor(null!==(e=i.randomizationFactor)&&void 0!==e?e:.5),r.backoff=new _t({min:r.reconnectionDelay(),max:r.reconnectionDelayMax(),jitter:r.randomizationFactor()}),r.timeout(null==i.timeout?2e4:i.timeout),r.st="closed",r.uri=n;var o=i.parser||xt;return r.encoder=new o.Encoder,r.decoder=new o.Decoder,r.et=!1!==i.autoConnect,r.et&&r.open(),r}s(n,t);var i=n.prototype;return i.reconnection=function(t){return arguments.length?(this.kt=!!t,t||(this.skipReconnect=!0),this):this.kt},i.reconnectionAttempts=function(t){return void 0===t?this.At:(this.At=t,this)},i.reconnectionDelay=function(t){var n;return void 0===t?this.jt:(this.jt=t,null===(n=this.backoff)||void 0===n||n.setMin(t),this)},i.randomizationFactor=function(t){var n;return void 0===t?this.Et:(this.Et=t,null===(n=this.backoff)||void 0===n||n.setJitter(t),this)},i.reconnectionDelayMax=function(t){var n;return void 0===t?this.Ot:(this.Ot=t,null===(n=this.backoff)||void 0===n||n.setMax(t),this)},i.timeout=function(t){return arguments.length?(this.Bt=t,this):this.Bt},i.maybeReconnectOnOpen=function(){!this.ot&&this.kt&&0===this.backoff.attempts&&this.reconnect()},i.open=function(t){var n=this;if(~this.st.indexOf("open"))return this;this.engine=new pt(this.uri,this.opts);var i=this.engine,r=this;this.st="opening",this.skipReconnect=!1;var e=It(i,"open",(function(){r.onopen(),t&&t()})),o=function(i){n.cleanup(),n.st="closed",n.emitReserved("error",i),t?t(i):n.maybeReconnectOnOpen()},s=It(i,"error",o);if(!1!==this.Bt){var u=this.Bt,h=this.setTimeoutFn((function(){e(),o(new Error("timeout")),i.close()}),u);this.opts.autoUnref&&h.unref(),this.subs.push((function(){n.clearTimeoutFn(h)}))}return this.subs.push(e),this.subs.push(s),this},i.connect=function(t){return this.open(t)},i.onopen=function(){this.cleanup(),this.st="open",this.emitReserved("open");var t=this.engine;this.subs.push(It(t,"ping",this.onping.bind(this)),It(t,"data",this.ondata.bind(this)),It(t,"error",this.onerror.bind(this)),It(t,"close",this.onclose.bind(this)),It(this.decoder,"decoded",this.ondecoded.bind(this)))},i.onping=function(){this.emitReserved("ping")},i.ondata=function(t){try{this.decoder.add(t)}catch(t){this.onclose("parse error",t)}},i.ondecoded=function(t){var n=this;R((function(){n.emitReserved("packet",t)}),this.setTimeoutFn)},i.onerror=function(t){this.emitReserved("error",t)},i.socket=function(t,n){var i=this.nsps[t];return i?this.et&&!i.active&&i.connect():(i=new Lt(this,t,n),this.nsps[t]=i),i},i.wt=function(t){for(var n=0,i=Object.keys(this.nsps);n<i.length;n++){var r=i[n];if(this.nsps[r].active)return}this.St()},i.ct=function(t){for(var n=this.encoder.encode(t),i=0;i<n.length;i++)this.engine.write(n[i],t.options)},i.cleanup=function(){this.subs.forEach((function(t){return t()})),this.subs.length=0,this.decoder.destroy()},i.St=function(){this.skipReconnect=!0,this.ot=!1,this.onclose("forced close")},i.disconnect=function(){return this.St()},i.onclose=function(t,n){var i;this.cleanup(),null===(i=this.engine)||void 0===i||i.close(),this.backoff.reset(),this.st="closed",this.emitReserved("close",t,n),this.kt&&!this.skipReconnect&&this.reconnect()},i.reconnect=function(){var t=this;if(this.ot||this.skipReconnect)return this;var n=this;if(this.backoff.attempts>=this.At)this.backoff.reset(),this.emitReserved("reconnect_failed"),this.ot=!1;else{var i=this.backoff.duration();this.ot=!0;var r=this.setTimeoutFn((function(){n.skipReconnect||(t.emitReserved("reconnect_attempt",n.backoff.attempts),n.skipReconnect||n.open((function(i){i?(n.ot=!1,n.reconnect(),t.emitReserved("reconnect_error",i)):n.onreconnect()})))}),i);this.opts.autoUnref&&r.unref(),this.subs.push((function(){t.clearTimeoutFn(r)}))}},i.onreconnect=function(){var t=this.backoff.attempts;this.ot=!1,this.backoff.reset(),this.emitReserved("reconnect",t)},n}(I),Pt={};function $t(t,n){"object"===c(t)&&(n=t,t=void 0);var i,r=function(t){var n=arguments.length>1&&void 0!==arguments[1]?arguments[1]:"",i=arguments.length>2?arguments[2]:void 0,r=t;i=i||"undefined"!=typeof location&&location,null==t&&(t=i.protocol+"//"+i.host),"string"==typeof t&&("/"===t.charAt(0)&&(t="/"===t.charAt(1)?i.protocol+t:i.host+t),/^(https?|wss?):\/\//.test(t)||(t=void 0!==i?i.protocol+"//"+t:"https://"+t),r=ft(t)),r.port||(/^(http|ws)$/.test(r.protocol)?r.port="80":/^(http|ws)s$/.test(r.protocol)&&(r.port="443")),r.path=r.path||"/";var e=-1!==r.host.indexOf(":")?"["+r.host+"]":r.host;return r.id=r.protocol+"://"+e+":"+r.port+n,r.href=r.protocol+"://"+e+(i&&i.port===r.port?"":":"+r.port),r}(t,(n=n||{}).path||"/socket.io"),e=r.source,o=r.id,s=r.path,u=Pt[o]&&s in Pt[o].nsps;return n.forceNew||n["force new connection"]||!1===n.multiplex||u?i=new Dt(e,n):(Pt[o]||(Pt[o]=new Dt(e,n)),i=Pt[o]),r.query&&!n.query&&(n.query=r.queryKey),i.socket(r.path,n)}return e($t,{Manager:Dt,Socket:Lt,io:$t,connect:$t}),$t}));
7 +//# sourceMappingURL=socket.io.min.js.map