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,858 @@
1
+ import uuid
2
+ from datetime import datetime
3
+
4
+ from fastapi import APIRouter, Depends, Query, Response, status
5
+ from pydantic import BaseModel, Field
6
+ from sqlalchemy.orm import Session
7
+
8
+ from server.api.background_tasks import bind_background_tasks
9
+ from server.api.common import emit
10
+ from server.api.deps import get_current_agent
11
+ from server.auth import experiment_access
12
+ from server.db.session import get_db
13
+ from server.domain.models import Agent, ExperimentPhase
14
+ from server.domain.schemas import (
15
+ AuditLogRead,
16
+ CommentCreate,
17
+ CommentRead,
18
+ CommentTreeNode,
19
+ CrossPersonaCallRecord,
20
+ EvidenceValidationSchema,
21
+ EvidenceWarningSchema,
22
+ ExperimentBundleRead,
23
+ ExperimentComplete,
24
+ ExperimentCreate,
25
+ ExperimentDetailRead,
26
+ ExperimentLockRead,
27
+ ExperimentLockStalledScanRead,
28
+ ExperimentLogCreate,
29
+ ExperimentLogRead,
30
+ ExperimentResultDecision,
31
+ ExperimentSummaryRead,
32
+ ExperimentUpdate,
33
+ LogCreateResponse,
34
+ PlanRevise,
35
+ PlanVersionRead,
36
+ ReviewCreate,
37
+ ReviewItemRead,
38
+ ReviewItemUpdate,
39
+ ReviewRead,
40
+ SimilarityWarningSchema,
41
+ TemplateValidationSchema,
42
+ TemplateWarningSchema,
43
+ )
44
+ from server.services import (
45
+ audit_service,
46
+ comment_service,
47
+ lock_service,
48
+ log_service,
49
+ notification_service,
50
+ phase_service,
51
+ plan_service,
52
+ review_service,
53
+ )
54
+ from server.services import permissions as perm
55
+ from server.services import project_service as svc
56
+ from server.services.errors import ForbiddenError
57
+ from server.services.experiment_capabilities_service import experiment_summary_for_actor
58
+ from server.services.template_service import validate_result_submission_template
59
+
60
+ HOST_AGENT_NAME = "multi-agent-platform-host"
61
+
62
+
63
+ def _summary_for_agent(db: Session, experiment, agent: Agent, **extra) -> ExperimentSummaryRead:
64
+ return experiment_summary_for_actor(db, experiment, agent, extra_updates=extra or None)
65
+
66
+ experiments_router = APIRouter(tags=["experiments"], dependencies=[Depends(bind_background_tasks)])
67
+
68
+
69
+ @experiments_router.post(
70
+ "/projects/{project_id}/experiments",
71
+ response_model=ExperimentSummaryRead,
72
+ status_code=status.HTTP_201_CREATED,
73
+ )
74
+ def create_experiment(
75
+ project_id: uuid.UUID,
76
+ payload: ExperimentCreate,
77
+ db: Session = Depends(get_db),
78
+ agent: Agent = Depends(get_current_agent),
79
+ ) -> ExperimentSummaryRead:
80
+ resolved_project_id = perm.resolve_project_id_for_agent(agent, project_id)
81
+ perm.ensure_project_access(agent, resolved_project_id)
82
+ warnings = svc.create_experiment_warnings(db, resolved_project_id, payload.topic_id)
83
+ experiment = svc.create_experiment(db, resolved_project_id, agent.id, payload)
84
+ emit(
85
+ db,
86
+ agent,
87
+ action="experiment.created",
88
+ target_type="experiment",
89
+ target_id=experiment.id,
90
+ project_id=resolved_project_id,
91
+ summary=f"创建实验「{experiment.title}」",
92
+ event="experiment.created",
93
+ event_payload={"id": str(experiment.id), "title": experiment.title},
94
+ )
95
+ return _summary_for_agent(db, experiment, agent, warnings=warnings)
96
+
97
+
98
+ @experiments_router.get("/projects/{project_id}/experiments", response_model=list[ExperimentSummaryRead])
99
+ def list_experiments(
100
+ project_id: uuid.UUID,
101
+ response: Response,
102
+ phase: ExperimentPhase | None = Query(default=None),
103
+ creator_agent_id: uuid.UUID | None = Query(default=None),
104
+ q: str | None = Query(default=None),
105
+ page: int = Query(default=1, ge=1),
106
+ # cleanup experiment (f12a5638) Exp B: unified ``limit`` query param
107
+ # (default 50). ``page_size`` is kept as a deprecated alias for one
108
+ # minor version; when only ``page_size`` is sent we honour it AND
109
+ # surface ``Deprecation`` + ``Sunset`` response headers so clients
110
+ # migrate before v0.12 (where ``page_size`` will be removed).
111
+ limit: int | None = Query(default=None, ge=1, le=100),
112
+ page_size: int | None = Query(default=None, ge=1, le=100),
113
+ include_archived: bool = Query(default=False),
114
+ db: Session = Depends(get_db),
115
+ agent: Agent = Depends(get_current_agent),
116
+ ) -> list[ExperimentSummaryRead]:
117
+ resolved_project_id = perm.resolve_project_id_for_agent(agent, project_id)
118
+ perm.ensure_project_access(agent, resolved_project_id)
119
+
120
+ # Resolve effective page_size: limit wins; else page_size (deprecated);
121
+ # else default 50.
122
+ if limit is not None:
123
+ effective_page_size = limit
124
+ elif page_size is not None:
125
+ effective_page_size = page_size
126
+ response.headers["Deprecation"] = "true"
127
+ response.headers["Sunset"] = "v0.12"
128
+ response.headers["Link"] = (
129
+ f'<{"?limit=" + str(page_size)}>; rel="successor-version"'
130
+ )
131
+ else:
132
+ effective_page_size = 50
133
+
134
+ experiments, total = svc.list_experiments(
135
+ db,
136
+ resolved_project_id,
137
+ phase=phase,
138
+ creator_agent_id=creator_agent_id,
139
+ q=q,
140
+ page=page,
141
+ page_size=effective_page_size,
142
+ include_archived=include_archived,
143
+ )
144
+ response.headers["X-Total-Count"] = str(total)
145
+ return [ExperimentSummaryRead.model_validate(e) for e in experiments]
146
+
147
+
148
+ @experiments_router.get("/experiments/{experiment_id}", response_model=ExperimentDetailRead)
149
+ def get_experiment(
150
+ experiment_id: uuid.UUID,
151
+ db: Session = Depends(get_db),
152
+ agent: Agent = Depends(get_current_agent),
153
+ ) -> ExperimentDetailRead:
154
+ perm.ensure_experiment_access(db, agent, experiment_id)
155
+ return svc.get_experiment_detail(db, experiment_id, agent)
156
+
157
+
158
+ @experiments_router.get("/experiments/{experiment_id}/bundle", response_model=ExperimentBundleRead)
159
+ def get_experiment_bundle(
160
+ experiment_id: uuid.UUID,
161
+ db: Session = Depends(get_db),
162
+ agent: Agent = Depends(get_current_agent),
163
+ ) -> ExperimentBundleRead:
164
+ perm.ensure_experiment_access(db, agent, experiment_id)
165
+ return svc.get_experiment_bundle(db, experiment_id, agent)
166
+
167
+
168
+ @experiments_router.patch("/experiments/{experiment_id}", response_model=ExperimentSummaryRead)
169
+ def update_experiment(
170
+ experiment_id: uuid.UUID,
171
+ payload: ExperimentUpdate,
172
+ db: Session = Depends(get_db),
173
+ agent: Agent = Depends(get_current_agent),
174
+ ) -> ExperimentSummaryRead:
175
+ # authz PR3: PATCH is a non-state-machine mutation but still
176
+ # touches experiment metadata (title/description). Only the
177
+ # creator or an admin should be able to rename someone else's
178
+ # experiment — the project-level guard alone was too loose.
179
+ experiment_access.ensure_experiment_creator_or_admin(db, agent, experiment_id)
180
+ experiment = svc.update_experiment(db, experiment_id, payload)
181
+ return _summary_for_agent(db, experiment, agent)
182
+
183
+
184
+ @experiments_router.delete("/experiments/{experiment_id}", status_code=status.HTTP_204_NO_CONTENT)
185
+ def delete_experiment(
186
+ experiment_id: uuid.UUID,
187
+ db: Session = Depends(get_db),
188
+ agent: Agent = Depends(get_current_agent),
189
+ ) -> None:
190
+ # authz PR3: same rationale as update_experiment — soft-delete is
191
+ # a destructive mutation, creator/admin only.
192
+ experiment_access.ensure_experiment_creator_or_admin(db, agent, experiment_id)
193
+ svc.soft_delete_experiment(db, experiment_id)
194
+
195
+
196
+ # --- M2: phase transitions ---
197
+
198
+
199
+ @experiments_router.post("/experiments/{experiment_id}/submit-review", response_model=ExperimentSummaryRead)
200
+ def submit_for_review(
201
+ experiment_id: uuid.UUID,
202
+ db: Session = Depends(get_db),
203
+ agent: Agent = Depends(get_current_agent),
204
+ ) -> ExperimentSummaryRead:
205
+ perm.ensure_experiment_access(db, agent, experiment_id)
206
+ phase_service.submit_for_review(db, experiment_id, agent)
207
+ experiment = svc.get_experiment(db, experiment_id)
208
+ emit(
209
+ db,
210
+ agent,
211
+ action="experiment.phase_changed",
212
+ target_type="experiment",
213
+ target_id=experiment_id,
214
+ project_id=experiment.project_id,
215
+ summary=f"提交评审({experiment.title})",
216
+ event="experiment.phase_changed",
217
+ event_payload={"id": str(experiment_id), "phase": experiment.phase.value, "title": experiment.title},
218
+ )
219
+ return _summary_for_agent(db, experiment, agent)
220
+
221
+
222
+ @experiments_router.post("/experiments/{experiment_id}/approve", response_model=ExperimentSummaryRead)
223
+ def approve_experiment(
224
+ experiment_id: uuid.UUID,
225
+ db: Session = Depends(get_db),
226
+ agent: Agent = Depends(get_current_agent),
227
+ ) -> ExperimentSummaryRead:
228
+ perm.ensure_experiment_access(db, agent, experiment_id)
229
+ phase_service.approve_experiment(db, experiment_id, agent)
230
+ experiment = svc.get_experiment(db, experiment_id)
231
+ emit(
232
+ db,
233
+ agent,
234
+ action="experiment.phase_changed",
235
+ target_type="experiment",
236
+ target_id=experiment_id,
237
+ project_id=experiment.project_id,
238
+ summary=f"批准实验({experiment.title})",
239
+ event="experiment.phase_changed",
240
+ event_payload={"id": str(experiment_id), "phase": experiment.phase.value, "title": experiment.title},
241
+ )
242
+ return _summary_for_agent(db, experiment, agent)
243
+
244
+
245
+ @experiments_router.post("/experiments/{experiment_id}/withdraw", response_model=ExperimentSummaryRead)
246
+ def withdraw_from_review(
247
+ experiment_id: uuid.UUID,
248
+ db: Session = Depends(get_db),
249
+ agent: Agent = Depends(get_current_agent),
250
+ ) -> ExperimentSummaryRead:
251
+ perm.ensure_experiment_access(db, agent, experiment_id)
252
+ phase_service.withdraw_from_review(db, experiment_id, agent)
253
+ experiment = svc.get_experiment(db, experiment_id)
254
+ # Phase 2 D2: kind-directed SSE so the waker can map to ``experiment_lifecycle``.
255
+ notification_service.emit_kind(
256
+ db,
257
+ project_id=experiment.project_id,
258
+ actor_id=agent.id,
259
+ personas=["host", "reviewer"],
260
+ event="experiment.lifecycle.withdrawn",
261
+ summary=f"实验已撤回评审({experiment.title})",
262
+ target_type="experiment",
263
+ target_id=experiment.id,
264
+ payload={"experiment_id": str(experiment.id), "title": experiment.title, "phase": experiment.phase.value},
265
+ )
266
+ return _summary_for_agent(db, experiment, agent)
267
+
268
+
269
+ @experiments_router.post("/experiments/{experiment_id}/cancel", response_model=ExperimentSummaryRead)
270
+ def cancel_experiment(
271
+ experiment_id: uuid.UUID,
272
+ db: Session = Depends(get_db),
273
+ agent: Agent = Depends(get_current_agent),
274
+ ) -> ExperimentSummaryRead:
275
+ perm.ensure_experiment_access(db, agent, experiment_id)
276
+ phase_service.cancel_experiment(db, experiment_id, agent)
277
+ experiment = svc.get_experiment(db, experiment_id)
278
+ # Phase 2 D2: kind-directed SSE so the waker can map to ``experiment_lifecycle``.
279
+ notification_service.emit_kind(
280
+ db,
281
+ project_id=experiment.project_id,
282
+ actor_id=agent.id,
283
+ personas=["host", "reviewer"],
284
+ event="experiment.lifecycle.cancelled",
285
+ summary=f"实验已取消({experiment.title})",
286
+ target_type="experiment",
287
+ target_id=experiment.id,
288
+ payload={"experiment_id": str(experiment.id), "title": experiment.title, "phase": experiment.phase.value},
289
+ )
290
+ return _summary_for_agent(db, experiment, agent)
291
+
292
+
293
+ # --- M2: plans ---
294
+
295
+
296
+ @experiments_router.get("/experiments/{experiment_id}/plans", response_model=list[PlanVersionRead])
297
+ def list_plans(
298
+ experiment_id: uuid.UUID,
299
+ limit: int = Query(default=50, ge=1, le=200),
300
+ db: Session = Depends(get_db),
301
+ agent: Agent = Depends(get_current_agent),
302
+ ) -> list[PlanVersionRead]:
303
+ perm.ensure_experiment_access(db, agent, experiment_id)
304
+ plans = plan_service.list_plans(db, experiment_id, limit=limit)
305
+ return [PlanVersionRead.model_validate(p) for p in plans]
306
+
307
+
308
+ @experiments_router.get("/experiments/{experiment_id}/plans/{version}", response_model=PlanVersionRead)
309
+ def get_plan_version(
310
+ experiment_id: uuid.UUID,
311
+ version: int,
312
+ db: Session = Depends(get_db),
313
+ agent: Agent = Depends(get_current_agent),
314
+ ) -> PlanVersionRead:
315
+ perm.ensure_experiment_access(db, agent, experiment_id)
316
+ plan = plan_service.get_plan_version(db, experiment_id, version)
317
+ return PlanVersionRead.model_validate(plan)
318
+
319
+
320
+ @experiments_router.post(
321
+ "/experiments/{experiment_id}/plans",
322
+ response_model=PlanVersionRead,
323
+ status_code=status.HTTP_201_CREATED,
324
+ )
325
+ def revise_plan(
326
+ experiment_id: uuid.UUID,
327
+ payload: PlanRevise,
328
+ db: Session = Depends(get_db),
329
+ agent: Agent = Depends(get_current_agent),
330
+ ) -> PlanVersionRead:
331
+ experiment = perm.ensure_experiment_access(db, agent, experiment_id)
332
+ previous_version = experiment.current_plan_version
333
+ plan = plan_service.revise_plan(db, experiment_id, agent, payload)
334
+ if plan.version != previous_version:
335
+ emit(
336
+ db,
337
+ agent,
338
+ action="plan.revised",
339
+ target_type="plan_version",
340
+ target_id=plan.id,
341
+ project_id=experiment.project_id,
342
+ summary=f"修订计划 v{plan.version}",
343
+ event="plan.revised",
344
+ event_payload={"experiment_id": str(experiment_id), "version": plan.version},
345
+ )
346
+ return PlanVersionRead.model_validate(plan)
347
+
348
+
349
+ # --- M2: reviews ---
350
+
351
+
352
+ @experiments_router.post(
353
+ "/experiments/{experiment_id}/reviews",
354
+ response_model=ReviewRead,
355
+ status_code=status.HTTP_201_CREATED,
356
+ )
357
+ def create_review(
358
+ experiment_id: uuid.UUID,
359
+ payload: ReviewCreate,
360
+ db: Session = Depends(get_db),
361
+ agent: Agent = Depends(get_current_agent),
362
+ ) -> ReviewRead:
363
+ experiment = perm.ensure_experiment_access(db, agent, experiment_id)
364
+ review = review_service.create_review(db, experiment_id, agent, payload)
365
+ emit(
366
+ db,
367
+ agent,
368
+ action="review.submitted",
369
+ target_type="review",
370
+ target_id=review.id,
371
+ project_id=experiment.project_id,
372
+ summary="提交评审",
373
+ event="review.submitted",
374
+ event_payload={"experiment_id": str(experiment_id), "review_id": str(review.id)},
375
+ )
376
+ return review_service.review_to_read(db, review)
377
+
378
+
379
+ @experiments_router.get("/experiments/{experiment_id}/reviews", response_model=list[ReviewRead])
380
+ def list_reviews(
381
+ experiment_id: uuid.UUID,
382
+ include_archived: bool = Query(
383
+ default=True,
384
+ description=(
385
+ "Whether to include archived reviews. Defaults to true for N=2 "
386
+ "transition (legacy e2e tests + UI initial load expect to see all); "
387
+ "set false to filter to active reviews only."
388
+ ),
389
+ ),
390
+ plan_version: int | None = None,
391
+ limit: int = Query(default=50, ge=1, le=200),
392
+ db: Session = Depends(get_db),
393
+ agent: Agent = Depends(get_current_agent),
394
+ ) -> list[ReviewRead]:
395
+ perm.ensure_experiment_access(db, agent, experiment_id)
396
+ reviews = review_service.list_reviews(
397
+ db,
398
+ experiment_id,
399
+ include_archived=include_archived,
400
+ plan_version=plan_version,
401
+ limit=limit,
402
+ )
403
+ return [review_service.review_to_read(db, r) for r in reviews]
404
+
405
+
406
+ @experiments_router.post(
407
+ "/experiments/{experiment_id}/reviews/{review_id}/withdraw",
408
+ status_code=status.HTTP_204_NO_CONTENT,
409
+ )
410
+ def withdraw_review(
411
+ experiment_id: uuid.UUID,
412
+ review_id: uuid.UUID,
413
+ db: Session = Depends(get_db),
414
+ agent: Agent = Depends(get_current_agent),
415
+ ) -> None:
416
+ experiment = perm.ensure_experiment_access(db, agent, experiment_id)
417
+ review_service.withdraw_review(db, experiment_id, review_id, agent)
418
+ emit(
419
+ db,
420
+ agent,
421
+ action="review.withdrawn",
422
+ target_type="review",
423
+ target_id=review_id,
424
+ project_id=experiment.project_id,
425
+ summary="撤回评审",
426
+ event="review.withdrawn",
427
+ event_payload={"experiment_id": str(experiment_id), "review_id": str(review_id)},
428
+ )
429
+
430
+
431
+ @experiments_router.patch("/review-items/{item_id}", response_model=ReviewItemRead)
432
+ def update_review_item(
433
+ item_id: uuid.UUID,
434
+ payload: ReviewItemUpdate,
435
+ db: Session = Depends(get_db),
436
+ agent: Agent = Depends(get_current_agent),
437
+ ) -> ReviewItemRead:
438
+ perm.ensure_review_item_access(db, agent, item_id)
439
+ item = review_service.update_review_item(db, item_id, agent, payload)
440
+ # Phase 2 D2: kind-directed SSE for the addressed/responded/rebutted
441
+ # transition so the waker can map to ``addressed_review_item``.
442
+ experiment = svc.get_experiment(db, item.review.experiment_id)
443
+ notification_service.emit_kind(
444
+ db,
445
+ project_id=experiment.project_id,
446
+ actor_id=agent.id,
447
+ personas=["host"],
448
+ event="review_item.status_changed",
449
+ summary=f"评审项状态变为 {item.status.value if item.status else 'updated'}({experiment.title})",
450
+ target_type="review_item",
451
+ target_id=item.id,
452
+ payload={
453
+ "experiment_id": str(experiment.id),
454
+ "review_id": str(item.review_id),
455
+ "item_id": str(item.id),
456
+ "status": item.status.value if item.status else None,
457
+ },
458
+ )
459
+ return ReviewItemRead.model_validate(item)
460
+
461
+
462
+ # --- M2: comments ---
463
+
464
+
465
+ @experiments_router.post(
466
+ "/experiments/{experiment_id}/comments",
467
+ response_model=CommentRead,
468
+ status_code=status.HTTP_201_CREATED,
469
+ )
470
+ def create_comment(
471
+ experiment_id: uuid.UUID,
472
+ payload: CommentCreate,
473
+ db: Session = Depends(get_db),
474
+ agent: Agent = Depends(get_current_agent),
475
+ ) -> CommentRead:
476
+ experiment = perm.ensure_experiment_access(db, agent, experiment_id)
477
+ comment, unresolved = comment_service.create_comment(db, experiment_id, agent, payload)
478
+ emit(
479
+ db,
480
+ agent,
481
+ action="comment.created",
482
+ target_type="comment",
483
+ target_id=comment.id,
484
+ project_id=experiment.project_id,
485
+ summary="发表评论",
486
+ event="comment.created",
487
+ event_payload={"experiment_id": str(experiment_id), "comment_id": str(comment.id)},
488
+ )
489
+ return comment_service.comment_read(db, comment, unresolved_mentions=unresolved)
490
+
491
+
492
+ @experiments_router.get("/experiments/{experiment_id}/comments")
493
+ def list_comments(
494
+ experiment_id: uuid.UUID,
495
+ tree: bool = Query(default=False),
496
+ limit: int = Query(default=100, ge=1, le=500),
497
+ db: Session = Depends(get_db),
498
+ agent: Agent = Depends(get_current_agent),
499
+ ) -> list[CommentRead] | list[CommentTreeNode]:
500
+ perm.ensure_experiment_access(db, agent, experiment_id)
501
+ comments = comment_service.list_comments(db, experiment_id, limit=limit)
502
+ if tree:
503
+ return comment_service.build_comment_tree(db, comments)
504
+ return comment_service.comments_to_read(db, comments)
505
+
506
+
507
+ # --- M3: execution ---
508
+
509
+
510
+ @experiments_router.post("/experiments/{experiment_id}/start", response_model=ExperimentSummaryRead)
511
+ def start_experiment(
512
+ experiment_id: uuid.UUID,
513
+ db: Session = Depends(get_db),
514
+ agent: Agent = Depends(get_current_agent),
515
+ ) -> ExperimentSummaryRead:
516
+ perm.ensure_experiment_access(db, agent, experiment_id)
517
+ phase_service.start_experiment(db, experiment_id, agent)
518
+ experiment = svc.get_experiment(db, experiment_id)
519
+ emit(
520
+ db,
521
+ agent,
522
+ action="experiment.phase_changed",
523
+ target_type="experiment",
524
+ target_id=experiment_id,
525
+ project_id=experiment.project_id,
526
+ summary=f"开始执行({experiment.title})",
527
+ event="experiment.phase_changed",
528
+ event_payload={"id": str(experiment_id), "phase": experiment.phase.value, "title": experiment.title},
529
+ )
530
+ return _summary_for_agent(db, experiment, agent)
531
+
532
+
533
+ @experiments_router.post("/experiments/{experiment_id}/complete", response_model=ExperimentSummaryRead)
534
+ def complete_experiment(
535
+ experiment_id: uuid.UUID,
536
+ payload: ExperimentComplete,
537
+ db: Session = Depends(get_db),
538
+ agent: Agent = Depends(get_current_agent),
539
+ ) -> ExperimentSummaryRead:
540
+ perm.ensure_experiment_access(db, agent, experiment_id)
541
+ phase_service.complete_experiment(db, experiment_id, agent, payload)
542
+ experiment = svc.get_experiment(db, experiment_id)
543
+ # b72d0542 I1.b: 4-段 template soft validation. Mirrors evidence
544
+ # validation: never blocks complete; surfaced via response for
545
+ # reviewer + host to triage.
546
+ template_result = validate_result_submission_template(payload.content_md)
547
+ template_validation = TemplateValidationSchema(
548
+ warnings=[
549
+ TemplateWarningSchema(code=w.code, section=w.section, detail=w.detail)
550
+ for w in template_result.warnings
551
+ ],
552
+ sections_present=list(template_result.sections_present),
553
+ log_link_count=template_result.log_link_count,
554
+ valid=template_result.valid,
555
+ )
556
+ emit(
557
+ db,
558
+ agent,
559
+ action="experiment.phase_changed",
560
+ target_type="experiment",
561
+ target_id=experiment_id,
562
+ project_id=experiment.project_id,
563
+ summary=f"提交实验结果待审批({experiment.title})",
564
+ event="experiment.phase_changed",
565
+ event_payload={"id": str(experiment_id), "phase": experiment.phase.value, "title": experiment.title},
566
+ )
567
+ return _summary_for_agent(
568
+ db, experiment, agent, template_validation=template_validation
569
+ )
570
+
571
+
572
+ @experiments_router.post("/experiments/{experiment_id}/accept-result", response_model=ExperimentSummaryRead)
573
+ def accept_experiment_result(
574
+ experiment_id: uuid.UUID,
575
+ payload: ExperimentResultDecision,
576
+ db: Session = Depends(get_db),
577
+ agent: Agent = Depends(get_current_agent),
578
+ ) -> ExperimentSummaryRead:
579
+ perm.ensure_experiment_access(db, agent, experiment_id)
580
+ phase_service.accept_result(db, experiment_id, agent, payload)
581
+ experiment = svc.get_experiment(db, experiment_id)
582
+ emit(
583
+ db,
584
+ agent,
585
+ action="experiment.phase_changed",
586
+ target_type="experiment",
587
+ target_id=experiment_id,
588
+ project_id=experiment.project_id,
589
+ summary=f"审批通过实验结果({experiment.title})",
590
+ event="experiment.phase_changed",
591
+ event_payload={"id": str(experiment_id), "phase": experiment.phase.value, "title": experiment.title},
592
+ )
593
+ return _summary_for_agent(db, experiment, agent)
594
+
595
+
596
+ @experiments_router.post("/experiments/{experiment_id}/reject-result", response_model=ExperimentSummaryRead)
597
+ def reject_experiment_result(
598
+ experiment_id: uuid.UUID,
599
+ payload: ExperimentResultDecision,
600
+ db: Session = Depends(get_db),
601
+ agent: Agent = Depends(get_current_agent),
602
+ ) -> ExperimentSummaryRead:
603
+ perm.ensure_experiment_access(db, agent, experiment_id)
604
+ phase_service.reject_result(db, experiment_id, agent, payload)
605
+ experiment = svc.get_experiment(db, experiment_id)
606
+ emit(
607
+ db,
608
+ agent,
609
+ action="experiment.phase_changed",
610
+ target_type="experiment",
611
+ target_id=experiment_id,
612
+ project_id=experiment.project_id,
613
+ summary=f"驳回实验结果({experiment.title})",
614
+ event="experiment.phase_changed",
615
+ event_payload={"id": str(experiment_id), "phase": experiment.phase.value, "title": experiment.title},
616
+ )
617
+ return _summary_for_agent(db, experiment, agent)
618
+
619
+
620
+ @experiments_router.post(
621
+ "/experiments/{experiment_id}/logs",
622
+ response_model=LogCreateResponse,
623
+ status_code=status.HTTP_201_CREATED,
624
+ )
625
+ def create_log(
626
+ experiment_id: uuid.UUID,
627
+ payload: ExperimentLogCreate,
628
+ db: Session = Depends(get_db),
629
+ agent: Agent = Depends(get_current_agent),
630
+ ) -> LogCreateResponse:
631
+ perm.ensure_experiment_access(db, agent, experiment_id)
632
+ log, validation, similarity, force_skip_applied = log_service.create_log(
633
+ db, experiment_id, agent, payload
634
+ )
635
+ validation_schema = EvidenceValidationSchema(
636
+ warnings=[
637
+ EvidenceWarningSchema(
638
+ code=w.code,
639
+ missing_key=w.missing_key,
640
+ plan_required=w.plan_required,
641
+ log_provided=w.log_provided,
642
+ )
643
+ for w in validation.warnings
644
+ ],
645
+ parse_error=validation.parse_error,
646
+ plan_keys=list(validation.plan_keys),
647
+ valid=validation.valid,
648
+ )
649
+ # b72d0542 I1.b(2)(e): surface similarity warning unless caller
650
+ # acknowledged via ``force_skip_similarity=True`` (the audit row
651
+ # already landed in ``log_service.append_log``). ``similarity_warning``
652
+ # is None when no warning fires; ``force_skip`` echoes the
653
+ # acknowledgement for downstream consumers.
654
+ similarity_warning: SimilarityWarningSchema | None = None
655
+ if similarity.warnings and not force_skip_applied:
656
+ warning = similarity.warnings[0]
657
+ similarity_warning = SimilarityWarningSchema(
658
+ code=warning.code,
659
+ score=warning.score,
660
+ threshold=warning.threshold,
661
+ ref_log_id=warning.ref_log_id,
662
+ model=warning.model,
663
+ )
664
+ return LogCreateResponse(
665
+ log=ExperimentLogRead.model_validate(log),
666
+ validation=validation_schema,
667
+ similarity_warning=similarity_warning,
668
+ force_skip=force_skip_applied,
669
+ )
670
+
671
+
672
+ @experiments_router.get("/experiments/{experiment_id}/logs", response_model=list[ExperimentLogRead])
673
+ def list_logs(
674
+ experiment_id: uuid.UUID,
675
+ limit: int = Query(default=50, ge=1, le=200),
676
+ db: Session = Depends(get_db),
677
+ agent: Agent = Depends(get_current_agent),
678
+ ) -> list[ExperimentLogRead]:
679
+ perm.ensure_experiment_access(db, agent, experiment_id)
680
+ logs = log_service.list_logs(db, experiment_id, limit=limit)
681
+ return [ExperimentLogRead.model_validate(log) for log in logs]
682
+
683
+
684
+ # 0db51e10 I2(5e): record a ``cross_persona_call`` audit row. The CLI
685
+ # facade (``map experiment status --persona-compare``) calls this with
686
+ # the per-persona view diff so admin / R6 metrics can aggregate by
687
+ # ``result_partition_count`` / ``diff_size`` later.
688
+ #
689
+ # authz PR2: capability-gated. Whitelist = ``host`` persona OR
690
+ # ``role == admin`` via ``system:cross_persona_call`` capability. A
691
+ # denied call STILL emits an audit row with ``rejected=True`` + reason
692
+ # so R6 metrics can spot probe attempts vs legitimate aggregations.
693
+ @experiments_router.post(
694
+ "/experiments/{experiment_id}/cross-persona-call",
695
+ response_model=AuditLogRead,
696
+ status_code=status.HTTP_201_CREATED,
697
+ )
698
+ def record_cross_persona_call(
699
+ experiment_id: uuid.UUID,
700
+ payload: CrossPersonaCallRecord,
701
+ db: Session = Depends(get_db),
702
+ agent: Agent = Depends(get_current_agent),
703
+ ) -> AuditLogRead:
704
+ perm.ensure_experiment_access(db, agent, experiment_id)
705
+ experiment = svc.get_experiment(db, experiment_id)
706
+ if not agent.has_capability("system:cross_persona_call"):
707
+ # Record the rejected attempt for R6 noise / intrusion detection,
708
+ # then surface the 403 to the caller.
709
+ entry = audit_service.log_cross_persona_call_no_commit(
710
+ db,
711
+ caller_agent_id=agent.id,
712
+ target_experiment_id=experiment_id,
713
+ project_id=experiment.project_id,
714
+ visibility_diff=payload.visibility_diff,
715
+ result_partition_count=payload.result_partition_count,
716
+ diff_size=payload.diff_size,
717
+ rejected=True,
718
+ rejection_reason="missing capability system:cross_persona_call",
719
+ )
720
+ db.commit()
721
+ raise perm.ForbiddenError(
722
+ "Agent lacks capability system:cross_persona_call"
723
+ )
724
+ entry = audit_service.log_cross_persona_call_no_commit(
725
+ db,
726
+ caller_agent_id=agent.id,
727
+ target_experiment_id=experiment_id,
728
+ project_id=experiment.project_id,
729
+ visibility_diff=payload.visibility_diff,
730
+ result_partition_count=payload.result_partition_count,
731
+ diff_size=payload.diff_size,
732
+ )
733
+ db.commit()
734
+ return AuditLogRead.model_validate(entry)
735
+
736
+
737
+ # --- Experiment execution lock (CP-3) -------------------------------------
738
+
739
+
740
+ class ExperimentLockAcquirePayload(BaseModel):
741
+ ttl_seconds: int = Field(default=lock_service.DEFAULT_LOCK_TTL_SECONDS, ge=1)
742
+
743
+
744
+ class ExperimentLockForceReleasePayload(BaseModel):
745
+ reason: str = Field(min_length=1, max_length=512)
746
+
747
+
748
+ class ExperimentLockSkipPayload(BaseModel):
749
+ next_attempt_at: datetime
750
+
751
+
752
+ @experiments_router.post(
753
+ "/experiments/{experiment_id}/lock/acquire",
754
+ response_model=ExperimentLockRead,
755
+ )
756
+ def acquire_experiment_lock_endpoint(
757
+ experiment_id: uuid.UUID,
758
+ payload: ExperimentLockAcquirePayload,
759
+ db: Session = Depends(get_db),
760
+ agent: Agent = Depends(get_current_agent),
761
+ ) -> ExperimentLockRead:
762
+ # authz (0e6926fa) PR1: two-gate guard (404 then 403) — must run
763
+ # before any state mutation. Order is load-bearing: a 404 must not
764
+ # be leaked as 403 (or vice versa) for cross-project probes.
765
+ experiment_access.ensure_experiment_creator_or_admin(db, agent, experiment_id)
766
+ result = lock_service.acquire_experiment_lock(
767
+ db,
768
+ experiment_id,
769
+ agent,
770
+ ttl_seconds=payload.ttl_seconds,
771
+ )
772
+ return ExperimentLockRead.model_validate(result)
773
+
774
+
775
+ @experiments_router.post(
776
+ "/experiments/{experiment_id}/lock/release",
777
+ response_model=ExperimentLockRead,
778
+ )
779
+ def release_experiment_lock_endpoint(
780
+ experiment_id: uuid.UUID,
781
+ db: Session = Depends(get_db),
782
+ agent: Agent = Depends(get_current_agent),
783
+ ) -> ExperimentLockRead:
784
+ experiment_access.ensure_experiment_creator_or_admin(db, agent, experiment_id)
785
+ result = lock_service.release_experiment_lock(db, experiment_id, agent)
786
+ return ExperimentLockRead.model_validate(result)
787
+
788
+
789
+ @experiments_router.post(
790
+ "/experiments/{experiment_id}/lock/force-release",
791
+ response_model=ExperimentLockRead,
792
+ )
793
+ def force_release_experiment_lock_endpoint(
794
+ experiment_id: uuid.UUID,
795
+ payload: ExperimentLockForceReleasePayload,
796
+ db: Session = Depends(get_db),
797
+ agent: Agent = Depends(get_current_agent),
798
+ ) -> ExperimentLockRead:
799
+ experiment_access.ensure_experiment_creator_or_admin(db, agent, experiment_id)
800
+ result = lock_service.force_release_experiment_lock(
801
+ db,
802
+ experiment_id,
803
+ agent,
804
+ reason=payload.reason,
805
+ )
806
+ return ExperimentLockRead.model_validate(result)
807
+
808
+
809
+ @experiments_router.post(
810
+ "/experiments/{experiment_id}/lock/skip",
811
+ response_model=ExperimentLockRead,
812
+ )
813
+ def record_experiment_lock_skip_endpoint(
814
+ experiment_id: uuid.UUID,
815
+ payload: ExperimentLockSkipPayload,
816
+ db: Session = Depends(get_db),
817
+ agent: Agent = Depends(get_current_agent),
818
+ ) -> ExperimentLockRead:
819
+ experiment_access.ensure_experiment_creator_or_admin(db, agent, experiment_id)
820
+ result = lock_service.record_experiment_lock_skip(
821
+ db,
822
+ experiment_id,
823
+ agent,
824
+ next_attempt_at=payload.next_attempt_at,
825
+ )
826
+ return ExperimentLockRead.model_validate(result)
827
+
828
+
829
+ @experiments_router.post(
830
+ "/experiments/lock/scan-stalled",
831
+ response_model=ExperimentLockStalledScanRead,
832
+ )
833
+ def scan_stalled_experiment_locks_endpoint(
834
+ db: Session = Depends(get_db),
835
+ agent: Agent = Depends(get_current_agent),
836
+ ) -> ExperimentLockStalledScanRead:
837
+ # authz PR3: replace the old ``agent.name == HOST_AGENT_NAME``
838
+ # check with a capability-based one. The name check was brittle —
839
+ # it required the host agent to keep a hard-coded name; renaming
840
+ # it (or registering a second host agent) silently disabled the
841
+ # stalled-lock scan. Use the same capability model as the rest of
842
+ # the system: any agent holding ``system:scan_stalled`` (or an
843
+ # admin) can run this.
844
+ if not (
845
+ perm.is_admin(agent)
846
+ or agent.has_capability("system:scan_stalled")
847
+ ):
848
+ raise ForbiddenError(
849
+ "Agent lacks capability system:scan_stalled"
850
+ )
851
+ notification_ids = notification_service.notify_stalled_experiment_locks(
852
+ db,
853
+ project_id=None if perm.is_admin(agent) else agent.project_id,
854
+ )
855
+ return ExperimentLockStalledScanRead(
856
+ notification_ids=notification_ids,
857
+ emitted_count=len(notification_ids),
858
+ )