multi-agent-platform 0.1.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.
Files changed (144) hide show
  1. cli/__init__.py +0 -0
  2. cli/action_item_escalation.py +177 -0
  3. cli/agent_client.py +554 -0
  4. cli/bridge_state.py +43 -0
  5. cli/commands/__init__.py +13 -0
  6. cli/commands/action.py +142 -0
  7. cli/commands/agent.py +117 -0
  8. cli/commands/audit.py +68 -0
  9. cli/commands/docs.py +179 -0
  10. cli/commands/experiment.py +755 -0
  11. cli/commands/feedback.py +106 -0
  12. cli/commands/notification.py +213 -0
  13. cli/commands/persona.py +63 -0
  14. cli/commands/project.py +87 -0
  15. cli/commands/runtime.py +105 -0
  16. cli/commands/topic.py +361 -0
  17. cli/e2e_collab.py +602 -0
  18. cli/git_checkpoint.py +68 -0
  19. cli/host_worker_types.py +151 -0
  20. cli/main.py +1553 -0
  21. cli/map_command_client.py +497 -0
  22. cli/participant_worker.py +255 -0
  23. cli/reviewer_worker.py +263 -0
  24. cli/runtime/__init__.py +5 -0
  25. cli/runtime/run_lock.py +497 -0
  26. cli/runtime_chat.py +317 -0
  27. cli/session_wake_log.py +235 -0
  28. cli/simple_waker.py +950 -0
  29. cli/table_render.py +113 -0
  30. cli/wake_backend.py +236 -0
  31. cli/worker_cycle_log.py +36 -0
  32. map_client/__init__.py +37 -0
  33. map_client/bootstrap.py +193 -0
  34. map_client/client.py +1045 -0
  35. map_client/config.py +21 -0
  36. map_client/errors.py +283 -0
  37. map_client/exceptions.py +130 -0
  38. map_client/plan_evidence.py +159 -0
  39. map_client/project_config.py +153 -0
  40. map_client/result_template.py +167 -0
  41. map_client/testing.py +27 -0
  42. map_mcp/__init__.py +4 -0
  43. map_mcp/_utils.py +28 -0
  44. map_mcp/auth.py +34 -0
  45. map_mcp/config.py +50 -0
  46. map_mcp/context.py +39 -0
  47. map_mcp/main.py +75 -0
  48. map_mcp/server.py +573 -0
  49. map_mcp/session.py +79 -0
  50. map_sdk/__init__.py +29 -0
  51. map_sdk/evidence.py +68 -0
  52. map_types/__init__.py +203 -0
  53. map_types/enums.py +199 -0
  54. map_types/schemas.py +1351 -0
  55. multi_agent_platform-0.1.0.dist-info/METADATA +298 -0
  56. multi_agent_platform-0.1.0.dist-info/RECORD +144 -0
  57. multi_agent_platform-0.1.0.dist-info/WHEEL +5 -0
  58. multi_agent_platform-0.1.0.dist-info/entry_points.txt +6 -0
  59. multi_agent_platform-0.1.0.dist-info/licenses/LICENSE +21 -0
  60. multi_agent_platform-0.1.0.dist-info/top_level.txt +6 -0
  61. server/__init__.py +0 -0
  62. server/__version__.py +14 -0
  63. server/api/__init__.py +0 -0
  64. server/api/action_items.py +138 -0
  65. server/api/agents.py +412 -0
  66. server/api/audit.py +54 -0
  67. server/api/background_tasks.py +18 -0
  68. server/api/common.py +117 -0
  69. server/api/deps.py +30 -0
  70. server/api/experiments.py +858 -0
  71. server/api/feedback.py +75 -0
  72. server/api/notifications.py +22 -0
  73. server/api/projects.py +209 -0
  74. server/api/router.py +25 -0
  75. server/api/status.py +33 -0
  76. server/api/topics.py +302 -0
  77. server/api/webhooks.py +74 -0
  78. server/auth/__init__.py +8 -0
  79. server/auth/experiment_access.py +66 -0
  80. server/config.py +38 -0
  81. server/db/__init__.py +3 -0
  82. server/db/base.py +5 -0
  83. server/db/deadlock_retry.py +146 -0
  84. server/db/session.py +41 -0
  85. server/domain/__init__.py +3 -0
  86. server/domain/encrypted_types.py +63 -0
  87. server/domain/models.py +713 -0
  88. server/domain/schemas.py +3 -0
  89. server/domain/state_machine.py +79 -0
  90. server/domain/topic_ack_constants.py +9 -0
  91. server/main.py +148 -0
  92. server/scripts/__init__.py +0 -0
  93. server/scripts/migrate_notification_unique.py +231 -0
  94. server/scripts/purge_audit_pollution.py +116 -0
  95. server/services/__init__.py +0 -0
  96. server/services/_lookups.py +26 -0
  97. server/services/acceptance_service.py +90 -0
  98. server/services/action_item_migration_service.py +190 -0
  99. server/services/action_item_service.py +200 -0
  100. server/services/agent_work_service.py +405 -0
  101. server/services/archive_lint_service.py +156 -0
  102. server/services/audit_service.py +457 -0
  103. server/services/auth.py +66 -0
  104. server/services/comment_service.py +173 -0
  105. server/services/errors.py +65 -0
  106. server/services/escalation_resolver.py +248 -0
  107. server/services/evidence_service.py +88 -0
  108. server/services/experiment_capabilities_service.py +277 -0
  109. server/services/inbound_event_service.py +111 -0
  110. server/services/lock_service.py +273 -0
  111. server/services/log_service.py +202 -0
  112. server/services/mention_service.py +730 -0
  113. server/services/notification_service.py +939 -0
  114. server/services/notification_stream.py +138 -0
  115. server/services/permissions.py +147 -0
  116. server/services/persona_activity_service.py +108 -0
  117. server/services/phase_owner_resolver.py +95 -0
  118. server/services/phase_service.py +381 -0
  119. server/services/plan_marker_service.py +235 -0
  120. server/services/plan_service.py +186 -0
  121. server/services/platform_feedback_service.py +114 -0
  122. server/services/project_service.py +534 -0
  123. server/services/project_status_service.py +132 -0
  124. server/services/review_service.py +707 -0
  125. server/services/secret_encryption.py +97 -0
  126. server/services/similarity_service.py +119 -0
  127. server/services/sse_event_schemas.py +17 -0
  128. server/services/status_service.py +68 -0
  129. server/services/template_service.py +134 -0
  130. server/services/text_utils.py +19 -0
  131. server/services/thread_activity.py +180 -0
  132. server/services/todo_persona_filter.py +73 -0
  133. server/services/todo_service.py +604 -0
  134. server/services/topic_ack_service.py +312 -0
  135. server/services/topic_action_item_ops.py +538 -0
  136. server/services/topic_comment_kind.py +14 -0
  137. server/services/topic_comment_service.py +237 -0
  138. server/services/topic_helpers.py +32 -0
  139. server/services/topic_lifecycle_service.py +478 -0
  140. server/services/topic_progress_service.py +40 -0
  141. server/services/topic_resolve_service.py +234 -0
  142. server/services/topic_service.py +102 -0
  143. server/services/topic_work_item_service.py +570 -0
  144. server/services/webhook_service.py +273 -0
