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.
@@ -0,0 +1,957 @@
1
+ from __future__ import annotations
2
+
3
+ import uuid
4
+ from collections.abc import AsyncGenerator, Callable
5
+ from datetime import UTC, datetime
6
+ from typing import TYPE_CHECKING, Protocol
7
+
8
+ from dooers.features.analytics.models import AnalyticsEvent
9
+ from dooers.features.analytics.worker_analytics import WorkerAnalytics
10
+ from dooers.features.settings.worker_settings import WorkerSettings
11
+ from dooers.handlers.memory import WorkerMemory
12
+ from dooers.handlers.request import WorkerRequest
13
+ from dooers.handlers.response import WorkerEvent, WorkerResponse
14
+ from dooers.persistence.base import Persistence
15
+ from dooers.protocol.frames import (
16
+ AckPayload,
17
+ C2S_AnalyticsSubscribe,
18
+ C2S_AnalyticsUnsubscribe,
19
+ C2S_Connect,
20
+ C2S_EventCreate,
21
+ C2S_Feedback,
22
+ C2S_SettingsPatch,
23
+ C2S_SettingsSubscribe,
24
+ C2S_SettingsUnsubscribe,
25
+ C2S_ThreadList,
26
+ C2S_ThreadSubscribe,
27
+ C2S_ThreadUnsubscribe,
28
+ ClientToServer,
29
+ EventAppendPayload,
30
+ FeedbackAckPayload,
31
+ RunUpsertPayload,
32
+ S2C_Ack,
33
+ S2C_EventAppend,
34
+ S2C_FeedbackAck,
35
+ S2C_RunUpsert,
36
+ S2C_ThreadListResult,
37
+ S2C_ThreadSnapshot,
38
+ S2C_ThreadUpsert,
39
+ ServerToClient,
40
+ ThreadListResultPayload,
41
+ ThreadSnapshotPayload,
42
+ ThreadUpsertPayload,
43
+ )
44
+ from dooers.protocol.models import DocumentPart, ImagePart, Run, TextPart, Thread, ThreadEvent
45
+ from dooers.protocol.parser import serialize_frame
46
+ from dooers.registry import ConnectionRegistry
47
+
48
+ if TYPE_CHECKING:
49
+ from dooers.features.analytics.collector import AnalyticsCollector
50
+ from dooers.features.settings.broadcaster import SettingsBroadcaster
51
+ from dooers.features.settings.models import SettingsSchema
52
+
53
+
54
+ class WebSocketProtocol(Protocol):
55
+ async def receive_text(self) -> str: ...
56
+ async def send_text(self, data: str) -> None: ...
57
+ async def close(self, code: int = 1000) -> None: ...
58
+
59
+
60
+ Handler = Callable[
61
+ [WorkerRequest, WorkerResponse, WorkerMemory, WorkerAnalytics, WorkerSettings],
62
+ AsyncGenerator[WorkerEvent, None],
63
+ ]
64
+
65
+
66
+ def _generate_id() -> str:
67
+ return str(uuid.uuid4())
68
+
69
+
70
+ def _now() -> datetime:
71
+ return datetime.now(UTC)
72
+
73
+
74
+ class Router:
75
+ def __init__(
76
+ self,
77
+ persistence: Persistence,
78
+ handler: Handler,
79
+ registry: ConnectionRegistry,
80
+ subscriptions: dict[str, set[str]],
81
+ analytics_collector: AnalyticsCollector | None = None,
82
+ settings_broadcaster: SettingsBroadcaster | None = None,
83
+ settings_schema: SettingsSchema | None = None,
84
+ analytics_subscriptions: dict[str, set[str]] | None = None,
85
+ settings_subscriptions: dict[str, set[str]] | None = None,
86
+ ):
87
+ self._persistence = persistence
88
+ self._handler = handler
89
+ self._registry = registry
90
+ self._subscriptions = subscriptions # ws_id -> set of subscribed thread_ids
91
+
92
+ # Analytics and settings
93
+ self._analytics_collector = analytics_collector
94
+ self._settings_broadcaster = settings_broadcaster
95
+ self._settings_schema = settings_schema
96
+ self._analytics_subscriptions = analytics_subscriptions or {}
97
+ self._settings_subscriptions = settings_subscriptions or {}
98
+
99
+ # Connection state
100
+ self._ws: WebSocketProtocol | None = None
101
+ self._ws_id: str = _generate_id() # Unique ID for this connection
102
+ self._worker_id: str | None = None
103
+ self._user_id: str | None = None
104
+ self._user_name: str | None = None
105
+ self._user_email: str | None = None
106
+ self._subscribed_threads: set[str] = set()
107
+
108
+ async def _send(self, ws: WebSocketProtocol, frame: ServerToClient) -> None:
109
+ await ws.send_text(serialize_frame(frame))
110
+
111
+ async def _send_ack(
112
+ self,
113
+ ws: WebSocketProtocol,
114
+ ack_id: str,
115
+ ok: bool = True,
116
+ error: dict | None = None,
117
+ ) -> None:
118
+ frame = S2C_Ack(
119
+ id=_generate_id(),
120
+ payload=AckPayload(ack_id=ack_id, ok=ok, error=error),
121
+ )
122
+ await self._send(ws, frame)
123
+
124
+ async def _broadcast_to_worker(self, frame: ServerToClient) -> None:
125
+ """Broadcast a frame to all connections for the current worker."""
126
+ if not self._worker_id:
127
+ return
128
+ message = serialize_frame(frame)
129
+ await self._registry.broadcast(self._worker_id, message)
130
+
131
+ async def _broadcast_to_worker_except_self(self, ws: WebSocketProtocol, frame: ServerToClient) -> None:
132
+ """Broadcast a frame to all connections for the current worker except this one."""
133
+ if not self._worker_id:
134
+ return
135
+ message = serialize_frame(frame)
136
+ await self._registry.broadcast_except(self._worker_id, ws, message)
137
+
138
+ async def route(self, ws: WebSocketProtocol, frame: ClientToServer) -> None:
139
+ self._ws = ws
140
+ match frame:
141
+ case C2S_Connect():
142
+ await self._handle_connect(ws, frame)
143
+ case C2S_ThreadList():
144
+ await self._handle_thread_list(ws, frame)
145
+ case C2S_ThreadSubscribe():
146
+ await self._handle_thread_subscribe(ws, frame)
147
+ case C2S_ThreadUnsubscribe():
148
+ await self._handle_thread_unsubscribe(ws, frame)
149
+ case C2S_EventCreate():
150
+ await self._handle_event_create(ws, frame)
151
+ # Analytics frames
152
+ case C2S_AnalyticsSubscribe():
153
+ await self._handle_analytics_subscribe(ws, frame)
154
+ case C2S_AnalyticsUnsubscribe():
155
+ await self._handle_analytics_unsubscribe(ws, frame)
156
+ case C2S_Feedback():
157
+ await self._handle_feedback(ws, frame)
158
+ # Settings frames
159
+ case C2S_SettingsSubscribe():
160
+ await self._handle_settings_subscribe(ws, frame)
161
+ case C2S_SettingsUnsubscribe():
162
+ await self._handle_settings_unsubscribe(ws, frame)
163
+ case C2S_SettingsPatch():
164
+ await self._handle_settings_patch(ws, frame)
165
+
166
+ async def cleanup(self) -> None:
167
+ """Clean up connection resources. Call this when the connection closes."""
168
+ if self._worker_id:
169
+ await self._registry.unregister(self._worker_id, self._ws)
170
+
171
+ # Clean up analytics subscriptions
172
+ if self._worker_id in self._analytics_subscriptions:
173
+ self._analytics_subscriptions[self._worker_id].discard(self._ws_id)
174
+ if not self._analytics_subscriptions[self._worker_id]:
175
+ del self._analytics_subscriptions[self._worker_id]
176
+
177
+ # Clean up settings subscriptions
178
+ if self._worker_id in self._settings_subscriptions:
179
+ self._settings_subscriptions[self._worker_id].discard(self._ws_id)
180
+ if not self._settings_subscriptions[self._worker_id]:
181
+ del self._settings_subscriptions[self._worker_id]
182
+
183
+ # Clean up thread subscriptions tracking
184
+ if self._ws_id in self._subscriptions:
185
+ del self._subscriptions[self._ws_id]
186
+
187
+ async def _handle_connect(self, ws: WebSocketProtocol, frame: C2S_Connect) -> None:
188
+ # Extract identity from payload
189
+ self._worker_id = frame.payload.worker_id
190
+ self._user_id = frame.payload.user_id
191
+ self._user_name = frame.payload.user_name
192
+ self._user_email = frame.payload.user_email
193
+ self._ws = ws
194
+
195
+ # Register connection in registry
196
+ await self._registry.register(self._worker_id, ws)
197
+
198
+ # Initialize subscriptions for this connection
199
+ self._subscriptions[self._ws_id] = set()
200
+
201
+ await self._send_ack(ws, frame.id)
202
+
203
+ async def _handle_thread_list(self, ws: WebSocketProtocol, frame: C2S_ThreadList) -> None:
204
+ if not self._worker_id:
205
+ await self._send_ack(
206
+ ws,
207
+ frame.id,
208
+ ok=False,
209
+ error={"code": "NOT_CONNECTED", "message": "Must connect first"},
210
+ )
211
+ return
212
+
213
+ # Filter by worker_id first, then optionally by user_id
214
+ threads = await self._persistence.list_threads(
215
+ worker_id=self._worker_id,
216
+ user_id=None, # Show all threads for this worker (team collaboration)
217
+ cursor=frame.payload.cursor,
218
+ limit=frame.payload.limit or 30,
219
+ )
220
+ result = S2C_ThreadListResult(
221
+ id=_generate_id(),
222
+ payload=ThreadListResultPayload(threads=threads, cursor=None),
223
+ )
224
+ await self._send(ws, result)
225
+
226
+ async def _handle_thread_subscribe(
227
+ self,
228
+ ws: WebSocketProtocol,
229
+ frame: C2S_ThreadSubscribe,
230
+ ) -> None:
231
+ if not self._worker_id:
232
+ await self._send_ack(
233
+ ws,
234
+ frame.id,
235
+ ok=False,
236
+ error={"code": "NOT_CONNECTED", "message": "Must connect first"},
237
+ )
238
+ return
239
+
240
+ thread_id = frame.payload.thread_id
241
+ thread = await self._persistence.get_thread(thread_id)
242
+
243
+ if not thread:
244
+ await self._send_ack(
245
+ ws,
246
+ frame.id,
247
+ ok=False,
248
+ error={"code": "NOT_FOUND", "message": "Thread not found"},
249
+ )
250
+ return
251
+
252
+ # Verify thread belongs to this worker
253
+ if thread.worker_id != self._worker_id:
254
+ await self._send_ack(
255
+ ws,
256
+ frame.id,
257
+ ok=False,
258
+ error={"code": "FORBIDDEN", "message": "Thread belongs to different worker"},
259
+ )
260
+ return
261
+
262
+ events = await self._persistence.get_events(
263
+ thread_id=thread_id,
264
+ after_event_id=frame.payload.after_event_id,
265
+ limit=100,
266
+ )
267
+
268
+ self._subscribed_threads.add(thread_id)
269
+ self._subscriptions[self._ws_id].add(thread_id)
270
+
271
+ snapshot = S2C_ThreadSnapshot(
272
+ id=_generate_id(),
273
+ payload=ThreadSnapshotPayload(thread=thread, events=events),
274
+ )
275
+ await self._send(ws, snapshot)
276
+
277
+ async def _handle_thread_unsubscribe(
278
+ self,
279
+ ws: WebSocketProtocol,
280
+ frame: C2S_ThreadUnsubscribe,
281
+ ) -> None:
282
+ thread_id = frame.payload.thread_id
283
+ self._subscribed_threads.discard(thread_id)
284
+ if self._ws_id in self._subscriptions:
285
+ self._subscriptions[self._ws_id].discard(thread_id)
286
+ await self._send_ack(ws, frame.id)
287
+
288
+ async def _handle_event_create(self, ws: WebSocketProtocol, frame: C2S_EventCreate) -> None:
289
+ if not self._worker_id:
290
+ await self._send_ack(
291
+ ws,
292
+ frame.id,
293
+ ok=False,
294
+ error={"code": "NOT_CONNECTED", "message": "Must connect first"},
295
+ )
296
+ return
297
+
298
+ now = _now()
299
+ thread_id = frame.payload.thread_id
300
+
301
+ if not thread_id:
302
+ # Create new thread for this worker
303
+ thread_id = _generate_id()
304
+ thread = Thread(
305
+ id=thread_id,
306
+ worker_id=self._worker_id,
307
+ user_id=self._user_id,
308
+ title=None,
309
+ created_at=now,
310
+ updated_at=now,
311
+ last_event_at=now,
312
+ )
313
+ await self._persistence.create_thread(thread)
314
+ self._subscribed_threads.add(thread_id)
315
+ self._subscriptions[self._ws_id].add(thread_id)
316
+
317
+ # Broadcast new thread to ALL worker connections
318
+ thread_upsert = S2C_ThreadUpsert(
319
+ id=_generate_id(),
320
+ payload=ThreadUpsertPayload(thread=thread),
321
+ )
322
+ await self._broadcast_to_worker(thread_upsert)
323
+
324
+ # Track thread.created
325
+ await self._track_event(
326
+ AnalyticsEvent.THREAD_CREATED.value,
327
+ thread_id=thread_id,
328
+ )
329
+ else:
330
+ # Verify thread belongs to this worker
331
+ thread = await self._persistence.get_thread(thread_id)
332
+ if not thread:
333
+ await self._send_ack(
334
+ ws,
335
+ frame.id,
336
+ ok=False,
337
+ error={"code": "NOT_FOUND", "message": "Thread not found"},
338
+ )
339
+ return
340
+ if thread.worker_id != self._worker_id:
341
+ await self._send_ack(
342
+ ws,
343
+ frame.id,
344
+ ok=False,
345
+ error={"code": "FORBIDDEN", "message": "Thread belongs to different worker"},
346
+ )
347
+ return
348
+
349
+ # Create user event with identity
350
+ user_event_id = _generate_id()
351
+ content_parts = [self._convert_content_part(part) for part in frame.payload.event.content]
352
+ user_event = ThreadEvent(
353
+ id=user_event_id,
354
+ thread_id=thread_id,
355
+ run_id=None,
356
+ type="message",
357
+ actor="user",
358
+ user_id=self._user_id,
359
+ user_name=self._user_name,
360
+ user_email=self._user_email,
361
+ content=content_parts,
362
+ data=frame.payload.event.data,
363
+ created_at=now,
364
+ )
365
+ await self._persistence.create_event(user_event)
366
+
367
+ # Broadcast user event to ALL worker connections
368
+ event_append = S2C_EventAppend(
369
+ id=_generate_id(),
370
+ payload=EventAppendPayload(thread_id=thread_id, events=[user_event]),
371
+ )
372
+ await self._broadcast_to_worker(event_append)
373
+
374
+ # Track message.c2s
375
+ await self._track_event(
376
+ AnalyticsEvent.MESSAGE_C2S.value,
377
+ thread_id=thread_id,
378
+ event_id=user_event_id,
379
+ )
380
+
381
+ await self._send_ack(ws, frame.id)
382
+
383
+ # Process with handler
384
+ message = self._extract_message(content_parts)
385
+ request = WorkerRequest(
386
+ message=message,
387
+ content=content_parts,
388
+ thread_id=thread_id,
389
+ event_id=user_event_id,
390
+ user_id=self._user_id,
391
+ )
392
+ response = WorkerResponse()
393
+ memory = WorkerMemory(thread_id=thread_id, persistence=self._persistence)
394
+
395
+ # Create analytics instance for handler
396
+ analytics = (
397
+ WorkerAnalytics(
398
+ worker_id=self._worker_id,
399
+ thread_id=thread_id,
400
+ user_id=self._user_id,
401
+ run_id=None, # Will be set when run starts
402
+ collector=self._analytics_collector,
403
+ )
404
+ if self._analytics_collector
405
+ else self._create_noop_analytics()
406
+ )
407
+
408
+ # Create settings instance for handler
409
+ settings = (
410
+ WorkerSettings(
411
+ worker_id=self._worker_id,
412
+ schema=self._settings_schema,
413
+ persistence=self._persistence,
414
+ broadcaster=self._settings_broadcaster,
415
+ )
416
+ if self._settings_schema and self._settings_broadcaster
417
+ else self._create_noop_settings()
418
+ )
419
+
420
+ current_run_id: str | None = None
421
+
422
+ try:
423
+ async for event in self._handler(request, response, memory, analytics, settings):
424
+ event_now = _now()
425
+
426
+ if event.response_type == "run_start":
427
+ current_run_id = _generate_id()
428
+ run = Run(
429
+ id=current_run_id,
430
+ thread_id=thread_id,
431
+ agent_id=event.data.get("agent_id"),
432
+ status="running",
433
+ started_at=event_now,
434
+ )
435
+ await self._persistence.create_run(run)
436
+
437
+ run_upsert = S2C_RunUpsert(
438
+ id=_generate_id(),
439
+ payload=RunUpsertPayload(run=run),
440
+ )
441
+ await self._broadcast_to_worker(run_upsert)
442
+
443
+ elif event.response_type == "run_end":
444
+ if current_run_id:
445
+ run = Run(
446
+ id=current_run_id,
447
+ thread_id=thread_id,
448
+ status=event.data.get("status", "succeeded"),
449
+ started_at=event_now,
450
+ ended_at=event_now,
451
+ error=event.data.get("error"),
452
+ )
453
+ await self._persistence.update_run(run)
454
+
455
+ run_upsert = S2C_RunUpsert(
456
+ id=_generate_id(),
457
+ payload=RunUpsertPayload(run=run),
458
+ )
459
+ await self._broadcast_to_worker(run_upsert)
460
+ current_run_id = None
461
+
462
+ elif event.response_type == "text":
463
+ event_id = _generate_id()
464
+ thread_event = ThreadEvent(
465
+ id=event_id,
466
+ thread_id=thread_id,
467
+ run_id=current_run_id,
468
+ type="message",
469
+ actor="assistant",
470
+ user_id=None,
471
+ user_name=None,
472
+ user_email=None,
473
+ content=[TextPart(text=event.data["text"])],
474
+ created_at=event_now,
475
+ )
476
+ await self._persistence.create_event(thread_event)
477
+
478
+ append = S2C_EventAppend(
479
+ id=_generate_id(),
480
+ payload=EventAppendPayload(thread_id=thread_id, events=[thread_event]),
481
+ )
482
+ await self._broadcast_to_worker(append)
483
+
484
+ # Track message.s2c
485
+ await self._track_event(
486
+ AnalyticsEvent.MESSAGE_S2C.value,
487
+ thread_id=thread_id,
488
+ run_id=current_run_id,
489
+ event_id=event_id,
490
+ data={"type": "text"},
491
+ )
492
+
493
+ elif event.response_type == "image":
494
+ event_id = _generate_id()
495
+ thread_event = ThreadEvent(
496
+ id=event_id,
497
+ thread_id=thread_id,
498
+ run_id=current_run_id,
499
+ type="message",
500
+ actor="assistant",
501
+ user_id=None,
502
+ user_name=None,
503
+ user_email=None,
504
+ content=[
505
+ ImagePart(
506
+ url=event.data["url"],
507
+ mime_type=event.data.get("mime_type"),
508
+ alt=event.data.get("alt"),
509
+ )
510
+ ],
511
+ created_at=event_now,
512
+ )
513
+ await self._persistence.create_event(thread_event)
514
+
515
+ append = S2C_EventAppend(
516
+ id=_generate_id(),
517
+ payload=EventAppendPayload(thread_id=thread_id, events=[thread_event]),
518
+ )
519
+ await self._broadcast_to_worker(append)
520
+
521
+ # Track message.s2c
522
+ await self._track_event(
523
+ AnalyticsEvent.MESSAGE_S2C.value,
524
+ thread_id=thread_id,
525
+ run_id=current_run_id,
526
+ event_id=event_id,
527
+ data={"type": "image"},
528
+ )
529
+
530
+ elif event.response_type == "document":
531
+ event_id = _generate_id()
532
+ thread_event = ThreadEvent(
533
+ id=event_id,
534
+ thread_id=thread_id,
535
+ run_id=current_run_id,
536
+ type="message",
537
+ actor="assistant",
538
+ user_id=None,
539
+ user_name=None,
540
+ user_email=None,
541
+ content=[
542
+ DocumentPart(
543
+ url=event.data["url"],
544
+ filename=event.data["filename"],
545
+ mime_type=event.data["mime_type"],
546
+ )
547
+ ],
548
+ created_at=event_now,
549
+ )
550
+ await self._persistence.create_event(thread_event)
551
+
552
+ append = S2C_EventAppend(
553
+ id=_generate_id(),
554
+ payload=EventAppendPayload(thread_id=thread_id, events=[thread_event]),
555
+ )
556
+ await self._broadcast_to_worker(append)
557
+
558
+ # Track message.s2c
559
+ await self._track_event(
560
+ AnalyticsEvent.MESSAGE_S2C.value,
561
+ thread_id=thread_id,
562
+ run_id=current_run_id,
563
+ event_id=event_id,
564
+ data={"type": "document"},
565
+ )
566
+
567
+ elif event.response_type == "tool_call":
568
+ event_id = _generate_id()
569
+ thread_event = ThreadEvent(
570
+ id=event_id,
571
+ thread_id=thread_id,
572
+ run_id=current_run_id,
573
+ type="tool.call",
574
+ actor="assistant",
575
+ user_id=None,
576
+ user_name=None,
577
+ user_email=None,
578
+ data={
579
+ "name": event.data["name"],
580
+ "args": event.data["args"],
581
+ },
582
+ created_at=event_now,
583
+ )
584
+ await self._persistence.create_event(thread_event)
585
+
586
+ append = S2C_EventAppend(
587
+ id=_generate_id(),
588
+ payload=EventAppendPayload(thread_id=thread_id, events=[thread_event]),
589
+ )
590
+ await self._broadcast_to_worker(append)
591
+
592
+ # Track tool.called
593
+ await self._track_event(
594
+ AnalyticsEvent.TOOL_CALLED.value,
595
+ thread_id=thread_id,
596
+ run_id=current_run_id,
597
+ event_id=event_id,
598
+ data={"name": event.data["name"]},
599
+ )
600
+
601
+ elif event.response_type == "tool_result":
602
+ thread_event = ThreadEvent(
603
+ id=_generate_id(),
604
+ thread_id=thread_id,
605
+ run_id=current_run_id,
606
+ type="tool.result",
607
+ actor="tool",
608
+ user_id=None,
609
+ user_name=None,
610
+ user_email=None,
611
+ data={
612
+ "name": event.data["name"],
613
+ "result": event.data["result"],
614
+ },
615
+ created_at=event_now,
616
+ )
617
+ await self._persistence.create_event(thread_event)
618
+
619
+ append = S2C_EventAppend(
620
+ id=_generate_id(),
621
+ payload=EventAppendPayload(thread_id=thread_id, events=[thread_event]),
622
+ )
623
+ await self._broadcast_to_worker(append)
624
+
625
+ await self._update_thread_last_event(thread_id, event_now)
626
+
627
+ except Exception as e:
628
+ # Track error.occurred
629
+ await self._track_event(
630
+ AnalyticsEvent.ERROR_OCCURRED.value,
631
+ thread_id=thread_id,
632
+ run_id=current_run_id,
633
+ data={"error": str(e), "type": type(e).__name__},
634
+ )
635
+
636
+ if current_run_id:
637
+ error_now = _now()
638
+ run = Run(
639
+ id=current_run_id,
640
+ thread_id=thread_id,
641
+ status="failed",
642
+ started_at=error_now,
643
+ ended_at=error_now,
644
+ error=str(e),
645
+ )
646
+ await self._persistence.update_run(run)
647
+
648
+ run_upsert = S2C_RunUpsert(
649
+ id=_generate_id(),
650
+ payload=RunUpsertPayload(run=run),
651
+ )
652
+ await self._broadcast_to_worker(run_upsert)
653
+
654
+ async def _update_thread_last_event(self, thread_id: str, timestamp: datetime) -> None:
655
+ thread = await self._persistence.get_thread(thread_id)
656
+ if thread:
657
+ thread.last_event_at = timestamp
658
+ thread.updated_at = timestamp
659
+ await self._persistence.update_thread(thread)
660
+
661
+ def _convert_content_part(self, part):
662
+ if hasattr(part, "model_dump"):
663
+ data = part.model_dump()
664
+ else:
665
+ data = dict(part) if hasattr(part, "__iter__") else part
666
+
667
+ part_type = data.get("type")
668
+ if part_type == "text":
669
+ return TextPart(**data)
670
+ elif part_type == "image":
671
+ return ImagePart(**data)
672
+ elif part_type == "document":
673
+ return DocumentPart(**data)
674
+ return part
675
+
676
+ def _extract_message(self, content: list) -> str:
677
+ texts = []
678
+ for part in content:
679
+ if isinstance(part, TextPart):
680
+ texts.append(part.text)
681
+ return " ".join(texts)
682
+
683
+ # Analytics frame handlers
684
+
685
+ async def _handle_analytics_subscribe(
686
+ self,
687
+ ws: WebSocketProtocol,
688
+ frame: C2S_AnalyticsSubscribe,
689
+ ) -> None:
690
+ if not self._worker_id:
691
+ await self._send_ack(
692
+ ws,
693
+ frame.id,
694
+ ok=False,
695
+ error={"code": "NOT_CONNECTED", "message": "Must connect first"},
696
+ )
697
+ return
698
+
699
+ worker_id = frame.payload.worker_id
700
+ if worker_id != self._worker_id:
701
+ await self._send_ack(
702
+ ws,
703
+ frame.id,
704
+ ok=False,
705
+ error={"code": "FORBIDDEN", "message": "Cannot subscribe to other worker's analytics"},
706
+ )
707
+ return
708
+
709
+ # Add to analytics subscriptions
710
+ if worker_id not in self._analytics_subscriptions:
711
+ self._analytics_subscriptions[worker_id] = set()
712
+ self._analytics_subscriptions[worker_id].add(self._ws_id)
713
+
714
+ await self._send_ack(ws, frame.id)
715
+
716
+ async def _handle_analytics_unsubscribe(
717
+ self,
718
+ ws: WebSocketProtocol,
719
+ frame: C2S_AnalyticsUnsubscribe,
720
+ ) -> None:
721
+ worker_id = frame.payload.worker_id
722
+ if worker_id in self._analytics_subscriptions:
723
+ self._analytics_subscriptions[worker_id].discard(self._ws_id)
724
+ if not self._analytics_subscriptions[worker_id]:
725
+ del self._analytics_subscriptions[worker_id]
726
+
727
+ await self._send_ack(ws, frame.id)
728
+
729
+ async def _handle_feedback(
730
+ self,
731
+ ws: WebSocketProtocol,
732
+ frame: C2S_Feedback,
733
+ ) -> None:
734
+ if not self._worker_id:
735
+ await self._send_ack(
736
+ ws,
737
+ frame.id,
738
+ ok=False,
739
+ error={"code": "NOT_CONNECTED", "message": "Must connect first"},
740
+ )
741
+ return
742
+
743
+ if self._analytics_collector:
744
+ await self._analytics_collector.feedback(
745
+ feedback_type=frame.payload.feedback,
746
+ target_type=frame.payload.target_type,
747
+ target_id=frame.payload.target_id,
748
+ worker_id=self._worker_id,
749
+ user_id=self._user_id,
750
+ reason=frame.payload.reason,
751
+ )
752
+
753
+ # Send feedback acknowledgment
754
+ ack = S2C_FeedbackAck(
755
+ id=_generate_id(),
756
+ payload=FeedbackAckPayload(
757
+ target_type=frame.payload.target_type,
758
+ target_id=frame.payload.target_id,
759
+ feedback=frame.payload.feedback,
760
+ ok=True,
761
+ ),
762
+ )
763
+ await self._send(ws, ack)
764
+
765
+ # Settings frame handlers
766
+
767
+ async def _handle_settings_subscribe(
768
+ self,
769
+ ws: WebSocketProtocol,
770
+ frame: C2S_SettingsSubscribe,
771
+ ) -> None:
772
+ if not self._worker_id:
773
+ await self._send_ack(
774
+ ws,
775
+ frame.id,
776
+ ok=False,
777
+ error={"code": "NOT_CONNECTED", "message": "Must connect first"},
778
+ )
779
+ return
780
+
781
+ worker_id = frame.payload.worker_id
782
+ if worker_id != self._worker_id:
783
+ await self._send_ack(
784
+ ws,
785
+ frame.id,
786
+ ok=False,
787
+ error={"code": "FORBIDDEN", "message": "Cannot subscribe to other worker's settings"},
788
+ )
789
+ return
790
+
791
+ if not self._settings_schema:
792
+ await self._send_ack(
793
+ ws,
794
+ frame.id,
795
+ ok=False,
796
+ error={"code": "NOT_CONFIGURED", "message": "No settings schema configured"},
797
+ )
798
+ return
799
+
800
+ # Add to settings subscriptions
801
+ if worker_id not in self._settings_subscriptions:
802
+ self._settings_subscriptions[worker_id] = set()
803
+ self._settings_subscriptions[worker_id].add(self._ws_id)
804
+
805
+ # Send settings snapshot directly to this connection
806
+ if self._settings_broadcaster:
807
+ values = await self._persistence.get_settings(worker_id)
808
+ await self._settings_broadcaster.broadcast_snapshot_to_ws(
809
+ worker_id=worker_id,
810
+ ws=ws,
811
+ schema=self._settings_schema,
812
+ values=values,
813
+ )
814
+
815
+ await self._send_ack(ws, frame.id)
816
+
817
+ async def _handle_settings_unsubscribe(
818
+ self,
819
+ ws: WebSocketProtocol,
820
+ frame: C2S_SettingsUnsubscribe,
821
+ ) -> None:
822
+ worker_id = frame.payload.worker_id
823
+ if worker_id in self._settings_subscriptions:
824
+ self._settings_subscriptions[worker_id].discard(self._ws_id)
825
+ if not self._settings_subscriptions[worker_id]:
826
+ del self._settings_subscriptions[worker_id]
827
+
828
+ await self._send_ack(ws, frame.id)
829
+
830
+ async def _handle_settings_patch(
831
+ self,
832
+ ws: WebSocketProtocol,
833
+ frame: C2S_SettingsPatch,
834
+ ) -> None:
835
+ if not self._worker_id:
836
+ await self._send_ack(
837
+ ws,
838
+ frame.id,
839
+ ok=False,
840
+ error={"code": "NOT_CONNECTED", "message": "Must connect first"},
841
+ )
842
+ return
843
+
844
+ if not self._settings_schema:
845
+ await self._send_ack(
846
+ ws,
847
+ frame.id,
848
+ ok=False,
849
+ error={"code": "NOT_CONFIGURED", "message": "No settings schema configured"},
850
+ )
851
+ return
852
+
853
+ field_id = frame.payload.field_id
854
+ value = frame.payload.value
855
+
856
+ # Validate field exists and is not readonly
857
+ field = self._settings_schema.get_field(field_id)
858
+ if not field:
859
+ await self._send_ack(
860
+ ws,
861
+ frame.id,
862
+ ok=False,
863
+ error={"code": "NOT_FOUND", "message": f"Unknown field: {field_id}"},
864
+ )
865
+ return
866
+
867
+ if field.readonly:
868
+ await self._send_ack(
869
+ ws,
870
+ frame.id,
871
+ ok=False,
872
+ error={"code": "READONLY", "message": f"Field '{field_id}' is readonly"},
873
+ )
874
+ return
875
+
876
+ # Update setting
877
+ await self._persistence.update_setting(self._worker_id, field_id, value)
878
+
879
+ # Broadcast to other subscribers (exclude sender)
880
+ if self._settings_broadcaster:
881
+ await self._settings_broadcaster.broadcast_patch(
882
+ worker_id=self._worker_id,
883
+ field_id=field_id,
884
+ value=value,
885
+ exclude_ws=ws,
886
+ )
887
+
888
+ await self._send_ack(ws, frame.id)
889
+
890
+ # Analytics tracking helpers
891
+
892
+ async def _track_event(
893
+ self,
894
+ event: str,
895
+ thread_id: str | None = None,
896
+ run_id: str | None = None,
897
+ event_id: str | None = None,
898
+ data: dict | None = None,
899
+ ) -> None:
900
+ """Track an analytics event if collector is enabled."""
901
+ if self._analytics_collector and self._worker_id:
902
+ await self._analytics_collector.track(
903
+ event=event,
904
+ worker_id=self._worker_id,
905
+ thread_id=thread_id,
906
+ user_id=self._user_id,
907
+ run_id=run_id,
908
+ event_id=event_id,
909
+ data=data,
910
+ )
911
+
912
+ def _create_noop_analytics(self) -> WorkerAnalytics:
913
+ """Create a no-op analytics instance when collector is disabled."""
914
+
915
+ # Create a minimal collector that does nothing
916
+ class NoopCollector:
917
+ async def track(self, **kwargs) -> None:
918
+ pass
919
+
920
+ async def feedback(self, **kwargs) -> None:
921
+ pass
922
+
923
+ return WorkerAnalytics(
924
+ worker_id=self._worker_id or "",
925
+ thread_id="",
926
+ user_id=None,
927
+ run_id=None,
928
+ collector=NoopCollector(), # type: ignore
929
+ )
930
+
931
+ def _create_noop_settings(self) -> WorkerSettings:
932
+ """Create a no-op settings instance when settings are not configured."""
933
+ from dooers.features.settings.models import SettingsSchema
934
+
935
+ class NoopBroadcaster:
936
+ async def broadcast_snapshot(self, **kwargs) -> None:
937
+ pass
938
+
939
+ async def broadcast_patch(self, **kwargs) -> None:
940
+ pass
941
+
942
+ class NoopPersistence:
943
+ async def get_settings(self, worker_id: str) -> dict:
944
+ return {}
945
+
946
+ async def update_setting(self, worker_id: str, field_id: str, value) -> None:
947
+ pass
948
+
949
+ async def set_settings(self, worker_id: str, values: dict) -> None:
950
+ pass
951
+
952
+ return WorkerSettings(
953
+ worker_id=self._worker_id or "",
954
+ schema=SettingsSchema(fields=[]),
955
+ persistence=NoopPersistence(), # type: ignore
956
+ broadcaster=NoopBroadcaster(), # type: ignore
957
+ )