dooers-workers 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
dooers/__init__.py ADDED
@@ -0,0 +1,73 @@
1
+ from dooers.broadcast import BroadcastManager
2
+ from dooers.config import WorkerConfig
3
+ from dooers.features.analytics import (
4
+ AnalyticsBatch,
5
+ AnalyticsCollector,
6
+ AnalyticsEvent,
7
+ AnalyticsEventPayload,
8
+ FeedbackData,
9
+ WorkerAnalytics,
10
+ )
11
+ from dooers.features.settings import (
12
+ SettingsBroadcaster,
13
+ SettingsField,
14
+ SettingsFieldType,
15
+ SettingsSchema,
16
+ SettingsSelectOption,
17
+ WorkerSettings,
18
+ )
19
+ from dooers.handlers.memory import WorkerMemory
20
+ from dooers.handlers.request import WorkerRequest
21
+ from dooers.handlers.response import WorkerResponse
22
+ from dooers.persistence.base import Persistence
23
+ from dooers.protocol.models import (
24
+ Actor,
25
+ ContentPart,
26
+ DocumentPart,
27
+ EventType,
28
+ ImagePart,
29
+ Run,
30
+ RunStatus,
31
+ TextPart,
32
+ Thread,
33
+ ThreadEvent,
34
+ )
35
+ from dooers.registry import ConnectionRegistry
36
+ from dooers.server import WorkerServer
37
+
38
+ __all__ = [
39
+ # Core
40
+ "WorkerConfig",
41
+ "WorkerServer",
42
+ "WorkerResponse",
43
+ "WorkerMemory",
44
+ "WorkerRequest",
45
+ "ConnectionRegistry",
46
+ "BroadcastManager",
47
+ "Persistence",
48
+ # Protocol models
49
+ "ContentPart",
50
+ "TextPart",
51
+ "ImagePart",
52
+ "DocumentPart",
53
+ "Thread",
54
+ "ThreadEvent",
55
+ "Run",
56
+ "RunStatus",
57
+ "Actor",
58
+ "EventType",
59
+ # Analytics
60
+ "AnalyticsEvent",
61
+ "AnalyticsEventPayload",
62
+ "AnalyticsBatch",
63
+ "AnalyticsCollector",
64
+ "FeedbackData",
65
+ "WorkerAnalytics",
66
+ # Settings
67
+ "SettingsFieldType",
68
+ "SettingsField",
69
+ "SettingsSelectOption",
70
+ "SettingsSchema",
71
+ "SettingsBroadcaster",
72
+ "WorkerSettings",
73
+ ]
dooers/broadcast.py ADDED
@@ -0,0 +1,180 @@
1
+ from __future__ import annotations
2
+
3
+ import uuid
4
+ from datetime import UTC, datetime
5
+ from typing import TYPE_CHECKING
6
+
7
+ from dooers.protocol.frames import (
8
+ EventAppendPayload,
9
+ S2C_EventAppend,
10
+ S2C_ThreadUpsert,
11
+ ThreadUpsertPayload,
12
+ )
13
+ from dooers.protocol.models import Actor, ContentPart, Thread, ThreadEvent
14
+ from dooers.protocol.parser import serialize_frame
15
+
16
+ if TYPE_CHECKING:
17
+ from dooers.persistence.base import Persistence
18
+ from dooers.registry import ConnectionRegistry
19
+
20
+
21
+ def _generate_id() -> str:
22
+ return str(uuid.uuid4())
23
+
24
+
25
+ def _now() -> datetime:
26
+ return datetime.now(UTC)
27
+
28
+
29
+ class BroadcastManager:
30
+ """
31
+ Manages broadcasting events from backend to connected WebSocket clients.
32
+
33
+ Use this to push messages, notifications, or events from external systems
34
+ (webhooks, background jobs, etc.) to all connected users of a worker.
35
+ """
36
+
37
+ def __init__(
38
+ self,
39
+ registry: ConnectionRegistry,
40
+ persistence: Persistence,
41
+ subscriptions: dict[str, set[str]], # worker_id -> set of subscribed thread_ids per connection
42
+ ) -> None:
43
+ self._registry = registry
44
+ self._persistence = persistence
45
+ self._subscriptions = subscriptions # Shared with Router instances
46
+
47
+ async def send_event(
48
+ self,
49
+ worker_id: str,
50
+ thread_id: str,
51
+ content: list[ContentPart],
52
+ actor: Actor = "system",
53
+ user_id: str | None = None,
54
+ user_name: str | None = None,
55
+ user_email: str | None = None,
56
+ ) -> tuple[ThreadEvent, int]:
57
+ """
58
+ Create and broadcast an event from the backend to all connected clients.
59
+
60
+ Args:
61
+ worker_id: The worker instance to broadcast to
62
+ thread_id: The thread to add the event to
63
+ content: Message content (text, images, documents)
64
+ actor: Event actor (default: "system")
65
+ user_id: Optional user identifier
66
+ user_name: Optional display name
67
+ user_email: Optional email
68
+
69
+ Returns:
70
+ Tuple of (created event, number of connections notified)
71
+
72
+ Raises:
73
+ ValueError: If thread not found
74
+ PermissionError: If thread belongs to different worker
75
+ """
76
+ # Verify thread exists and belongs to worker
77
+ thread = await self._persistence.get_thread(thread_id)
78
+ if not thread:
79
+ raise ValueError(f"Thread {thread_id} not found")
80
+ if thread.worker_id != worker_id:
81
+ raise PermissionError(f"Thread {thread_id} belongs to different worker")
82
+
83
+ now = _now()
84
+
85
+ # Create the event
86
+ event = ThreadEvent(
87
+ id=_generate_id(),
88
+ thread_id=thread_id,
89
+ run_id=None,
90
+ type="message",
91
+ actor=actor,
92
+ user_id=user_id,
93
+ user_name=user_name,
94
+ user_email=user_email,
95
+ content=content,
96
+ data=None,
97
+ created_at=now,
98
+ )
99
+
100
+ # Persist
101
+ await self._persistence.create_event(event)
102
+
103
+ # Update thread timestamp
104
+ thread.last_event_at = now
105
+ thread.updated_at = now
106
+ await self._persistence.update_thread(thread)
107
+
108
+ # Broadcast to all connections for this worker
109
+ frame = S2C_EventAppend(
110
+ id=_generate_id(),
111
+ payload=EventAppendPayload(thread_id=thread_id, events=[event]),
112
+ )
113
+ message = serialize_frame(frame)
114
+ count = await self._registry.broadcast(worker_id, message)
115
+
116
+ return event, count
117
+
118
+ async def send_thread_update(
119
+ self,
120
+ worker_id: str,
121
+ thread: Thread,
122
+ ) -> int:
123
+ """
124
+ Broadcast a thread update to all connected clients.
125
+
126
+ Args:
127
+ worker_id: The worker instance to broadcast to
128
+ thread: The thread that was updated
129
+
130
+ Returns:
131
+ Number of connections notified
132
+ """
133
+ if thread.worker_id != worker_id:
134
+ raise PermissionError(f"Thread {thread.id} belongs to different worker")
135
+
136
+ frame = S2C_ThreadUpsert(
137
+ id=_generate_id(),
138
+ payload=ThreadUpsertPayload(thread=thread),
139
+ )
140
+ message = serialize_frame(frame)
141
+ return await self._registry.broadcast(worker_id, message)
142
+
143
+ async def create_thread_and_broadcast(
144
+ self,
145
+ worker_id: str,
146
+ user_id: str | None = None,
147
+ title: str | None = None,
148
+ ) -> tuple[Thread, int]:
149
+ """
150
+ Create a new thread and broadcast it to all connected clients.
151
+
152
+ Args:
153
+ worker_id: The worker instance
154
+ user_id: Optional user who owns the thread
155
+ title: Optional thread title
156
+
157
+ Returns:
158
+ Tuple of (created thread, number of connections notified)
159
+ """
160
+ now = _now()
161
+ thread = Thread(
162
+ id=_generate_id(),
163
+ worker_id=worker_id,
164
+ user_id=user_id,
165
+ title=title,
166
+ created_at=now,
167
+ updated_at=now,
168
+ last_event_at=now,
169
+ )
170
+
171
+ await self._persistence.create_thread(thread)
172
+
173
+ frame = S2C_ThreadUpsert(
174
+ id=_generate_id(),
175
+ payload=ThreadUpsertPayload(thread=thread),
176
+ )
177
+ message = serialize_frame(frame)
178
+ count = await self._registry.broadcast(worker_id, message)
179
+
180
+ return thread, count
dooers/config.py ADDED
@@ -0,0 +1,22 @@
1
+ from dataclasses import dataclass
2
+ from typing import TYPE_CHECKING, Literal
3
+
4
+ if TYPE_CHECKING:
5
+ from dooers.features.settings.models import SettingsSchema
6
+
7
+
8
+ @dataclass
9
+ class WorkerConfig:
10
+ database_url: str
11
+ database_type: Literal["postgres", "sqlite"]
12
+ table_prefix: str = "worker_"
13
+ auto_migrate: bool = True
14
+
15
+ # Analytics (optional, defaults from settings.py)
16
+ analytics_enabled: bool = True
17
+ analytics_webhook_url: str | None = None # Override default webhook URL
18
+ analytics_batch_size: int | None = None # Override default
19
+ analytics_flush_interval: float | None = None # Override default
20
+
21
+ # Settings
22
+ settings_schema: "SettingsSchema | None" = None
File without changes
@@ -0,0 +1,12 @@
1
+ from .collector import AnalyticsCollector
2
+ from .models import AnalyticsBatch, AnalyticsEvent, AnalyticsEventPayload, FeedbackData
3
+ from .worker_analytics import WorkerAnalytics
4
+
5
+ __all__ = [
6
+ "AnalyticsCollector",
7
+ "AnalyticsBatch",
8
+ "AnalyticsEvent",
9
+ "AnalyticsEventPayload",
10
+ "FeedbackData",
11
+ "WorkerAnalytics",
12
+ ]
@@ -0,0 +1,219 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import logging
5
+ from datetime import UTC, datetime
6
+ from typing import TYPE_CHECKING, Any
7
+ from uuid import uuid4
8
+
9
+ import httpx
10
+
11
+ from .models import AnalyticsBatch, AnalyticsEvent, AnalyticsEventPayload
12
+
13
+ if TYPE_CHECKING:
14
+ from ..registry import ConnectionRegistry
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+
19
+ class AnalyticsCollector:
20
+ """
21
+ Collects analytics events, broadcasts to subscribers, and batches for webhook delivery.
22
+
23
+ Features:
24
+ - Real-time broadcast to analytics subscribers
25
+ - Batched webhook delivery (configurable size and interval)
26
+ - Background flush task for time-based batching
27
+ """
28
+
29
+ def __init__(
30
+ self,
31
+ webhook_url: str,
32
+ registry: ConnectionRegistry,
33
+ subscriptions: dict[str, set[str]], # worker_id -> set of ws_ids
34
+ batch_size: int = 10,
35
+ flush_interval: float = 5.0,
36
+ ) -> None:
37
+ self._webhook_url = webhook_url
38
+ self._registry = registry
39
+ self._subscriptions = subscriptions
40
+ self._batch_size = batch_size
41
+ self._flush_interval = flush_interval
42
+ self._buffer: list[AnalyticsEventPayload] = []
43
+ self._lock = asyncio.Lock()
44
+ self._flush_task: asyncio.Task[None] | None = None
45
+ self._http_client: httpx.AsyncClient | None = None
46
+ self._running = False
47
+
48
+ async def start(self) -> None:
49
+ """Start the background flush task and HTTP client."""
50
+ if self._running:
51
+ return
52
+ self._running = True
53
+ self._http_client = httpx.AsyncClient(timeout=30.0)
54
+ self._flush_task = asyncio.create_task(self._flush_loop())
55
+
56
+ async def stop(self) -> None:
57
+ """Stop the background task and flush remaining events."""
58
+ self._running = False
59
+ if self._flush_task:
60
+ self._flush_task.cancel()
61
+ try:
62
+ await self._flush_task
63
+ except asyncio.CancelledError:
64
+ pass
65
+ self._flush_task = None
66
+
67
+ try:
68
+ # Flush any remaining events
69
+ await self._flush()
70
+ finally:
71
+ # Always close HTTP client
72
+ if self._http_client:
73
+ await self._http_client.aclose()
74
+ self._http_client = None
75
+
76
+ async def track(
77
+ self,
78
+ event: str,
79
+ worker_id: str,
80
+ thread_id: str | None = None,
81
+ user_id: str | None = None,
82
+ run_id: str | None = None,
83
+ event_id: str | None = None,
84
+ data: dict[str, Any] | None = None,
85
+ ) -> None:
86
+ """
87
+ Track an analytics event.
88
+
89
+ Immediately broadcasts to analytics subscribers and adds to batch buffer.
90
+ """
91
+ payload = AnalyticsEventPayload(
92
+ event=event,
93
+ timestamp=datetime.now(UTC),
94
+ worker_id=worker_id,
95
+ thread_id=thread_id,
96
+ user_id=user_id,
97
+ run_id=run_id,
98
+ event_id=event_id,
99
+ data=data,
100
+ )
101
+
102
+ # Broadcast to analytics subscribers immediately
103
+ await self._broadcast(worker_id, payload)
104
+
105
+ # Add to batch buffer
106
+ async with self._lock:
107
+ self._buffer.append(payload)
108
+ if len(self._buffer) >= self._batch_size:
109
+ await self._flush_locked()
110
+
111
+ async def feedback(
112
+ self,
113
+ feedback_type: str,
114
+ target_type: str,
115
+ target_id: str,
116
+ worker_id: str,
117
+ thread_id: str | None = None,
118
+ user_id: str | None = None,
119
+ reason: str | None = None,
120
+ ) -> None:
121
+ """Record a feedback event (like or dislike)."""
122
+ event = AnalyticsEvent.FEEDBACK_LIKE if feedback_type == "like" else AnalyticsEvent.FEEDBACK_DISLIKE
123
+ await self.track(
124
+ event=event.value,
125
+ worker_id=worker_id,
126
+ thread_id=thread_id,
127
+ user_id=user_id,
128
+ data={
129
+ "target_type": target_type,
130
+ "target_id": target_id,
131
+ "reason": reason,
132
+ },
133
+ )
134
+
135
+ async def _broadcast(self, worker_id: str, payload: AnalyticsEventPayload) -> None:
136
+ """Broadcast an analytics event to all subscribers for a worker."""
137
+ from ..protocol.frames import S2C_AnalyticsEvent
138
+
139
+ subscriber_ws_ids = self._subscriptions.get(worker_id, set())
140
+ if not subscriber_ws_ids:
141
+ return
142
+
143
+ message = S2C_AnalyticsEvent(
144
+ id=str(uuid4()),
145
+ payload=payload,
146
+ )
147
+ message_json = message.model_dump_json()
148
+
149
+ # Send to all subscribed connections
150
+ # subscriber_ws_ids contains the ws_id from Router, which we use to track subscriptions
151
+ # We need to send to all connections since we can't map ws_id back to ws objects
152
+ # The subscription dict tracks which ws_ids are subscribed, but we broadcast to all
153
+ # connections for the worker - this is acceptable for real-time analytics
154
+ connections = self._registry.get_connections(worker_id)
155
+ for ws in connections:
156
+ try:
157
+ await ws.send_text(message_json)
158
+ except Exception:
159
+ logger.warning("Failed to send analytics event to subscriber")
160
+
161
+ async def _flush(self) -> None:
162
+ """Flush the buffer, acquiring lock first."""
163
+ async with self._lock:
164
+ await self._flush_locked()
165
+
166
+ async def _flush_locked(self) -> None:
167
+ """Flush the buffer (must be called with lock held)."""
168
+ if not self._buffer:
169
+ return
170
+
171
+ # Group events by worker_id for separate batches
172
+ events_by_worker: dict[str, list[AnalyticsEventPayload]] = {}
173
+ for event in self._buffer:
174
+ if event.worker_id not in events_by_worker:
175
+ events_by_worker[event.worker_id] = []
176
+ events_by_worker[event.worker_id].append(event)
177
+
178
+ self._buffer.clear()
179
+
180
+ # Send batches per worker
181
+ for worker_id, events in events_by_worker.items():
182
+ batch = AnalyticsBatch(
183
+ batch_id=str(uuid4()),
184
+ worker_id=worker_id,
185
+ events=events,
186
+ sent_at=datetime.now(UTC),
187
+ )
188
+ await self._send_to_webhook(batch)
189
+
190
+ async def _send_to_webhook(self, batch: AnalyticsBatch) -> None:
191
+ """Send a batch to the webhook endpoint (fire and forget with retry)."""
192
+ if not self._http_client:
193
+ return
194
+
195
+ try:
196
+ response = await self._http_client.post(
197
+ self._webhook_url,
198
+ json=batch.model_dump(mode="json"),
199
+ headers={"Content-Type": "application/json"},
200
+ )
201
+ if response.status_code >= 400:
202
+ logger.warning(
203
+ "Analytics webhook returned %d: %s",
204
+ response.status_code,
205
+ response.text[:200],
206
+ )
207
+ except httpx.RequestError as e:
208
+ logger.warning("Failed to send analytics batch: %s", e)
209
+
210
+ async def _flush_loop(self) -> None:
211
+ """Background task that flushes every flush_interval seconds."""
212
+ while self._running:
213
+ try:
214
+ await asyncio.sleep(self._flush_interval)
215
+ await self._flush()
216
+ except asyncio.CancelledError:
217
+ break
218
+ except Exception as e:
219
+ logger.exception("Error in analytics flush loop: %s", e)
@@ -0,0 +1,50 @@
1
+ from datetime import datetime
2
+ from enum import StrEnum
3
+ from typing import Any, Literal
4
+
5
+ from pydantic import BaseModel
6
+
7
+
8
+ class AnalyticsEvent(StrEnum):
9
+ """Built-in analytics event types."""
10
+
11
+ # Automatic events
12
+ THREAD_CREATED = "thread.created"
13
+ MESSAGE_C2S = "message.c2s"
14
+ MESSAGE_S2C = "message.s2c"
15
+ TOOL_CALLED = "tool.called"
16
+ ERROR_OCCURRED = "error.occurred"
17
+
18
+ # Feedback events
19
+ FEEDBACK_LIKE = "feedback.like"
20
+ FEEDBACK_DISLIKE = "feedback.dislike"
21
+
22
+
23
+ class AnalyticsEventPayload(BaseModel):
24
+ """Payload for an analytics event."""
25
+
26
+ event: str # AnalyticsEvent value or custom string
27
+ timestamp: datetime
28
+ worker_id: str
29
+ thread_id: str | None = None
30
+ user_id: str | None = None
31
+ run_id: str | None = None
32
+ event_id: str | None = None
33
+ data: dict[str, Any] | None = None
34
+
35
+
36
+ class FeedbackData(BaseModel):
37
+ """Feedback targeting information."""
38
+
39
+ target_type: Literal["event", "run", "thread"]
40
+ target_id: str
41
+ reason: str | None = None
42
+
43
+
44
+ class AnalyticsBatch(BaseModel):
45
+ """Batch of analytics events for webhook delivery."""
46
+
47
+ batch_id: str
48
+ worker_id: str
49
+ events: list[AnalyticsEventPayload]
50
+ sent_at: datetime
@@ -0,0 +1,100 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING, Any
4
+
5
+ if TYPE_CHECKING:
6
+ from .collector import AnalyticsCollector
7
+
8
+
9
+ class WorkerAnalytics:
10
+ """
11
+ Handler API for analytics tracking.
12
+
13
+ Provides methods for tracking custom events and recording feedback.
14
+ Passed as the fourth parameter to handler functions.
15
+ """
16
+
17
+ def __init__(
18
+ self,
19
+ worker_id: str,
20
+ thread_id: str,
21
+ user_id: str | None,
22
+ run_id: str | None,
23
+ collector: AnalyticsCollector,
24
+ ) -> None:
25
+ self._worker_id = worker_id
26
+ self._thread_id = thread_id
27
+ self._user_id = user_id
28
+ self._run_id = run_id
29
+ self._collector = collector
30
+
31
+ async def track(self, event: str, data: dict[str, Any] | None = None) -> None:
32
+ """
33
+ Track a custom analytics event.
34
+
35
+ Args:
36
+ event: Event name (e.g., "llm.called", "search.performed")
37
+ data: Optional event-specific data
38
+ """
39
+ await self._collector.track(
40
+ event=event,
41
+ worker_id=self._worker_id,
42
+ thread_id=self._thread_id,
43
+ user_id=self._user_id,
44
+ run_id=self._run_id,
45
+ data=data,
46
+ )
47
+
48
+ async def like(
49
+ self,
50
+ target_type: str,
51
+ target_id: str,
52
+ reason: str | None = None,
53
+ ) -> None:
54
+ """
55
+ Record a like (positive feedback).
56
+
57
+ Useful for external sources like WhatsApp where users can't
58
+ interact with the dashboard directly.
59
+
60
+ Args:
61
+ target_type: Type of target ("event", "run", or "thread")
62
+ target_id: ID of the target
63
+ reason: Optional reason for the feedback
64
+ """
65
+ await self._collector.feedback(
66
+ feedback_type="like",
67
+ target_type=target_type,
68
+ target_id=target_id,
69
+ worker_id=self._worker_id,
70
+ thread_id=self._thread_id,
71
+ user_id=self._user_id,
72
+ reason=reason,
73
+ )
74
+
75
+ async def dislike(
76
+ self,
77
+ target_type: str,
78
+ target_id: str,
79
+ reason: str | None = None,
80
+ ) -> None:
81
+ """
82
+ Record a dislike (negative feedback).
83
+
84
+ Useful for external sources like WhatsApp where users can't
85
+ interact with the dashboard directly.
86
+
87
+ Args:
88
+ target_type: Type of target ("event", "run", or "thread")
89
+ target_id: ID of the target
90
+ reason: Optional reason for the feedback
91
+ """
92
+ await self._collector.feedback(
93
+ feedback_type="dislike",
94
+ target_type=target_type,
95
+ target_id=target_id,
96
+ worker_id=self._worker_id,
97
+ thread_id=self._thread_id,
98
+ user_id=self._user_id,
99
+ reason=reason,
100
+ )
@@ -0,0 +1,12 @@
1
+ from .broadcaster import SettingsBroadcaster
2
+ from .models import SettingsField, SettingsFieldType, SettingsSchema, SettingsSelectOption
3
+ from .worker_settings import WorkerSettings
4
+
5
+ __all__ = [
6
+ "SettingsBroadcaster",
7
+ "SettingsField",
8
+ "SettingsFieldType",
9
+ "SettingsSchema",
10
+ "SettingsSelectOption",
11
+ "WorkerSettings",
12
+ ]