@@ -0,0 +1,939 @@
1
+ import uuid
2
+ from datetime import UTC, datetime
3
+ from typing import Any, cast
4
+
5
+ from map_types.enums import ExperimentPhase, NotificationCategory, NotificationFingerprintVersion
6
+ from sqlalchemy import event, func, or_, select
7
+ from sqlalchemy.dialects.postgresql import insert as pg_insert
8
+ from sqlalchemy.dialects.sqlite import insert as sqlite_insert
9
+ from sqlalchemy.orm import Session
10
+
11
+ from server.domain.models import Agent, AgentRole, Experiment, ExperimentLog, Notification, TopicActionItem
12
+ from server.services import notification_stream
13
+ from server.services.errors import ForbiddenError, NotFoundError
14
+
15
+ # Phase 2 D2: persona agent names used by emit_kind to resolve wake-kind
16
+ # recipients. These are the canonical MAP persona agents bound to a project;
17
+ # the wake kinds (pending_review / pending_result_review / topic_lifecycle /
18
+ # etc.) target one or more of these so the waker can differentiate lifecycle
19
+ # events from generic notifications. Falls back to no recipients if a project
20
+ # has not yet bound a given persona (early onboarding is graceful).
21
+ PERSONA_AGENT_NAMES: dict[str, str] = {
22
+ "host": "multi-agent-platform-host",
23
+ "participant": "multi-agent-platform-participant",
24
+ "reviewer": "multi-agent-platform-reviewer",
25
+ }
26
+
27
+ """Wakeable 事件显式白名单——v0.9 用以替代显式 runtime feature flag。
28
+
29
+ 等价性论证(与 topic d0df651c resolve decision 一致):
30
+ - ``NotificationCategory.digest`` 是 schema 默认值,等价于「feature flag off」(不触发 waker)
31
+ - 仅本集合显式列出的事件走 ``wakeable``,等价于「feature flag 显式开启」
32
+ - 不启用 ``system.runtime_attention``(schema 占位),等价于「feature flag 不引入新 wakeable 入口」
33
+
34
+ 新增 wakeable 事件必须在此集合内显式声明并经过代码评审,避免 schema/runtime config 双源漂移。
35
+ """
36
+
37
+ WAKEABLE_NOTIFICATION_EVENTS: set[str] = {
38
+ "experiment.lifecycle.withdrawn",
39
+ "experiment.lifecycle.cancelled",
40
+ "experiment.lock.no_progress",
41
+ "review_item.status_changed",
42
+ "system.runtime_attention",
43
+ "topic.lifecycle.closed",
44
+ "topic.lifecycle.reopened",
45
+ "action_item.wake_sent",
46
+ "action_item.stale",
47
+ }
48
+
49
+
50
+ def _emit_created(
51
+ recipient_ids: list[uuid.UUID],
52
+ notification_ids: list[uuid.UUID],
53
+ *,
54
+ event: str,
55
+ categories: list[NotificationCategory] | None = None,
56
+ wake_versions: list[int] | None = None,
57
+ fingerprint_versions: list[NotificationFingerprintVersion] | None = None,
58
+ ) -> None:
59
+ """Publish ``notification.created`` SSE frames for wakeable notifications.
60
+
61
+ v0.9 PRD §7.2: the waker drops digest notifications at the SSE frame layer,
62
+ so digest rows must NOT trigger a publish here. ``enqueue_for_agents`` and
63
+ ``enqueue_from_event`` pre-filter the recipient list to wakeable rows
64
+ before calling us; ``categories`` is kept parallel so SSE subscribers can
65
+ see which category they received without a second round-trip.
66
+ """
67
+ if categories is None:
68
+ categories = [NotificationCategory.wakeable] * len(notification_ids)
69
+ if wake_versions is None:
70
+ wake_versions = [1] * len(notification_ids)
71
+ if fingerprint_versions is None:
72
+ fingerprint_versions = [NotificationFingerprintVersion.v2] * len(notification_ids)
73
+ for recipient_id, notification_id, category, wake_version, fp_version in zip(
74
+ recipient_ids,
75
+ notification_ids,
76
+ categories,
77
+ wake_versions,
78
+ fingerprint_versions,
79
+ strict=True,
80
+ ):
81
+ if category != NotificationCategory.wakeable:
82
+ continue
83
+ notification_stream.publish(
84
+ recipient_id,
85
+ {
86
+ "type": "notification.created",
87
+ "event": event,
88
+ "notification_id": str(notification_id),
89
+ "category": category.value,
90
+ "wake_version": wake_version,
91
+ "fingerprint_version": fp_version.value,
92
+ },
93
+ )
94
+
95
+
96
+ _PENDING_SSE_KEY = "map_pending_notification_created_sse"
97
+ _PendingSseFrame = tuple[
98
+ list[uuid.UUID],
99
+ list[uuid.UUID],
100
+ str,
101
+ list[NotificationCategory],
102
+ list[int],
103
+ list[NotificationFingerprintVersion],
104
+ ]
105
+
106
+
107
+ def _queue_created_after_commit(
108
+ db: Session,
109
+ recipient_ids: list[uuid.UUID],
110
+ notification_ids: list[uuid.UUID],
111
+ *,
112
+ event: str,
113
+ categories: list[NotificationCategory],
114
+ wake_versions: list[int],
115
+ fingerprint_versions: list[NotificationFingerprintVersion],
116
+ ) -> None:
117
+ """Publish notification SSE frames only after the DB transaction commits."""
118
+ if not notification_ids:
119
+ return
120
+ pending = db.info.setdefault(_PENDING_SSE_KEY, [])
121
+ pending.append(
122
+ (
123
+ list(recipient_ids),
124
+ list(notification_ids),
125
+ event,
126
+ list(categories),
127
+ list(wake_versions),
128
+ list(fingerprint_versions),
129
+ )
130
+ )
131
+
132
+
133
+ @event.listens_for(Session, "after_commit")
134
+ def _publish_pending_created_after_commit(db: Session) -> None:
135
+ pending: list[_PendingSseFrame] = db.info.pop(_PENDING_SSE_KEY, [])
136
+ for (
137
+ recipient_ids,
138
+ notification_ids,
139
+ event_name,
140
+ categories,
141
+ wake_versions,
142
+ fingerprint_versions,
143
+ ) in pending:
144
+ _emit_created(
145
+ recipient_ids,
146
+ notification_ids,
147
+ event=event_name,
148
+ categories=categories,
149
+ wake_versions=wake_versions,
150
+ fingerprint_versions=fingerprint_versions,
151
+ )
152
+
153
+
154
+ @event.listens_for(Session, "after_rollback")
155
+ def _discard_pending_created_after_rollback(db: Session) -> None:
156
+ db.info.pop(_PENDING_SSE_KEY, None)
157
+
158
+
159
+ def _resolve_persona_agent_ids(
160
+ db: Session, project_id: uuid.UUID, personas: list[str]
161
+ ) -> list[uuid.UUID]:
162
+ """Resolve persona names to Agent.id within a project.
163
+
164
+ Returns an empty list if no persona matches (e.g. project hasn't bound
165
+ that persona yet) so callers can treat it as a no-op rather than a 500.
166
+ """
167
+ names = [PERSONA_AGENT_NAMES[p] for p in personas if p in PERSONA_AGENT_NAMES]
168
+ if not names:
169
+ return []
170
+ rows = db.scalars(
171
+ select(Agent).where(Agent.name.in_(names), Agent.project_id == project_id)
172
+ ).all()
173
+ return [agent.id for agent in rows]
174
+
175
+
176
+ def classify(event: str, *, wakeable: bool | None = None) -> NotificationCategory:
177
+ """Single public entry point for category decisions.
178
+
179
+ Per topic d0df651c Round 1 (1b) hard rule: API route / waker / Web MUST NOT
180
+ carry any category decision logic — every notification row's ``category``
181
+ value must come through this function. The explicit ``wakeable`` kwarg is
182
+ reserved for the wakeable-only ``action_item.*`` / ``system.runtime_attention``
183
+ paths where the caller has authoritative knowledge (see
184
+ ``notify_owner_action_item_wake`` etc.).
185
+ """
186
+ if wakeable is not None:
187
+ return NotificationCategory.wakeable if wakeable else NotificationCategory.digest
188
+ if event in WAKEABLE_NOTIFICATION_EVENTS:
189
+ return NotificationCategory.wakeable
190
+ return NotificationCategory.digest
191
+
192
+
193
+ def _event_category(event: str, *, wakeable: bool | None = None) -> NotificationCategory:
194
+ """Backwards-compat alias; delegates to :func:`classify`."""
195
+ return classify(event, wakeable=wakeable)
196
+
197
+
198
+ def v2_fingerprint(persona: str, notification_id: uuid.UUID, wake_version: int) -> str:
199
+ """Build the canonical v2 fingerprint for a notification wake.
200
+
201
+ Format from topic d0df651c Round 1 §2: ``{persona}:notification:{notification_id}:{wake_version}``.
202
+ The waker's resume gate relies on this exact shape; v1 fingerprints
203
+ (``inbound:<event_id>``) are rejected and counted via
204
+ ``InboundEvent.rejection_count`` (see M30A acceptance §4).
205
+ """
206
+ return f"{persona}:notification:{notification_id}:{wake_version}"
207
+
208
+
209
+ def is_legacy_v1_fingerprint(fingerprint: str) -> bool:
210
+ """Return True if ``fingerprint`` uses the pre-v0.9 ``inbound:<event_id>``
211
+ shape so the host's resume endpoint can route it to the v1 rejection
212
+ counter instead of resuming a session.
213
+
214
+ v0.9 fingerprints are namespace-prefixed (``{persona}:notification:...``
215
+ or ``{persona}:{todo_bucket}:...``) so they never start with ``inbound:``.
216
+ """
217
+ return fingerprint.startswith("inbound:")
218
+
219
+
220
+ def _group_target(
221
+ *,
222
+ event: str,
223
+ target_type: str,
224
+ target_id: uuid.UUID | None,
225
+ payload: dict[str, Any] | None,
226
+ ) -> tuple[str, str]:
227
+ data = payload or {}
228
+ if event == "topic.comment.created" and data.get("topic_id"):
229
+ return "topic", str(data["topic_id"])
230
+ if event == "comment.created" and data.get("experiment_id"):
231
+ return "experiment", str(data["experiment_id"])
232
+ if target_id is not None:
233
+ return target_type, str(target_id)
234
+ for key in ("topic_id", "experiment_id", "id"):
235
+ if data.get(key):
236
+ inferred_type = "topic" if key == "topic_id" else "experiment" if key == "experiment_id" else target_type
237
+ return inferred_type, str(data[key])
238
+ return target_type, "none"
239
+
240
+
241
+ def _group_key(
242
+ *,
243
+ recipient_agent_id: uuid.UUID,
244
+ project_id: uuid.UUID | None,
245
+ event: str,
246
+ target_type: str,
247
+ target_id: uuid.UUID | None,
248
+ payload: dict[str, Any] | None,
249
+ ) -> str:
250
+ group_target_type, group_target_id = _group_target(
251
+ event=event,
252
+ target_type=target_type,
253
+ target_id=target_id,
254
+ payload=payload,
255
+ )
256
+ project = str(project_id) if project_id is not None else "global"
257
+ return f"recipient:{recipient_agent_id}:project:{project}:{group_target_type}:{group_target_id}:{event}"
258
+
259
+
260
+ def _upsert_notification(
261
+ db: Session,
262
+ *,
263
+ recipient_agent_id: uuid.UUID,
264
+ project_id: uuid.UUID | None,
265
+ event: str,
266
+ summary: str,
267
+ target_type: str,
268
+ target_id: uuid.UUID | None,
269
+ payload: dict[str, Any] | None,
270
+ category: NotificationCategory,
271
+ ) -> Notification:
272
+ """Insert-or-merge a notification row.
273
+
274
+ Race experiment (eca0f522) PR2: replaced select-then-update with a
275
+ single ``INSERT ... ON CONFLICT DO UPDATE`` (atomic on both PG and
276
+ SQLite 3.24+). The ``uq_notifications_recipient_group_key``
277
+ constraint is the merge key. PG-only ``event_count``-via-arithmetic
278
+ keeps the counter monotonic without a second round-trip; the wakeable
279
+ ``wake_version`` bump is gated by a CASE on the incoming ``category``
280
+ so digest upserts don't push a new waker fingerprint.
281
+ """
282
+ now = datetime.now(UTC)
283
+ group_key = _group_key(
284
+ recipient_agent_id=recipient_agent_id,
285
+ project_id=project_id,
286
+ event=event,
287
+ target_type=target_type,
288
+ target_id=target_id,
289
+ payload=payload,
290
+ )
291
+ is_wakeable = category == NotificationCategory.wakeable
292
+
293
+ values = {
294
+ "recipient_agent_id": recipient_agent_id,
295
+ "project_id": project_id,
296
+ "event": event,
297
+ "summary": summary,
298
+ "target_type": target_type,
299
+ "target_id": target_id,
300
+ "payload_json": payload,
301
+ "category": category,
302
+ "group_key": group_key,
303
+ "wake_version": 1,
304
+ "fingerprint_version": NotificationFingerprintVersion.v2,
305
+ "event_count": 1,
306
+ "first_event_at": now,
307
+ "last_event_at": now,
308
+ "updated_at": now,
309
+ }
310
+ set_: dict[str, object] = {
311
+ "event": event,
312
+ "summary": summary,
313
+ "target_type": target_type,
314
+ "target_id": target_id,
315
+ "payload_json": payload,
316
+ "category": category,
317
+ "event_count": Notification.event_count + 1,
318
+ "last_event_at": now,
319
+ "updated_at": now,
320
+ "read_at": None,
321
+ # wake_version bump only on wakeable merges; preserves the v0.9
322
+ # monotonicity invariant while keeping digest fingerprints stable.
323
+ "wake_version": Notification.wake_version + 1 if is_wakeable else Notification.wake_version,
324
+ }
325
+
326
+ # SQLite 3.24+ and PG both accept ON CONFLICT DO UPDATE with the same
327
+ # syntax; SQLAlchemy requires the dialect-specific ``Insert`` class.
328
+ dialect_insert: Any = (
329
+ pg_insert
330
+ if db.bind is not None and db.bind.dialect.name == "postgresql"
331
+ else sqlite_insert
332
+ )
333
+ stmt = dialect_insert(Notification).values(**values)
334
+ stmt = stmt.on_conflict_do_update(
335
+ index_elements=["recipient_agent_id", "group_key"],
336
+ set_=set_,
337
+ )
338
+ # ``populate_existing=True`` forces the identity-map cached object to be
339
+ # refreshed from RETURNING. Without it, sessions with
340
+ # ``expire_on_commit=False`` (the test fixture's savepoint pattern) keep
341
+ # stale ``event_count``/``wake_version`` attributes on subsequent merges,
342
+ # even though the DB row is correct — see PR2 tests.
343
+ result = db.execute(
344
+ stmt.returning(Notification),
345
+ execution_options={"populate_existing": True},
346
+ )
347
+ row = result.scalar_one()
348
+ return cast(Notification, row)
349
+
350
+
351
+ def emit_kind(
352
+ db: Session,
353
+ *,
354
+ project_id: uuid.UUID,
355
+ actor_id: uuid.UUID,
356
+ personas: list[str],
357
+ event: str,
358
+ summary: str,
359
+ target_type: str,
360
+ target_id: uuid.UUID,
361
+ payload: dict[str, Any] | None,
362
+ wakeable: bool | None = None,
363
+ commit: bool = True,
364
+ ) -> list[uuid.UUID]:
365
+ """Insert one Notification per persona agent + SSE publish.
366
+
367
+ Phase 2 D2 entry point for kind-specific wake events. Distinct from
368
+ ``enqueue_from_event`` (broadcast to all project agents) — here the
369
+ recipient set is narrowed to persona agents (host / participant /
370
+ reviewer) so the waker can differentiate lifecycle wake kinds from
371
+ the generic ``notification`` bucket.
372
+
373
+ Payload is enriched with ``kind`` (derived from the event name's middle
374
+ segment, e.g. ``topic.lifecycle.closed`` → ``lifecycle``) so the waker
375
+ can build a kind-specific fingerprint without re-deriving from the
376
+ Notification row.
377
+ """
378
+ recipient_ids = _resolve_persona_agent_ids(db, project_id, personas)
379
+ if not recipient_ids:
380
+ return []
381
+ enriched = dict(payload or {})
382
+ parts = event.split(".")
383
+ if len(parts) >= 2 and "kind" not in enriched:
384
+ enriched["kind"] = ".".join(parts[:-1])
385
+ return enqueue_for_agents(
386
+ db,
387
+ recipient_agent_ids=recipient_ids,
388
+ project_id=project_id,
389
+ actor_id=actor_id,
390
+ event=event,
391
+ summary=summary,
392
+ target_type=target_type,
393
+ target_id=target_id,
394
+ payload=enriched,
395
+ wakeable=wakeable,
396
+ commit=commit,
397
+ )
398
+
399
+
400
+ def _recipients_for_project(db: Session, project_id: uuid.UUID | None, exclude_agent_id: uuid.UUID) -> list[Agent]:
401
+ if project_id is None:
402
+ return []
403
+ stmt = select(Agent).where(
404
+ or_(
405
+ Agent.project_id == project_id,
406
+ Agent.role == AgentRole.admin,
407
+ )
408
+ )
409
+ return [agent for agent in db.scalars(stmt) if agent.id != exclude_agent_id]
410
+
411
+
412
+ def enqueue_from_event(
413
+ db: Session,
414
+ *,
415
+ project_id: uuid.UUID | None,
416
+ actor_id: uuid.UUID,
417
+ event: str,
418
+ summary: str,
419
+ target_type: str,
420
+ target_id: uuid.UUID | None,
421
+ payload: dict[str, Any] | None,
422
+ exclude_recipient_ids: set[uuid.UUID] | None = None,
423
+ wakeable: bool | None = None,
424
+ commit: bool = True,
425
+ ) -> list[uuid.UUID]:
426
+ """Write in-app notifications for project agents (and admins), excluding the actor.
427
+
428
+ ``commit=False`` 时只 ``flush`` 不 ``commit``——供需要把通知写入与调用方
429
+ 自身业务变更绑在同一事务内的场景。调用方负责最终 commit。
430
+ """
431
+ skip = exclude_recipient_ids or set()
432
+ recipients = [
433
+ agent
434
+ for agent in _recipients_for_project(db, project_id, actor_id)
435
+ if agent.id not in skip
436
+ ]
437
+ if not recipients:
438
+ return []
439
+
440
+ notification_ids: list[uuid.UUID] = []
441
+ recipient_ids: list[uuid.UUID] = []
442
+ categories: list[NotificationCategory] = []
443
+ wake_versions: list[int] = []
444
+ fingerprint_versions: list[NotificationFingerprintVersion] = []
445
+ category = classify(event, wakeable=wakeable)
446
+ for recipient in recipients:
447
+ notification = _upsert_notification(
448
+ db,
449
+ recipient_agent_id=recipient.id,
450
+ project_id=project_id,
451
+ summary=summary,
452
+ event=event,
453
+ target_type=target_type,
454
+ target_id=target_id,
455
+ payload=payload,
456
+ category=category,
457
+ )
458
+ notification_ids.append(notification.id)
459
+ recipient_ids.append(recipient.id)
460
+ categories.append(notification.category)
461
+ wake_versions.append(notification.wake_version)
462
+ fingerprint_versions.append(notification.fingerprint_version)
463
+ _queue_created_after_commit(
464
+ db,
465
+ recipient_ids,
466
+ notification_ids,
467
+ event=event,
468
+ categories=categories,
469
+ wake_versions=wake_versions,
470
+ fingerprint_versions=fingerprint_versions,
471
+ )
472
+ if commit:
473
+ db.commit()
474
+ else:
475
+ db.flush()
476
+ return notification_ids
477
+
478
+
479
+ def notify_topic_comment_created(
480
+ db: Session,
481
+ *,
482
+ project_id: uuid.UUID,
483
+ actor_id: uuid.UUID,
484
+ creator_agent_id: uuid.UUID,
485
+ target_id: uuid.UUID,
486
+ payload: dict[str, Any] | None,
487
+ ) -> list[uuid.UUID]:
488
+ """Broadcast to project agents; host gets a separate directed notification (no duplicate)."""
489
+ event = "topic.comment.created"
490
+ base_payload = dict(payload or {})
491
+ notification_ids = enqueue_from_event(
492
+ db,
493
+ project_id=project_id,
494
+ actor_id=actor_id,
495
+ event=event,
496
+ summary="话题新评论",
497
+ target_type="topic_comment",
498
+ target_id=target_id,
499
+ payload=base_payload,
500
+ exclude_recipient_ids={creator_agent_id} if creator_agent_id != actor_id else None,
501
+ )
502
+ if creator_agent_id != actor_id:
503
+ notification_ids.extend(
504
+ enqueue_for_agents(
505
+ db,
506
+ recipient_agent_ids=[creator_agent_id],
507
+ project_id=project_id,
508
+ actor_id=actor_id,
509
+ event=event,
510
+ summary="【主持】话题新评论待回复",
511
+ target_type="topic_comment",
512
+ target_id=target_id,
513
+ payload={**base_payload, "host_directed": True},
514
+ )
515
+ )
516
+ return notification_ids
517
+
518
+
519
+ def enqueue_for_agents(
520
+ db: Session,
521
+ *,
522
+ recipient_agent_ids: list[uuid.UUID],
523
+ project_id: uuid.UUID | None,
524
+ actor_id: uuid.UUID,
525
+ event: str,
526
+ summary: str,
527
+ target_type: str,
528
+ target_id: uuid.UUID | None,
529
+ payload: dict[str, Any] | None,
530
+ wakeable: bool | None = None,
531
+ exclude_actor: bool = True,
532
+ commit: bool = True,
533
+ ) -> list[uuid.UUID]:
534
+ """Write in-app notifications for specific agents (e.g. @mentions).
535
+
536
+ ``commit=False`` 时只 ``flush``——供需要把通知与调用方业务变更绑在同一
537
+ 事务内的场景(如 mention 处理)。调用方负责最终 commit。
538
+ """
539
+ notification_ids: list[uuid.UUID] = []
540
+ recipient_ids: list[uuid.UUID] = []
541
+ categories: list[NotificationCategory] = []
542
+ wake_versions: list[int] = []
543
+ fingerprint_versions: list[NotificationFingerprintVersion] = []
544
+ category = classify(event, wakeable=wakeable)
545
+ for recipient_id in recipient_agent_ids:
546
+ if exclude_actor and recipient_id == actor_id:
547
+ continue
548
+ notification = _upsert_notification(
549
+ db,
550
+ recipient_agent_id=recipient_id,
551
+ project_id=project_id,
552
+ event=event,
553
+ summary=summary,
554
+ target_type=target_type,
555
+ target_id=target_id,
556
+ payload=payload,
557
+ category=category,
558
+ )
559
+ notification_ids.append(notification.id)
560
+ recipient_ids.append(recipient_id)
561
+ categories.append(notification.category)
562
+ wake_versions.append(notification.wake_version)
563
+ fingerprint_versions.append(notification.fingerprint_version)
564
+ if notification_ids:
565
+ _queue_created_after_commit(
566
+ db,
567
+ recipient_ids,
568
+ notification_ids,
569
+ event=event,
570
+ categories=categories,
571
+ wake_versions=wake_versions,
572
+ fingerprint_versions=fingerprint_versions,
573
+ )
574
+ if commit:
575
+ db.commit()
576
+ else:
577
+ db.flush()
578
+ return notification_ids
579
+
580
+
581
+ def list_for_agent(
582
+ db: Session,
583
+ agent: Agent,
584
+ *,
585
+ unread_only: bool = False,
586
+ category: NotificationCategory | None = None,
587
+ target_type: str | None = None,
588
+ limit: int = 50,
589
+ offset: int = 0,
590
+ ) -> tuple[list[Notification], int]:
591
+ filters = [Notification.recipient_agent_id == agent.id]
592
+ if unread_only:
593
+ filters.append(Notification.read_at.is_(None))
594
+ if category is not None:
595
+ filters.append(Notification.category == category)
596
+ if target_type is not None:
597
+ filters.append(Notification.target_type == target_type)
598
+ total = db.scalar(select(func.count()).select_from(Notification).where(*filters)) or 0
599
+ rows = list(
600
+ db.scalars(
601
+ select(Notification)
602
+ .where(*filters)
603
+ .order_by(Notification.updated_at.desc(), Notification.created_at.desc())
604
+ .offset(offset)
605
+ .limit(min(limit, 200))
606
+ )
607
+ )
608
+ return rows, total
609
+
610
+
611
+ def count_unread(
612
+ db: Session,
613
+ agent: Agent,
614
+ *,
615
+ category: NotificationCategory | None = None,
616
+ target_type: str | None = None,
617
+ ) -> int:
618
+ filters = [Notification.recipient_agent_id == agent.id, Notification.read_at.is_(None)]
619
+ if category is not None:
620
+ filters.append(Notification.category == category)
621
+ if target_type is not None:
622
+ filters.append(Notification.target_type == target_type)
623
+ return (
624
+ db.scalar(
625
+ select(func.count())
626
+ .select_from(Notification)
627
+ .where(*filters)
628
+ )
629
+ or 0
630
+ )
631
+
632
+
633
+ def mark_agent_mentioned_notifications_read_no_commit(
634
+ db: Session,
635
+ *,
636
+ mentions: list[Any],
637
+ now: datetime | None = None,
638
+ ) -> int:
639
+ """Mark ``agent.mentioned`` notifications read when their mention is dismissed."""
640
+ from server.domain.models import Mention
641
+
642
+ if not mentions:
643
+ return 0
644
+ now = now or datetime.now(UTC)
645
+ touched = 0
646
+ for mention in mentions:
647
+ if not isinstance(mention, Mention):
648
+ continue
649
+ rows = list(
650
+ db.scalars(
651
+ select(Notification).where(
652
+ Notification.recipient_agent_id == mention.mentioned_agent_id,
653
+ Notification.event == "agent.mentioned",
654
+ Notification.read_at.is_(None),
655
+ Notification.target_id == mention.source_id,
656
+ )
657
+ )
658
+ )
659
+ for row in rows:
660
+ row.read_at = now
661
+ touched += 1
662
+ return touched
663
+
664
+
665
+ def mark_read(db: Session, agent: Agent, notification_id: uuid.UUID) -> Notification:
666
+ notification = db.get(Notification, notification_id)
667
+ if notification is None:
668
+ raise NotFoundError("Notification not found")
669
+ if notification.recipient_agent_id != agent.id:
670
+ raise ForbiddenError("Cannot mark another agent's notification")
671
+ if notification.read_at is None:
672
+ notification.read_at = datetime.now(UTC)
673
+ db.commit()
674
+ db.refresh(notification)
675
+ return notification
676
+
677
+
678
+ def mark_all_read(db: Session, agent: Agent) -> int:
679
+ now = datetime.now(UTC)
680
+ rows = list(
681
+ db.scalars(
682
+ select(Notification).where(
683
+ Notification.recipient_agent_id == agent.id,
684
+ Notification.read_at.is_(None),
685
+ )
686
+ )
687
+ )
688
+ for row in rows:
689
+ row.read_at = now
690
+ if rows:
691
+ db.commit()
692
+ return len(rows)
693
+
694
+
695
+ # ---------------------------------------------------------------------------
696
+ # action_item wake / stale notification helpers (experiment B, plan §3 #2 #3)
697
+ # ---------------------------------------------------------------------------
698
+ #
699
+ # The runtime-waker calls ``mark_wake_sent`` / ``mark_stale`` via the API,
700
+ # but those endpoints only mutate the action_item + write the audit row.
701
+ # They do NOT post any in-app notification — by design, the wakeable
702
+ # signals the assignee / admin sees live here, alongside the audit row.
703
+ #
704
+ # Why split this out instead of folding into the endpoint wrappers:
705
+ # - Keeps ``action_item_service`` / ``topic_service`` free of notification
706
+ # service imports (service layering: audit + state in service, fan-out in
707
+ # notification service).
708
+ # - Makes the actor / recipient policy explicit per plan section §3 #2 #3:
709
+ # wake goes to the assignee; stale goes to admins; creator is intentionally
710
+ # skipped (audit-only path, see I6).
711
+ # ---------------------------------------------------------------------------
712
+
713
+
714
+ def _resolve_admin_agent_ids(
715
+ db: Session,
716
+ *,
717
+ exclude_agent_id: uuid.UUID | None,
718
+ ) -> list[uuid.UUID]:
719
+ """Return the list of admin Agent ids in the system.
720
+
721
+ Admin role is global in this codebase (no per-project scoping), so a
722
+ single query against ``Agent.role == AgentRole.admin`` is sufficient.
723
+ ``exclude_agent_id`` filters the assignee out so the owner does not get
724
+ a duplicate notification through the admin path when the assignee is
725
+ themselves an admin.
726
+ """
727
+ rows = db.scalars(select(Agent.id).where(Agent.role == AgentRole.admin)).all()
728
+ if exclude_agent_id is None:
729
+ return list(rows)
730
+ return [row for row in rows if row != exclude_agent_id]
731
+
732
+
733
+ def notify_owner_action_item_wake(
734
+ db: Session,
735
+ *,
736
+ action_item: TopicActionItem,
737
+ ) -> list[uuid.UUID]:
738
+ """Send a wakeable notification to the assignee after a wake bump.
739
+
740
+ Plan §3 wake path: the runtime-waker wakes the owner at T+24h / T+72h /
741
+ every 7d up to 4 times. Each bump surfaces here as an in-app notification
742
+ so the assignee sees a wakeable signal in their todos (independent of
743
+ whether they happen to be looking at the action_item list right now).
744
+ Falls through silently when the item has no owner (shouldn't happen —
745
+ ``mark_wake_sent`` already rejected unassigned items upstream — but the
746
+ guard is here for defence-in-depth).
747
+ """
748
+ if action_item.owner_agent_id is None:
749
+ return []
750
+ payload: dict[str, object] = {
751
+ "action_item_id": str(action_item.id),
752
+ "topic_id": str(action_item.topic_id),
753
+ "wake_count": action_item.wake_count,
754
+ }
755
+ if action_item.last_woken_at is not None:
756
+ payload["last_woken_at"] = action_item.last_woken_at.isoformat()
757
+ return enqueue_for_agents(
758
+ db,
759
+ recipient_agent_ids=[action_item.owner_agent_id],
760
+ project_id=action_item.project_id,
761
+ actor_id=action_item.owner_agent_id,
762
+ event="action_item.wake_sent",
763
+ summary=f"待办提醒:{action_item.title}(第 {action_item.wake_count} 次)",
764
+ target_type="topic_action_item",
765
+ target_id=action_item.id,
766
+ payload=payload,
767
+ wakeable=True,
768
+ exclude_actor=False,
769
+ )
770
+
771
+
772
+ def notify_admin_action_item_stale(
773
+ db: Session,
774
+ *,
775
+ action_item: TopicActionItem,
776
+ ) -> list[uuid.UUID]:
777
+ """Send a wakeable notification to every admin after a stale transition.
778
+
779
+ Plan §3 #2 (3c admin 优先): admin is the most stable收口 because the
780
+ creator may have been deactivated / changed roles / left the project.
781
+ The owner is explicitly excluded — if the owner happens to be an admin
782
+ they'd otherwise get a duplicate notification through both paths, and
783
+ the audit row + plan §3 #3 (3c creator audit-only) policy says admin
784
+ notification should not double as the owner's channel.
785
+
786
+ Plan §3 #3 (creator audit-only): the creator is intentionally NOT in
787
+ this recipient set. They can find the stale event via ``action_items``
788
+ or the audit history, but they do NOT get a wake notification (per I6).
789
+ """
790
+ admin_ids = _resolve_admin_agent_ids(
791
+ db, exclude_agent_id=action_item.owner_agent_id
792
+ )
793
+ if not admin_ids:
794
+ return []
795
+ payload: dict[str, object] = {
796
+ "action_item_id": str(action_item.id),
797
+ "topic_id": str(action_item.topic_id),
798
+ "wake_count": action_item.wake_count,
799
+ }
800
+ if action_item.stale_at is not None:
801
+ payload["stale_at"] = action_item.stale_at.isoformat()
802
+ # actor_id is unused because exclude_actor=False — every admin in the
803
+ # resolved list gets the notification regardless. We still need to pass
804
+ # a UUID-shaped value to satisfy the signature, so use the first admin
805
+ # or the owner as a stable label.
806
+ actor_id = action_item.owner_agent_id or admin_ids[0]
807
+ return enqueue_for_agents(
808
+ db,
809
+ recipient_agent_ids=admin_ids,
810
+ project_id=action_item.project_id,
811
+ actor_id=actor_id,
812
+ event="action_item.stale",
813
+ summary=f"行动项已 stale:{action_item.title}",
814
+ target_type="topic_action_item",
815
+ target_id=action_item.id,
816
+ payload=payload,
817
+ wakeable=True,
818
+ exclude_actor=False,
819
+ )
820
+
821
+
822
+ def _aware(value: datetime | None) -> datetime | None:
823
+ if value is None:
824
+ return None
825
+ if value.tzinfo is None:
826
+ return value.replace(tzinfo=UTC)
827
+ return value
828
+
829
+
830
+ def _latest_experiment_log_at(db: Session, experiment_id: uuid.UUID) -> datetime | None:
831
+ return db.scalar(
832
+ select(func.max(ExperimentLog.created_at)).where(ExperimentLog.experiment_id == experiment_id)
833
+ )
834
+
835
+
836
+ def _project_agent_ids(db: Session, project_id: uuid.UUID, *, exclude: set[uuid.UUID]) -> list[uuid.UUID]:
837
+ stmt = select(Agent.id).where(Agent.project_id == project_id)
838
+ if exclude:
839
+ stmt = stmt.where(Agent.id.notin_(exclude))
840
+ rows = db.scalars(stmt).all()
841
+ return list(rows)
842
+
843
+
844
+ def notify_stalled_experiment_locks(
845
+ db: Session,
846
+ *,
847
+ project_id: uuid.UUID | None = None,
848
+ now: datetime | None = None,
849
+ progress_threshold: float = 0.5,
850
+ wake_threshold: float = 0.8,
851
+ commit: bool = True,
852
+ ) -> list[uuid.UUID]:
853
+ """Notify when a running experiment holds the execution lock without progress.
854
+
855
+ D experiment slice:
856
+ - holder/host gets a wakeable notification once the lock has consumed at
857
+ least ``wake_threshold`` of its TTL without a new execution log.
858
+ - project members get a digest notification once the lock has consumed at
859
+ least ``progress_threshold`` of its TTL without a new execution log.
860
+
861
+ Notification grouping keeps repeated scans from creating many rows; wakeable
862
+ upserts still bump ``wake_version`` so waker fingerprints can advance.
863
+ """
864
+ reference = now or datetime.now(UTC)
865
+ if reference.tzinfo is None:
866
+ reference = reference.replace(tzinfo=UTC)
867
+ emitted: list[uuid.UUID] = []
868
+ filters = [
869
+ Experiment.phase == ExperimentPhase.running,
870
+ Experiment.lock_holder_experiment_id.is_not(None),
871
+ Experiment.lock_acquired_at.is_not(None),
872
+ ]
873
+ if project_id is not None:
874
+ filters.append(Experiment.project_id == project_id)
875
+ experiments = db.scalars(select(Experiment).where(*filters)).all()
876
+ for experiment in experiments:
877
+ acquired_at = _aware(experiment.lock_acquired_at)
878
+ if acquired_at is None:
879
+ continue
880
+ ttl_seconds = int(experiment.lock_ttl_seconds or 0)
881
+ if ttl_seconds <= 0:
882
+ continue
883
+ elapsed = max(0.0, (reference - acquired_at).total_seconds())
884
+ ratio = elapsed / ttl_seconds
885
+ if ratio < progress_threshold:
886
+ continue
887
+ last_log_at = _aware(_latest_experiment_log_at(db, experiment.id))
888
+ if last_log_at is not None and last_log_at > acquired_at:
889
+ continue
890
+ payload: dict[str, object] = {
891
+ "experiment_id": str(experiment.id),
892
+ "lock_holder_experiment_id": str(experiment.lock_holder_experiment_id),
893
+ "lock_acquired_at": acquired_at.isoformat(),
894
+ "lock_ttl_seconds": ttl_seconds,
895
+ "elapsed_seconds": int(elapsed),
896
+ "threshold_ratio": ratio,
897
+ "last_log_at": last_log_at.isoformat() if last_log_at else None,
898
+ }
899
+ summary = f"实验锁长时间无进展:{experiment.title}"
900
+ holder_ids = [experiment.creator_agent_id]
901
+ if ratio >= wake_threshold:
902
+ emitted.extend(
903
+ enqueue_for_agents(
904
+ db,
905
+ recipient_agent_ids=holder_ids,
906
+ project_id=experiment.project_id,
907
+ actor_id=experiment.creator_agent_id,
908
+ event="experiment.lock.no_progress",
909
+ summary=summary,
910
+ target_type="experiment",
911
+ target_id=experiment.id,
912
+ payload=payload,
913
+ wakeable=True,
914
+ exclude_actor=False,
915
+ commit=False,
916
+ )
917
+ )
918
+ digest_recipients = _project_agent_ids(db, experiment.project_id, exclude=set(holder_ids))
919
+ emitted.extend(
920
+ enqueue_for_agents(
921
+ db,
922
+ recipient_agent_ids=digest_recipients,
923
+ project_id=experiment.project_id,
924
+ actor_id=experiment.creator_agent_id,
925
+ event="experiment.lock.no_progress",
926
+ summary=summary,
927
+ target_type="experiment",
928
+ target_id=experiment.id,
929
+ payload=payload,
930
+ wakeable=False,
931
+ exclude_actor=False,
932
+ commit=False,
933
+ )
934
+ )
935
+ if commit:
936
+ db.commit()
937
+ else:
938
+ db.flush()
939
+ return emitted