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,707 @@
1
+ import uuid
2
+ from datetime import UTC, datetime
3
+
4
+ from map_types.enums import ResolutionReason, ReviewSubstituteKind, ReviewVerdict
5
+ from sqlalchemy import func, select
6
+ from sqlalchemy.orm import Session, joinedload
7
+
8
+ from server.domain.models import (
9
+ Agent,
10
+ AgentRole,
11
+ ExperimentLog,
12
+ ExperimentPhase,
13
+ Review,
14
+ ReviewItem,
15
+ ReviewItemKind,
16
+ ReviewItemStatus,
17
+ )
18
+ from server.domain.schemas import ReviewCreate, ReviewItemRead, ReviewItemUpdate, ReviewRead
19
+ from server.domain.state_machine import ReviewItemTransitionContext, validate_review_item_transition
20
+ from server.services import audit_service
21
+ from server.services.errors import ConflictError, ForbiddenError, NotFoundError, StateTransitionError
22
+ from server.services.project_service import get_experiment
23
+
24
+
25
+ class CreatorSelfReviewBlockedError(ForbiddenError):
26
+ def __init__(
27
+ self,
28
+ message: str,
29
+ *,
30
+ actor_id: uuid.UUID,
31
+ experiment_id: uuid.UUID,
32
+ ) -> None:
33
+ super().__init__(message)
34
+ self.reason = "creator_self_review_blocked"
35
+ self.actor_id = str(actor_id)
36
+ self.experiment_id = str(experiment_id)
37
+
38
+
39
+ class ApproveEligibilityError(ConflictError):
40
+ def __init__(self, message: str, *, reason: str) -> None:
41
+ super().__init__(message)
42
+ self.reason = reason
43
+
44
+
45
+ def _qualifying_non_creator_reviews(reviews: list[Review], creator_id: uuid.UUID) -> list[Review]:
46
+ return [
47
+ review
48
+ for review in reviews
49
+ if review.reviewer_agent_id != creator_id
50
+ and review.substitute_kind != ReviewSubstituteKind.admin_self_substitute
51
+ ]
52
+
53
+
54
+ def compute_legacy_self_review(db: Session, experiment) -> bool:
55
+ if experiment.phase in (ExperimentPhase.draft, ExperimentPhase.review):
56
+ return False
57
+ reviews = list(
58
+ db.scalars(select(Review).where(Review.experiment_id == experiment.id))
59
+ )
60
+ return len(_qualifying_non_creator_reviews(reviews, experiment.creator_agent_id)) == 0
61
+
62
+
63
+ def _review_has_item_activity(review: Review) -> bool:
64
+ for item in review.items:
65
+ if item.kind != ReviewItemKind.unreasonable:
66
+ continue
67
+ if item.status is not None and item.status != ReviewItemStatus.open:
68
+ return True
69
+ return False
70
+
71
+
72
+ def get_unreasonable_items(db: Session, experiment_id: uuid.UUID) -> list[ReviewItem]:
73
+ stmt = (
74
+ select(ReviewItem)
75
+ .join(Review)
76
+ .where(
77
+ Review.experiment_id == experiment_id,
78
+ Review.archived_at.is_(None),
79
+ ReviewItem.kind == ReviewItemKind.unreasonable,
80
+ )
81
+ .options(joinedload(ReviewItem.review))
82
+ )
83
+ return list(db.scalars(stmt))
84
+
85
+
86
+ def count_open_unreasonable_for_experiment(db: Session, experiment_id: uuid.UUID) -> int:
87
+ stmt = (
88
+ select(func.count())
89
+ .select_from(ReviewItem)
90
+ .join(Review)
91
+ .where(
92
+ Review.experiment_id == experiment_id,
93
+ Review.archived_at.is_(None),
94
+ ReviewItem.kind == ReviewItemKind.unreasonable,
95
+ ReviewItem.status.in_(
96
+ (
97
+ ReviewItemStatus.open,
98
+ ReviewItemStatus.addressed,
99
+ ReviewItemStatus.rebutted,
100
+ ReviewItemStatus.escalated,
101
+ )
102
+ ),
103
+ )
104
+ )
105
+ return db.scalar(stmt) or 0
106
+
107
+
108
+ def open_unreasonable_count_by_experiment(
109
+ db: Session, experiment_ids: list[uuid.UUID]
110
+ ) -> dict[uuid.UUID, int]:
111
+ """Per-experiment count of open unreasonable review items in one GROUP BY.
112
+
113
+ Items counted: ``status in (open, addressed, rebutted, escalated)`` —
114
+ mirrors :func:`count_open_unreasonable_for_experiment`. Returns a dict
115
+ keyed by experiment_id; experiments with no open items map to 0.
116
+ Empty input → empty dict without hitting the database.
117
+ """
118
+ if not experiment_ids:
119
+ return {}
120
+ stmt = (
121
+ select(Review.experiment_id, func.count())
122
+ .join(ReviewItem, ReviewItem.review_id == Review.id)
123
+ .where(
124
+ Review.experiment_id.in_(experiment_ids),
125
+ Review.archived_at.is_(None),
126
+ ReviewItem.kind == ReviewItemKind.unreasonable,
127
+ ReviewItem.status.in_(
128
+ (
129
+ ReviewItemStatus.open,
130
+ ReviewItemStatus.addressed,
131
+ ReviewItemStatus.rebutted,
132
+ ReviewItemStatus.escalated,
133
+ )
134
+ ),
135
+ )
136
+ .group_by(Review.experiment_id)
137
+ )
138
+ found = {eid: int(count) for eid, count in db.execute(stmt).all()}
139
+ # Fill in zeros for experiments with no open unreasonable items so the
140
+ # caller can index by experiment_id without a defensive ``.get``.
141
+ return {eid: found.get(eid, 0) for eid in experiment_ids}
142
+
143
+
144
+ def has_review_on_older_plan_version(db: Session, experiment) -> bool:
145
+ """True when plan was revised after at least one review on a prior version."""
146
+ stmt = (
147
+ select(func.count())
148
+ .select_from(Review)
149
+ .where(
150
+ Review.experiment_id == experiment.id,
151
+ Review.plan_version < experiment.current_plan_version,
152
+ )
153
+ )
154
+ return (db.scalar(stmt) or 0) > 0
155
+
156
+
157
+ def count_open_status_unreasonable_for_experiment(
158
+ db: Session, experiment_id: uuid.UUID
159
+ ) -> int:
160
+ """Unreasonable items with status=open only (plan revision obligation)."""
161
+ stmt = (
162
+ select(func.count())
163
+ .select_from(ReviewItem)
164
+ .join(Review)
165
+ .where(
166
+ Review.experiment_id == experiment_id,
167
+ Review.archived_at.is_(None),
168
+ ReviewItem.kind == ReviewItemKind.unreasonable,
169
+ ReviewItem.status == ReviewItemStatus.open,
170
+ )
171
+ )
172
+ return db.scalar(stmt) or 0
173
+
174
+
175
+ def _prior_version_fully_resolved_from_reviews(
176
+ prior_reviews: list[Review],
177
+ *,
178
+ creator_agent_id: uuid.UUID,
179
+ ) -> bool:
180
+ """Pure counterpart of :func:`_prior_version_reviews_fully_resolved`.
181
+
182
+ Operates on an already-loaded review list so callers can batch-fetch
183
+ reviews for many experiments and evaluate the carve-out in memory.
184
+ """
185
+ prior_non_creator = _qualifying_non_creator_reviews(prior_reviews, creator_agent_id)
186
+ has_unreasonable = False
187
+ for review in prior_non_creator:
188
+ for item in review.items:
189
+ if item.kind != ReviewItemKind.unreasonable:
190
+ continue
191
+ has_unreasonable = True
192
+ # I1(c): the canonical "fully resolved" signal is now
193
+ # ``status=closed`` with ``last_resolution_reason=resolved``.
194
+ # Legacy rows that still carry ``status=resolved`` are accepted
195
+ # for backward compatibility.
196
+ is_fully_resolved = (
197
+ item.status == ReviewItemStatus.closed
198
+ and item.last_resolution_reason == ResolutionReason.resolved
199
+ ) or item.status == ReviewItemStatus.resolved
200
+ if not is_fully_resolved:
201
+ return False
202
+ return has_unreasonable
203
+
204
+
205
+ def _prior_version_reviews_fully_resolved(db: Session, experiment) -> bool:
206
+ """True when a non-creator review on an older plan version raised at least
207
+ one unreasonable item and all such items are now ``resolved``.
208
+
209
+ Reviewer resolving every unreasonable item they raised on a prior plan
210
+ version counts as explicit acceptance of the revision, so the creator may
211
+ approve without waiting for a fresh review on the current plan version.
212
+ Reviews with no unreasonable items do not qualify — the reviewer has not
213
+ acknowledged the revision, so a fresh review on the current version is
214
+ still required.
215
+ """
216
+ prior_reviews = list(
217
+ db.scalars(
218
+ select(Review)
219
+ .where(
220
+ Review.experiment_id == experiment.id,
221
+ Review.plan_version < experiment.current_plan_version,
222
+ )
223
+ .options(joinedload(Review.items))
224
+ ).unique()
225
+ )
226
+ return _prior_version_fully_resolved_from_reviews(
227
+ prior_reviews,
228
+ creator_agent_id=experiment.creator_agent_id,
229
+ )
230
+
231
+
232
+ def prior_version_reviews_fully_resolved_by_experiment(
233
+ db: Session,
234
+ experiments: list,
235
+ ) -> dict[uuid.UUID, bool]:
236
+ """Batch form of :func:`_prior_version_reviews_fully_resolved`.
237
+
238
+ Returns ``{experiment_id: True}`` when that experiment should be
239
+ excluded from ``pending_reviews`` (prior-version carve-out satisfied).
240
+ Issues at most one ``reviews`` SELECT for the whole input set.
241
+ """
242
+ if not experiments:
243
+ return {}
244
+ exp_by_id = {exp.id: exp for exp in experiments}
245
+ rows = list(
246
+ db.scalars(
247
+ select(Review)
248
+ .where(Review.experiment_id.in_(exp_by_id.keys()))
249
+ .options(joinedload(Review.items))
250
+ ).unique()
251
+ )
252
+ grouped: dict[uuid.UUID, list[Review]] = {eid: [] for eid in exp_by_id}
253
+ for review in rows:
254
+ exp = exp_by_id[review.experiment_id]
255
+ if review.plan_version < exp.current_plan_version:
256
+ grouped[review.experiment_id].append(review)
257
+ return {
258
+ eid: _prior_version_fully_resolved_from_reviews(
259
+ grouped.get(eid, []),
260
+ creator_agent_id=exp.creator_agent_id,
261
+ )
262
+ for eid, exp in exp_by_id.items()
263
+ }
264
+
265
+
266
+ def assert_approve_eligibility(db: Session, experiment) -> None:
267
+ reviews = list(
268
+ db.scalars(
269
+ select(Review).where(
270
+ Review.experiment_id == experiment.id,
271
+ Review.plan_version == experiment.current_plan_version,
272
+ )
273
+ )
274
+ )
275
+ non_creator_reviews = _qualifying_non_creator_reviews(reviews, experiment.creator_agent_id)
276
+ # Carve-out: a prior-version review whose unreasonable items have all
277
+ # been explicitly ``resolved`` stands in for a fresh current-version
278
+ # review (reviewer has accepted the revision).
279
+ if not non_creator_reviews and not _prior_version_reviews_fully_resolved(db, experiment):
280
+ if not reviews:
281
+ if has_review_on_older_plan_version(db, experiment):
282
+ raise ApproveEligibilityError(
283
+ "Cannot approve: no review for the current plan version "
284
+ f"(v{experiment.current_plan_version}); reviewer must submit review "
285
+ "for this plan version after plan revise",
286
+ reason="no_review_for_current_plan_version",
287
+ )
288
+ raise ApproveEligibilityError(
289
+ "Cannot approve: no review from a non-creator agent",
290
+ reason="no_review",
291
+ )
292
+ raise ApproveEligibilityError(
293
+ "Cannot approve: only creator reviews exist",
294
+ reason="creator_only_review",
295
+ )
296
+ if count_open_unreasonable_for_experiment(db, experiment.id) > 0:
297
+ raise ApproveEligibilityError(
298
+ "Cannot approve: open unreasonable items remain",
299
+ reason="open_unreasonable_item",
300
+ )
301
+
302
+
303
+ def create_review(
304
+ db: Session,
305
+ experiment_id: uuid.UUID,
306
+ reviewer: Agent,
307
+ payload: ReviewCreate,
308
+ ) -> Review:
309
+ experiment = get_experiment(db, experiment_id)
310
+ if experiment.phase != ExperimentPhase.review:
311
+ raise StateTransitionError("Reviews can only be submitted during review phase")
312
+ if (
313
+ reviewer.id == experiment.creator_agent_id
314
+ and reviewer.role != AgentRole.admin
315
+ ):
316
+ raise CreatorSelfReviewBlockedError(
317
+ "Experiment creator cannot submit a review for their own experiment",
318
+ actor_id=reviewer.id,
319
+ experiment_id=experiment_id,
320
+ )
321
+
322
+ substitute_kind = ReviewSubstituteKind.none
323
+ if reviewer.role == AgentRole.admin:
324
+ if reviewer.id == experiment.creator_agent_id:
325
+ substitute_kind = ReviewSubstituteKind.admin_self_substitute
326
+ else:
327
+ substitute_kind = ReviewSubstituteKind.admin_for_others
328
+ if not payload.substitute_reason or not payload.substitute_reason.strip():
329
+ raise ConflictError(
330
+ "Admin substitute review requires substitute_reason",
331
+ )
332
+
333
+ existing = db.scalar(
334
+ select(Review).where(
335
+ Review.experiment_id == experiment_id,
336
+ Review.reviewer_agent_id == reviewer.id,
337
+ Review.plan_version == experiment.current_plan_version,
338
+ )
339
+ )
340
+ if existing:
341
+ raise ConflictError("Reviewer already submitted a review for this plan version")
342
+
343
+ review = Review(
344
+ experiment_id=experiment_id,
345
+ reviewer_agent_id=reviewer.id,
346
+ plan_version=experiment.current_plan_version,
347
+ substitute_kind=substitute_kind,
348
+ )
349
+ db.add(review)
350
+ db.flush()
351
+
352
+ if substitute_kind != ReviewSubstituteKind.none:
353
+ audit_service.log_no_commit(
354
+ db,
355
+ action="review_substitute",
356
+ target_type="experiment",
357
+ agent_id=reviewer.id,
358
+ project_id=experiment.project_id,
359
+ target_id=experiment_id,
360
+ summary=f"Admin substitute review ({substitute_kind.value})",
361
+ payload={
362
+ "actor_id": str(reviewer.id),
363
+ "actor_role": reviewer.role.value,
364
+ "action": "review_substitute",
365
+ "target": str(experiment_id),
366
+ "target_plan_version": experiment.current_plan_version,
367
+ "substitute_kind": substitute_kind.value,
368
+ "reason": payload.substitute_reason,
369
+ "review_id": str(review.id),
370
+ },
371
+ )
372
+
373
+ for content in payload.reasonable_items:
374
+ db.add(
375
+ ReviewItem(
376
+ review_id=review.id,
377
+ kind=ReviewItemKind.reasonable,
378
+ content=content,
379
+ status=None,
380
+ )
381
+ )
382
+ for content in payload.unreasonable_items:
383
+ db.add(
384
+ ReviewItem(
385
+ review_id=review.id,
386
+ kind=ReviewItemKind.unreasonable,
387
+ content=content,
388
+ status=ReviewItemStatus.open,
389
+ )
390
+ )
391
+ db.flush()
392
+
393
+ # I1(e): emit a ``review_item.mutation`` audit row for each item created
394
+ # during the review submit so admins can reconstruct the review timeline
395
+ # from ``map audit list --kind review_item_mutation --experiment <id>``.
396
+ for item in review.items:
397
+ audit_service.log_review_item_mutation_no_commit(
398
+ db,
399
+ item=item,
400
+ experiment_id=experiment_id,
401
+ actor_id=reviewer.id,
402
+ project_id=experiment.project_id,
403
+ action="add_item",
404
+ before_state=None,
405
+ after_state=item.status.value if item.status is not None else None,
406
+ reason=None,
407
+ )
408
+
409
+ db.commit()
410
+ stmt = select(Review).where(Review.id == review.id).options(joinedload(Review.items))
411
+ reloaded = db.scalar(stmt)
412
+ assert reloaded is not None
413
+ return reloaded
414
+
415
+
416
+ def withdraw_review(
417
+ db: Session,
418
+ experiment_id: uuid.UUID,
419
+ review_id: uuid.UUID,
420
+ actor: Agent,
421
+ ) -> None:
422
+ experiment = get_experiment(db, experiment_id)
423
+ if experiment.phase != ExperimentPhase.review:
424
+ raise StateTransitionError("Reviews can only be withdrawn during review phase")
425
+
426
+ stmt = (
427
+ select(Review)
428
+ .where(Review.id == review_id, Review.experiment_id == experiment_id)
429
+ .options(joinedload(Review.items))
430
+ )
431
+ review = db.scalar(stmt)
432
+ if review is None:
433
+ raise NotFoundError("Review not found")
434
+ # I1(d): withdrawing an archived review is structurally meaningless.
435
+ _ensure_review_not_archived(review)
436
+ if review.reviewer_agent_id != actor.id and actor.role != AgentRole.admin:
437
+ raise ForbiddenError("Only the review author can withdraw a review")
438
+ if _review_has_item_activity(review):
439
+ raise ConflictError("Cannot withdraw review after review items have been acted on")
440
+
441
+ for item in review.items:
442
+ db.delete(item)
443
+ db.delete(review)
444
+ db.commit()
445
+
446
+
447
+ def list_reviews(
448
+ db: Session,
449
+ experiment_id: uuid.UUID,
450
+ *,
451
+ include_archived: bool = False,
452
+ plan_version: int | None = None,
453
+ limit: int = 50,
454
+ ) -> list[Review]:
455
+ get_experiment(db, experiment_id)
456
+ stmt = select(Review).where(Review.experiment_id == experiment_id)
457
+ if not include_archived:
458
+ stmt = stmt.where(Review.archived_at.is_(None))
459
+ if plan_version is not None:
460
+ stmt = stmt.where(Review.plan_version == plan_version)
461
+ stmt = (
462
+ stmt.options(joinedload(Review.items))
463
+ .order_by(Review.created_at.asc())
464
+ .limit(max(1, min(limit, 200)))
465
+ )
466
+ return list(db.scalars(stmt).unique())
467
+
468
+
469
+ def get_review_item(db: Session, item_id: uuid.UUID) -> ReviewItem:
470
+ stmt = select(ReviewItem).where(ReviewItem.id == item_id).options(joinedload(ReviewItem.review))
471
+ item = db.scalar(stmt)
472
+ if item is None:
473
+ raise NotFoundError("Review item not found")
474
+ return item
475
+
476
+
477
+ def _ensure_review_not_archived(review: Review) -> None:
478
+ """Guard against mutating an archived review.
479
+
480
+ I1(d) — once a review row carries ``archived_at`` (set by ``plan_revise``
481
+ auto-archive, manual admin archive, or the backfill marker from
482
+ migration 034), it is no longer canonical and resolve / withdraw /
483
+ update-item flows must refuse with a structured subcode instead of
484
+ silently mutating stale state. The CLI / SDK use this subcode to
485
+ surface the recovery hint pointing at ``--include-archived``.
486
+ """
487
+ if review.archived_at is None:
488
+ return
489
+ reason = (
490
+ review.archived_reason.value
491
+ if review.archived_reason is not None
492
+ else "auto"
493
+ )
494
+ raise StateTransitionError(
495
+ (
496
+ "Review "
497
+ f"{review.id} has been archived (reason={reason}); "
498
+ "resolve / withdraw / update-item flows are not allowed on archived reviews."
499
+ ),
500
+ error_code="REVIEW_ALREADY_ARCHIVED",
501
+ hint=(
502
+ "查看 --include-archived 历史; 若需要修改 item, 请在新的 plan_version 提交新 review。"
503
+ ),
504
+ retryable=False,
505
+ )
506
+
507
+
508
+ def update_review_item(
509
+ db: Session,
510
+ item_id: uuid.UUID,
511
+ actor: Agent,
512
+ payload: ReviewItemUpdate,
513
+ ) -> ReviewItem:
514
+ item = get_review_item(db, item_id)
515
+ experiment = get_experiment(db, item.review.experiment_id)
516
+
517
+ # I1(d): archived reviews are not mutable. Refuse with REVIEW_ALREADY_ARCHIVED
518
+ # so the CLI / SDK can surface the --include-archived recovery hint.
519
+ _ensure_review_not_archived(item.review)
520
+
521
+ if item.kind != ReviewItemKind.unreasonable:
522
+ raise StateTransitionError("Only unreasonable items have mutable status")
523
+ if item.status is None:
524
+ raise StateTransitionError("Item has no status")
525
+
526
+ is_creator = experiment.creator_agent_id == actor.id
527
+ is_reviewer = item.review.reviewer_agent_id == actor.id
528
+ is_admin = actor.role == AgentRole.admin
529
+
530
+ # I1(d): rebutting a single review item is only meaningful while the
531
+ # experiment is in the review phase. Once the experiment has reached
532
+ # ``result_review`` the host creator's intent ("this result should not
533
+ # be accepted") is structurally modelled by ``reject-result``, not by
534
+ # a per-item rebuttal. Surface that as the same structured subcode so
535
+ # the CLI / SDK can route the user to the right command.
536
+ if (
537
+ payload.status == ReviewItemStatus.rebutted
538
+ and is_creator
539
+ and not is_admin
540
+ and experiment.phase != ExperimentPhase.review
541
+ ):
542
+ raise StateTransitionError(
543
+ (
544
+ "Cannot rebut a single review item after the experiment has "
545
+ "left review phase (current phase: "
546
+ f"{experiment.phase.value}). 整个实验结果驳回请让 reviewer / admin 调用 "
547
+ "reject-result。"
548
+ ),
549
+ error_code="REVIEW_REJECT_RESULT_MISUSE",
550
+ hint=(
551
+ "单 item 驳回仅在 review 阶段有效;实验已过 review, 若要驳回整个 result, "
552
+ "请让 reviewer / admin 调用 reject-result, host creator 不可拒绝自己的 result。"
553
+ ),
554
+ retryable=False,
555
+ )
556
+
557
+ ctx = ReviewItemTransitionContext(
558
+ is_creator=is_creator,
559
+ is_reviewer=is_reviewer,
560
+ is_admin=is_admin,
561
+ )
562
+ try:
563
+ validate_review_item_transition(item.status, payload.status, ctx)
564
+ except Exception as exc:
565
+ raise StateTransitionError(str(exc)) from exc
566
+
567
+ before_state = item.status.value if item.status is not None else None
568
+ item.status, item.last_resolution_reason = _normalize_terminal_status(payload.status)
569
+ item.updated_at = datetime.now(UTC)
570
+ after_state = item.status.value
571
+
572
+ # I1(e): emit a ``review_item.mutation`` audit row capturing the
573
+ # before / after state for admin timeline reconstruction. The
574
+ # ``reason`` field stays ``None`` for resolve-item mutations; the
575
+ # free-form reason lives in the experiment log.
576
+ audit_service.log_review_item_mutation_no_commit(
577
+ db,
578
+ item=item,
579
+ experiment_id=experiment.id,
580
+ actor_id=actor.id,
581
+ project_id=experiment.project_id,
582
+ action="resolve_item",
583
+ before_state=before_state,
584
+ after_state=after_state,
585
+ reason=None,
586
+ )
587
+ db.commit()
588
+ db.refresh(item)
589
+ return item
590
+
591
+
592
+ # Terminal status values that the I1(c) state migration collapses into
593
+ # ``closed``. Existing callers (CLI ``--status resolved``, review dashboard
594
+ # PATCHes, MCP tools) keep sending the legacy values; the API layer rewrites
595
+ # them on the way to the database so the new ``closed`` terminal becomes the
596
+ # single source of truth.
597
+ #
598
+ # ``rebutted`` is intentionally absent: it is a *mid-cycle* signal (host
599
+ # pushes back on the item while keeping the experiment moving). A reviewer
600
+ # may still transition ``rebutted → resolved`` or ``rebutted → open`` to
601
+ # accept the rebuttal or restart discussion. Collapsing it to ``closed``
602
+ # would break that follow-up path, so the legacy value is preserved as-is
603
+ # at the database layer until a follow-up transition completes.
604
+ _TERMINAL_STATUS_REASONS: dict[ReviewItemStatus, ResolutionReason] = {
605
+ ReviewItemStatus.resolved: ResolutionReason.resolved,
606
+ ReviewItemStatus.withdrawn: ResolutionReason.superseded,
607
+ }
608
+
609
+
610
+ def _normalize_terminal_status(
611
+ target: ReviewItemStatus,
612
+ ) -> tuple[ReviewItemStatus, ResolutionReason | None]:
613
+ """Rewrite legacy terminal values to ``closed{reason}``.
614
+
615
+ Callers that send ``resolved`` / ``rebutted`` / ``withdrawn`` continue to
616
+ succeed; the row is stored as ``closed`` with the matching
617
+ ``last_resolution_reason`` so the new terminal state is the single
618
+ canonical representation in the database.
619
+ """
620
+ reason = _TERMINAL_STATUS_REASONS.get(target)
621
+ if reason is None:
622
+ return target, None
623
+ return ReviewItemStatus.closed, reason
624
+
625
+
626
+ def _latest_verdict_reasons_by_item(
627
+ db: Session, experiment_id: uuid.UUID
628
+ ) -> dict[uuid.UUID, str]:
629
+ """Look up the most recent accept-result verdict_file and return a map of
630
+ item_id → waived_reason for items where verdict == 'waived'.
631
+
632
+ Reads ``experiment_logs.metadata_json.verdict_file`` (written by
633
+ ``phase_service.accept_result`` when a structured verdict file is
634
+ supplied). Returns an empty map when no verdict log exists yet.
635
+ """
636
+ latest = db.scalar(
637
+ select(ExperimentLog)
638
+ .where(
639
+ ExperimentLog.experiment_id == experiment_id,
640
+ ExperimentLog.metadata_json.is_not(None),
641
+ )
642
+ .order_by(ExperimentLog.created_at.desc())
643
+ .limit(1)
644
+ )
645
+ if latest is None or not isinstance(latest.metadata_json, dict):
646
+ return {}
647
+ verdict_file = latest.metadata_json.get("verdict_file")
648
+ if not isinstance(verdict_file, dict):
649
+ return {}
650
+ reasons: dict[uuid.UUID, str] = {}
651
+ for verdict in verdict_file.get("verdicts", []) or []:
652
+ if not isinstance(verdict, dict):
653
+ continue
654
+ if verdict.get("verdict") != ReviewVerdict.waived.value:
655
+ continue
656
+ raw_id = verdict.get("item_id")
657
+ reason = verdict.get("reason")
658
+ if not raw_id or not reason:
659
+ continue
660
+ try:
661
+ reasons[uuid.UUID(str(raw_id))] = str(reason)
662
+ except (ValueError, TypeError):
663
+ continue
664
+ return reasons
665
+
666
+
667
+ def review_to_read(
668
+ db: Session,
669
+ review: Review,
670
+ *,
671
+ verdict_reasons: dict[uuid.UUID, str] | None = None,
672
+ ) -> ReviewRead:
673
+ """Render a ``Review`` ORM row as the API read model.
674
+
675
+ Args:
676
+ db: SQLAlchemy session.
677
+ review: ORM row (must have ``items`` eagerly loaded — see
678
+ :func:`list_reviews` which uses ``joinedload(Review.items)``).
679
+ verdict_reasons: Pre-computed map of ``ReviewItem.id → waived_reason``
680
+ for items where the latest accept-result verdict was ``waived``.
681
+ When ``None`` (the default for single-review callers), the
682
+ latest verdict log is fetched on demand. When the caller is
683
+ rendering multiple reviews for the same experiment (e.g.
684
+ ``get_experiment_bundle``), passing a precomputed map
685
+ collapses R redundant ``SELECT … FROM experiment_logs`` to 1.
686
+ """
687
+ reasons = (
688
+ verdict_reasons
689
+ if verdict_reasons is not None
690
+ else _latest_verdict_reasons_by_item(db, review.experiment_id)
691
+ )
692
+ items = []
693
+ for i in review.items:
694
+ item_read = ReviewItemRead.model_validate(i)
695
+ item_read.waived_reason = reasons.get(i.id)
696
+ items.append(item_read)
697
+ return ReviewRead(
698
+ id=review.id,
699
+ experiment_id=review.experiment_id,
700
+ reviewer_agent_id=review.reviewer_agent_id,
701
+ plan_version=review.plan_version,
702
+ substitute_kind=review.substitute_kind,
703
+ created_at=review.created_at,
704
+ archived_at=review.archived_at,
705
+ archived_reason=review.archived_reason,
706
+ items=items,
707
+ )