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
server/api/agents.py ADDED
@@ -0,0 +1,412 @@
1
+ import uuid
2
+
3
+ from fastapi import APIRouter, Depends, Header, HTTPException, Query, Response, status
4
+ from fastapi.responses import StreamingResponse
5
+ from map_types.enums import NotificationCategory
6
+ from sqlalchemy import select
7
+ from sqlalchemy.orm import Session
8
+
9
+ from server.api.deps import get_current_agent, get_optional_current_agent
10
+ from server.db.session import get_db
11
+ from server.domain.models import Agent, AgentRole, Experiment, Project
12
+ from server.domain.schemas import (
13
+ AgentCreate,
14
+ AgentCreateResponse,
15
+ AgentRead,
16
+ AgentWorkRead,
17
+ AgentWorkSummaryRead,
18
+ DismissAllMentionsResultRead,
19
+ DismissMentionResultRead,
20
+ EscalationTargetRead,
21
+ InboundEventCreate,
22
+ InboundEventRead,
23
+ InboundEventRecordResult,
24
+ NotificationListRead,
25
+ NotificationRead,
26
+ TodoRead,
27
+ TopicProgressListRead,
28
+ TopicReadCursorRead,
29
+ )
30
+ from server.services import (
31
+ agent_work_service,
32
+ inbound_event_service,
33
+ mention_service,
34
+ notification_service,
35
+ todo_service,
36
+ topic_service,
37
+ )
38
+ from server.services import auth as auth_service
39
+ from server.services import permissions as perm
40
+ from server.services import project_service as svc
41
+ from server.services.errors import BadRequestError, ConflictError
42
+ from server.services.escalation_resolver import (
43
+ escalation_label,
44
+ resolve_escalation_target_with_tier,
45
+ )
46
+ from server.services.notification_stream import notification_sse_response
47
+
48
+ agents_router = APIRouter(prefix="/agents", tags=["agents"])
49
+
50
+
51
+ @agents_router.get("", response_model=list[AgentRead])
52
+ def list_agents(
53
+ role: AgentRole | None = Query(default=None),
54
+ project_id: uuid.UUID | None = Query(default=None),
55
+ db: Session = Depends(get_db),
56
+ agent: Agent = Depends(get_current_agent),
57
+ ) -> list[AgentRead]:
58
+ """List agents visible to the caller.
59
+
60
+ - Admins see every agent (with optional filters).
61
+ - Project-bound agents see admins and agents within their own project.
62
+ """
63
+ stmt = select(Agent).order_by(Agent.created_at.asc())
64
+ if not perm.is_admin(agent):
65
+ stmt = stmt.where(
66
+ (Agent.role == AgentRole.admin) | (Agent.project_id == agent.project_id)
67
+ )
68
+ if role is not None:
69
+ stmt = stmt.where(Agent.role == role)
70
+ if project_id is not None:
71
+ stmt = stmt.where(Agent.project_id == project_id)
72
+
73
+ agents = list(db.scalars(stmt))
74
+ project_keys: dict[uuid.UUID, str] = {}
75
+ project_ids = {a.project_id for a in agents if a.project_id is not None}
76
+ if project_ids:
77
+ rows = db.scalars(select(Project).where(Project.id.in_(project_ids))).all()
78
+ project_keys = {row.id: row.project_key for row in rows}
79
+
80
+ return [
81
+ AgentRead(
82
+ id=a.id,
83
+ name=a.name,
84
+ role=a.role,
85
+ project_id=a.project_id,
86
+ project_key=project_keys.get(a.project_id) if a.project_id else None,
87
+ created_at=a.created_at,
88
+ )
89
+ for a in agents
90
+ ]
91
+
92
+
93
+ @agents_router.post("", response_model=AgentCreateResponse, status_code=status.HTTP_201_CREATED)
94
+ def register_agent(
95
+ payload: AgentCreate,
96
+ db: Session = Depends(get_db),
97
+ actor: Agent | None = Depends(get_optional_current_agent),
98
+ ) -> AgentCreateResponse:
99
+ perm.ensure_can_register_agent(db, actor, payload.role)
100
+
101
+ existing = db.query(Agent).filter(Agent.name == payload.name).first()
102
+ if existing:
103
+ raise ConflictError("Agent name already exists")
104
+
105
+ resolved_project_id: uuid.UUID | None = None
106
+ if payload.role == AgentRole.agent:
107
+ if payload.project_id is not None:
108
+ resolved_project_id = svc.get_project(db, payload.project_id).id
109
+ elif payload.project_key is not None:
110
+ resolved_project_id = svc.get_project_by_key(db, payload.project_key).id
111
+ else:
112
+ raise BadRequestError(
113
+ "project_id or project_key is required for role=agent"
114
+ )
115
+
116
+ try:
117
+ agent, token = auth_service.create_agent(
118
+ db, payload.name, payload.role, project_id=resolved_project_id
119
+ )
120
+ except ValueError as exc:
121
+ raise BadRequestError(str(exc)) from exc
122
+ return AgentCreateResponse(
123
+ id=agent.id,
124
+ name=agent.name,
125
+ role=agent.role,
126
+ project_id=agent.project_id,
127
+ created_at=agent.created_at,
128
+ api_token=token,
129
+ )
130
+
131
+
132
+ @agents_router.get("/me", response_model=AgentRead)
133
+ def get_me(
134
+ agent: Agent = Depends(get_current_agent),
135
+ db: Session = Depends(get_db),
136
+ ) -> AgentRead:
137
+ project_key: str | None = None
138
+ if agent.project_id is not None:
139
+ project = svc.get_project(db, agent.project_id)
140
+ project_key = project.project_key
141
+ return AgentRead(
142
+ id=agent.id,
143
+ name=agent.name,
144
+ role=agent.role,
145
+ project_id=agent.project_id,
146
+ project_key=project_key,
147
+ created_at=agent.created_at,
148
+ )
149
+
150
+
151
+ @agents_router.get("/me/escalation-target", response_model=EscalationTargetRead)
152
+ def get_my_escalation_target(
153
+ experiment_id: uuid.UUID | None = Query(default=None),
154
+ agent: Agent = Depends(get_current_agent),
155
+ db: Session = Depends(get_db),
156
+ ) -> EscalationTargetRead:
157
+ """Resolve the escalation contact for a STATE_MACHINE.* error (I1(c)).
158
+
159
+ The CLI calls this endpoint whenever it gets a ``MAPHTTPError`` with
160
+ a ``STATE_MACHINE_*`` ``error_code`` so it can append an
161
+ ``Escalation: @<name>`` line to the error output. The server runs
162
+ the same 3-tier rule documented on ``experiments.escalation_target_agent_id``
163
+ (override → caller → same-role → admin) and returns the chosen agent
164
+ + the tier that picked it.
165
+
166
+ When ``experiment_id`` is omitted, only the caller + role-based rules
167
+ apply (no override available).
168
+ """
169
+ experiment = None
170
+ if experiment_id is not None:
171
+ experiment = db.get(Experiment, experiment_id)
172
+ # Tolerate missing experiment — fall through to the role-based path
173
+ # so the caller still gets a useful escalation contact.
174
+ target_id, tier = resolve_escalation_target_with_tier(
175
+ db,
176
+ experiment=experiment,
177
+ caller_agent_id=agent.id,
178
+ project_id=agent.project_id,
179
+ )
180
+ label = escalation_label(db, escalation_target_id=target_id)
181
+ return EscalationTargetRead(
182
+ experiment_id=experiment_id,
183
+ escalation_target_id=target_id,
184
+ escalation_label=label,
185
+ tier=tier,
186
+ )
187
+
188
+
189
+ @agents_router.get("/me/todos", response_model=TodoRead)
190
+ def get_my_todos(
191
+ include_all_partitions: bool = Query(default=False),
192
+ agent: Agent = Depends(get_current_agent),
193
+ db: Session = Depends(get_db),
194
+ ) -> TodoRead:
195
+ return todo_service.get_todos(
196
+ db, agent, include_all_partitions=include_all_partitions
197
+ )
198
+
199
+
200
+ @agents_router.get("/me/work", response_model=AgentWorkRead)
201
+ def get_my_work(
202
+ notification_limit: int = Query(default=50, ge=1, le=200),
203
+ notification_category: NotificationCategory | str | None = Query(default="wakeable"),
204
+ agent: Agent = Depends(get_current_agent),
205
+ db: Session = Depends(get_db),
206
+ ) -> AgentWorkRead:
207
+ """Unified work snapshot: whoami + topic-progress + todos + unread notifications."""
208
+ normalized_category: NotificationCategory | None
209
+ if notification_category is None or notification_category == "all":
210
+ normalized_category = None
211
+ elif isinstance(notification_category, NotificationCategory):
212
+ normalized_category = notification_category
213
+ else:
214
+ try:
215
+ normalized_category = NotificationCategory(notification_category)
216
+ except ValueError as exc:
217
+ raise HTTPException(
218
+ status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
219
+ detail="notification_category must be wakeable, digest, or all",
220
+ ) from exc
221
+ return agent_work_service.get_agent_work(
222
+ db,
223
+ agent,
224
+ notification_limit=notification_limit,
225
+ notification_category=normalized_category,
226
+ )
227
+
228
+
229
+ @agents_router.get("/me/work/summary", response_model=AgentWorkSummaryRead)
230
+ def get_my_work_summary(
231
+ include_all_personas: bool = Query(default=False),
232
+ topics_limit: int = Query(default=10, ge=1, le=100),
233
+ experiments_limit: int = Query(default=5, ge=1, le=50),
234
+ agent: Agent = Depends(get_current_agent),
235
+ db: Session = Depends(get_db),
236
+ ) -> AgentWorkSummaryRead:
237
+ """6-bucket by_kind summary of the agent's work.
238
+
239
+ Drives the /work top summary card and ``map work --summary``. Defaults
240
+ filter host-only buckets for non-host personas; pass
241
+ ``include_all_personas=true`` to opt in.
242
+ """
243
+ return agent_work_service.get_agent_work_summary(
244
+ db,
245
+ agent,
246
+ include_all_personas=include_all_personas,
247
+ topics_limit=topics_limit,
248
+ experiments_limit=experiments_limit,
249
+ )
250
+
251
+
252
+ @agents_router.get("/me/topic-progress", response_model=TopicProgressListRead)
253
+ def get_my_topic_progress(
254
+ agent: Agent = Depends(get_current_agent),
255
+ db: Session = Depends(get_db),
256
+ ) -> TopicProgressListRead:
257
+ from server.services import topic_progress_service
258
+
259
+ return topic_progress_service.list_topic_progress_for_agent(db, agent)
260
+
261
+
262
+ @agents_router.post(
263
+ "/me/topics/{topic_id}/read",
264
+ response_model=TopicReadCursorRead,
265
+ )
266
+ def mark_my_topic_read(
267
+ topic_id: uuid.UUID,
268
+ agent: Agent = Depends(get_current_agent),
269
+ db: Session = Depends(get_db),
270
+ ) -> TopicReadCursorRead:
271
+ return topic_service.mark_topic_read(db, agent=agent, topic_id=topic_id)
272
+
273
+
274
+ @agents_router.post(
275
+ "/me/mentions/{mention_id}/dismiss",
276
+ response_model=DismissMentionResultRead,
277
+ )
278
+ def dismiss_my_mention(
279
+ mention_id: uuid.UUID,
280
+ agent: Agent = Depends(get_current_agent),
281
+ db: Session = Depends(get_db),
282
+ ) -> DismissMentionResultRead:
283
+ mention = mention_service.dismiss_mention(db, agent=agent, mention_id=mention_id)
284
+ if mention is None:
285
+ raise HTTPException(status_code=404, detail="Mention not found")
286
+ assert mention.dismissed_at is not None # for type checker
287
+ return DismissMentionResultRead(id=mention.id, dismissed_at=mention.dismissed_at)
288
+
289
+
290
+ @agents_router.post(
291
+ "/me/mentions/dismiss-all",
292
+ response_model=DismissAllMentionsResultRead,
293
+ )
294
+ def dismiss_all_my_mentions(
295
+ agent: Agent = Depends(get_current_agent),
296
+ db: Session = Depends(get_db),
297
+ ) -> DismissAllMentionsResultRead:
298
+ count = mention_service.dismiss_all_for_agent(db, agent)
299
+ return DismissAllMentionsResultRead(dismissed=count)
300
+
301
+
302
+ @agents_router.get("/me/notifications", response_model=NotificationListRead)
303
+ def list_my_notifications(
304
+ unread_only: bool = Query(default=False),
305
+ category: NotificationCategory | str | None = Query(default=None),
306
+ target_type: str | None = Query(default=None),
307
+ limit: int = Query(default=50, ge=1, le=200),
308
+ offset: int = Query(default=0, ge=0),
309
+ agent: Agent = Depends(get_current_agent),
310
+ db: Session = Depends(get_db),
311
+ ) -> NotificationListRead:
312
+ normalized_category: NotificationCategory | None
313
+ if category is None or category == "all":
314
+ normalized_category = None
315
+ elif isinstance(category, NotificationCategory):
316
+ normalized_category = category
317
+ else:
318
+ try:
319
+ normalized_category = NotificationCategory(category)
320
+ except ValueError as exc:
321
+ raise HTTPException(
322
+ status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
323
+ detail="category must be wakeable, digest, or all",
324
+ ) from exc
325
+ items, total = notification_service.list_for_agent(
326
+ db,
327
+ agent,
328
+ unread_only=unread_only,
329
+ category=normalized_category,
330
+ target_type=target_type,
331
+ limit=limit,
332
+ offset=offset,
333
+ )
334
+ unread_count = notification_service.count_unread(
335
+ db,
336
+ agent,
337
+ category=normalized_category,
338
+ target_type=target_type,
339
+ )
340
+ return NotificationListRead(
341
+ items=[NotificationRead.model_validate(n) for n in items],
342
+ total=total,
343
+ unread_count=unread_count,
344
+ )
345
+
346
+
347
+ @agents_router.get("/me/notifications/stream")
348
+ def stream_my_notifications(
349
+ agent: Agent = Depends(get_current_agent),
350
+ last_event_id: str | None = Header(default=None, alias="Last-Event-ID"),
351
+ ) -> StreamingResponse:
352
+ # Last-Event-ID 由浏览器 EventSource 在断线重连时自动发送,服务端从
353
+ # per-agent ring buffer 中 replay id > last_event_id 的事件。
354
+ return notification_sse_response(agent.id, last_event_id)
355
+
356
+
357
+ @agents_router.post("/me/notifications/read-all")
358
+ def mark_all_notifications_read(
359
+ agent: Agent = Depends(get_current_agent),
360
+ db: Session = Depends(get_db),
361
+ ) -> dict[str, int]:
362
+ count = notification_service.mark_all_read(db, agent)
363
+ return {"marked": count}
364
+
365
+
366
+ @agents_router.post(
367
+ "/me/inbound-events",
368
+ response_model=InboundEventRecordResult,
369
+ )
370
+ def record_my_inbound_event(
371
+ payload: InboundEventCreate,
372
+ response: Response,
373
+ agent: Agent = Depends(get_current_agent),
374
+ db: Session = Depends(get_db),
375
+ ) -> InboundEventRecordResult:
376
+ """Record that the caller has seen ``payload.event_id`` and intends to act
377
+ on it. This is the D6 server-side primary dedup gate: ``UNIQUE(fingerprint)``
378
+ enforces A1 (replay rejection) and A2 (concurrent claim) across processes
379
+ and restarts. A replay returns 409 Conflict — waker treats that as
380
+ "already woken" and skips resume.
381
+
382
+ v0.9 (M30A/M31 I2): legacy v1 fingerprints (``inbound:<event_id>``) return
383
+ 200 OK with ``status="rejected_v1"`` and the bumped ``rejection_count`` so
384
+ the audit row is preserved but the waker is told to skip resume. We do NOT
385
+ map this to 409 because v1 is a known-legacy shape (not a cross-process
386
+ race) and a 409 would invite retry loops.
387
+ """
388
+ event, result_status = inbound_event_service.record_inbound_event(
389
+ db, agent, payload
390
+ )
391
+ if result_status == "duplicate":
392
+ raise HTTPException(
393
+ status_code=status.HTTP_409_CONFLICT,
394
+ detail=(
395
+ f"inbound_event with fingerprint '{payload.fingerprint}' already exists; "
396
+ "treating as already woken (D6 server gate)"
397
+ ),
398
+ )
399
+ if result_status == "rejected_v1":
400
+ # Legacy fingerprint: row persisted (or upserted) with rejection_count
401
+ # bumped. Use 200 instead of 201 to signal "we accepted the record but
402
+ # you should NOT proceed with the resume".
403
+ response.status_code = status.HTTP_200_OK
404
+ return InboundEventRecordResult(
405
+ status="rejected_v1",
406
+ event=InboundEventRead.model_validate(event),
407
+ )
408
+ response.status_code = status.HTTP_201_CREATED
409
+ return InboundEventRecordResult(
410
+ status="recorded",
411
+ event=InboundEventRead.model_validate(event),
412
+ )
server/api/audit.py ADDED
@@ -0,0 +1,54 @@
1
+ import uuid
2
+
3
+ from fastapi import APIRouter, Depends, Query, Response
4
+ from sqlalchemy.orm import Session
5
+
6
+ from server.api.deps import get_current_agent
7
+ from server.db.session import get_db
8
+ from server.domain.models import Agent
9
+ from server.domain.schemas import (
10
+ AuditLogRead,
11
+ )
12
+ from server.services import audit_service
13
+ from server.services import permissions as perm
14
+
15
+ audit_router = APIRouter(tags=["audit"])
16
+
17
+
18
+ @audit_router.get("/audit", response_model=list[AuditLogRead])
19
+ def list_audit_for_target(
20
+ target_type: str = Query(...),
21
+ target_id: uuid.UUID = Query(...),
22
+ limit: int = Query(default=50, ge=1, le=200),
23
+ db: Session = Depends(get_db),
24
+ agent: Agent = Depends(get_current_agent),
25
+ ) -> list[AuditLogRead]:
26
+ perm.ensure_audit_target_access(db, agent, target_type, target_id)
27
+ return audit_service.query_by_target(db, target_type, target_id, limit=limit)
28
+
29
+
30
+ @audit_router.get("/admin/audit", response_model=list[AuditLogRead])
31
+ def list_audit_global(
32
+ response: Response,
33
+ page: int = Query(default=1, ge=1),
34
+ page_size: int = Query(default=50, ge=1, le=200),
35
+ # I1(g): CLI ``map audit list --kind <action> --experiment <id>`` filters.
36
+ # ``kind`` maps onto the audit row's ``action`` column (e.g.
37
+ # ``review_item.mutation``). ``experiment_id`` filters on
38
+ # ``payload_json.experiment_id`` for events that denormalise it (every
39
+ # review-item mutation does).
40
+ kind: str | None = Query(default=None, max_length=128),
41
+ experiment_id: uuid.UUID | None = Query(default=None),
42
+ db: Session = Depends(get_db),
43
+ agent: Agent = Depends(get_current_agent),
44
+ ) -> list[AuditLogRead]:
45
+ perm.require_admin(agent)
46
+ items, total = audit_service.query_all(
47
+ db,
48
+ page=page,
49
+ page_size=page_size,
50
+ action=kind,
51
+ experiment_id=experiment_id,
52
+ )
53
+ response.headers["X-Total-Count"] = str(total)
54
+ return items
@@ -0,0 +1,18 @@
1
+ from __future__ import annotations
2
+
3
+ from contextvars import ContextVar
4
+
5
+ from fastapi import BackgroundTasks
6
+
7
+ _current_background_tasks: ContextVar[BackgroundTasks | None] = ContextVar(
8
+ "current_background_tasks",
9
+ default=None,
10
+ )
11
+
12
+
13
+ def bind_background_tasks(background_tasks: BackgroundTasks) -> None:
14
+ _current_background_tasks.set(background_tasks)
15
+
16
+
17
+ def get_background_tasks() -> BackgroundTasks | None:
18
+ return _current_background_tasks.get()
server/api/common.py ADDED
@@ -0,0 +1,117 @@
1
+ import uuid
2
+ from typing import Any
3
+
4
+ from sqlalchemy.orm import Session
5
+
6
+ from server.api.background_tasks import get_background_tasks
7
+ from server.domain.models import Agent
8
+ from server.services import audit_service, notification_service, webhook_service
9
+
10
+
11
+ def emit(
12
+ db: Session,
13
+ agent: Agent,
14
+ *,
15
+ action: str,
16
+ target_type: str,
17
+ target_id: uuid.UUID | None,
18
+ project_id: uuid.UUID | None,
19
+ summary: str | None = None,
20
+ event: str | None = None,
21
+ event_payload: dict[str, Any] | None = None,
22
+ notify: bool = True,
23
+ ) -> None:
24
+ """记录审计日志并扇出副作用(notification + webhook)。
25
+
26
+ 副作用顺序:audit → notification → webhook。
27
+ audit 总是执行;notification 仅在 ``notify=True`` 时执行;
28
+ webhook 仅在 ``event`` 非空时执行,投递失败不影响主流程。
29
+ """
30
+ _log_audit(
31
+ db,
32
+ agent=agent,
33
+ action=action,
34
+ target_type=target_type,
35
+ target_id=target_id,
36
+ project_id=project_id,
37
+ summary=summary,
38
+ )
39
+ if event is None:
40
+ return
41
+ payload = event_payload or {}
42
+ if notify:
43
+ _dispatch_notifications(
44
+ db,
45
+ project_id=project_id,
46
+ actor_id=agent.id,
47
+ event=event,
48
+ summary=summary or event,
49
+ target_type=target_type,
50
+ target_id=target_id,
51
+ payload=payload,
52
+ )
53
+ _dispatch_webhooks(db, event=event, payload=payload, project_id=project_id)
54
+
55
+
56
+ def _log_audit(
57
+ db: Session,
58
+ *,
59
+ agent: Agent,
60
+ action: str,
61
+ target_type: str,
62
+ target_id: uuid.UUID | None,
63
+ project_id: uuid.UUID | None,
64
+ summary: str | None,
65
+ ) -> None:
66
+ audit_service.log(
67
+ db,
68
+ agent_id=agent.id,
69
+ action=action,
70
+ target_type=target_type,
71
+ target_id=target_id,
72
+ project_id=project_id,
73
+ summary=summary,
74
+ )
75
+
76
+
77
+ def _dispatch_notifications(
78
+ db: Session,
79
+ *,
80
+ project_id: uuid.UUID | None,
81
+ actor_id: uuid.UUID,
82
+ event: str,
83
+ summary: str,
84
+ target_type: str,
85
+ target_id: uuid.UUID | None,
86
+ payload: dict[str, Any],
87
+ ) -> None:
88
+ notification_service.enqueue_from_event(
89
+ db,
90
+ project_id=project_id,
91
+ actor_id=actor_id,
92
+ event=event,
93
+ summary=summary,
94
+ target_type=target_type,
95
+ target_id=target_id,
96
+ payload=payload,
97
+ )
98
+
99
+
100
+ def _dispatch_webhooks(
101
+ db: Session,
102
+ *,
103
+ event: str,
104
+ payload: dict[str, Any],
105
+ project_id: uuid.UUID | None,
106
+ ) -> None:
107
+ delivery_ids = webhook_service.enqueue_event_deliveries(
108
+ db, event, payload, project_id
109
+ )
110
+ background_tasks = get_background_tasks()
111
+ if background_tasks is not None:
112
+ for delivery_id in delivery_ids:
113
+ background_tasks.add_task(webhook_service.deliver_delivery, delivery_id)
114
+ else:
115
+ webhook_service.deliver_deliveries(db, delivery_ids)
116
+
117
+
server/api/deps.py ADDED
@@ -0,0 +1,30 @@
1
+ from fastapi import Depends, HTTPException, Security, status
2
+ from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
3
+ from sqlalchemy.orm import Session
4
+
5
+ from server.db.session import get_db
6
+ from server.domain.models import Agent
7
+ from server.services.auth import get_agent_by_token
8
+
9
+ bearer_scheme = HTTPBearer(auto_error=False)
10
+
11
+
12
+ def get_current_agent(
13
+ credentials: HTTPAuthorizationCredentials | None = Security(bearer_scheme),
14
+ db: Session = Depends(get_db),
15
+ ) -> Agent:
16
+ if credentials is None or credentials.scheme.lower() != "bearer":
17
+ raise HTTPException(status.HTTP_401_UNAUTHORIZED, detail="Missing or invalid authorization")
18
+ agent = get_agent_by_token(db, credentials.credentials)
19
+ if agent is None:
20
+ raise HTTPException(status.HTTP_401_UNAUTHORIZED, detail="Invalid API token")
21
+ return agent
22
+
23
+
24
+ def get_optional_current_agent(
25
+ credentials: HTTPAuthorizationCredentials | None = Security(bearer_scheme),
26
+ db: Session = Depends(get_db),
27
+ ) -> Agent | None:
28
+ if credentials is None or credentials.scheme.lower() != "bearer":
29
+ return None
30
+ return get_agent_by_token(db, credentials.credentials)