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,65 @@
1
+ class NotFoundError(Exception):
2
+ pass
3
+
4
+
5
+ class ConflictError(Exception):
6
+ pass
7
+
8
+
9
+ class BadRequestError(Exception):
10
+ """Caller-supplied payload is well-formed (syntactically valid JSON /
11
+ body) but semantically rejected — e.g. a missing cross-reference, an
12
+ out-of-range enum value, or a field combination that does not
13
+ satisfy a domain precondition. Maps to HTTP 400.
14
+
15
+ Distinct from pydantic's 422 (which fires on shape-level schema
16
+ failures before the handler runs).
17
+ """
18
+
19
+
20
+ class UnauthorizedError(Exception):
21
+ pass
22
+
23
+
24
+ class ForbiddenError(Exception):
25
+ pass
26
+
27
+
28
+ class StateTransitionError(Exception):
29
+ """A request was structurally valid but the domain state machine refuses
30
+ the requested transition.
31
+
32
+ Optional attributes are surfaced to the HTTP layer by
33
+ :func:`server.main.register_domain_exception_handlers` so callers can
34
+ programmatically route on the failure mode instead of pattern-matching
35
+ the human-readable ``detail`` string:
36
+
37
+ - ``error_code`` — stable machine identifier (default ``"state_machine_error"``).
38
+ I1(d) introduces the ``REVIEW_REJECT_RESULT_MISUSE`` subcode so the
39
+ client can distinguish "wrong CLI command for this intent" from
40
+ generic state-machine refusals.
41
+ - ``hint`` — short remediation hint surfaced to operators and CLI users.
42
+ - ``retryable`` — whether the caller can succeed by retrying with the
43
+ same input after fixing state out-of-band. Always ``False`` for misuse
44
+ subcodes; preserved as a structured field for future retryable errors.
45
+ """
46
+
47
+ error_code: str = "state_machine_error"
48
+ hint: str | None = None
49
+ retryable: bool = False
50
+
51
+ def __init__(
52
+ self,
53
+ message: str,
54
+ *,
55
+ error_code: str | None = None,
56
+ hint: str | None = None,
57
+ retryable: bool | None = None,
58
+ ) -> None:
59
+ super().__init__(message)
60
+ if error_code is not None:
61
+ self.error_code = error_code
62
+ if hint is not None:
63
+ self.hint = hint
64
+ if retryable is not None:
65
+ self.retryable = retryable
@@ -0,0 +1,248 @@
1
+ """STATE_MACHINE.* error escalation contact resolver (experiment 156172e9 I1(b)).
2
+
3
+ Resolves which agent should be surfaced as the recovery contact when a
4
+ STATE_MACHINE.* error fires. The lookup is two-tier:
5
+
6
+ 1. ``Experiment.escalation_target_agent_id`` override (set by host creator
7
+ on a per-experiment basis; NULL by default).
8
+ 2. Role-based fallback chain documented in plan (b):
9
+ a. Current caller agent (avoid cross-role mis-routing; preferred
10
+ since the caller usually has the most context).
11
+ b. Same-role + same-project active agent (any log / topic-comment /
12
+ review action in the last 7 days).
13
+ c. Admin role (last-resort).
14
+
15
+ The function never raises — callers use it in error-handling paths
16
+ where a missing escalation contact must degrade gracefully (the CLI
17
+ falls back to printing the bare error_code + hint instead of crashing).
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import uuid
23
+ from datetime import UTC, datetime, timedelta
24
+
25
+ from map_types.enums import AgentRole
26
+ from sqlalchemy import select
27
+ from sqlalchemy.orm import Session
28
+
29
+ from server.domain.models import Agent, Experiment, ExperimentLog, Review, TopicComment
30
+
31
+ # Activity window for "active agent" lookup (plan (b) 2b).
32
+ ACTIVE_LOOKBACK_DAYS = 7
33
+ _ACTIVITY_SINCE = timedelta(days=ACTIVE_LOOKBACK_DAYS)
34
+
35
+
36
+ def resolve_escalation_target(
37
+ db: Session,
38
+ *,
39
+ experiment: Experiment | None,
40
+ caller_agent_id: uuid.UUID | None,
41
+ project_id: uuid.UUID | None = None,
42
+ ) -> uuid.UUID | None:
43
+ """Pick the best escalation contact for a STATE_MACHINE.* error.
44
+
45
+ Returns the agent UUID, or ``None`` if no candidate exists (caller
46
+ should print the hint without an escalation name).
47
+
48
+ Args:
49
+ db: SQLAlchemy session.
50
+ experiment: Source experiment if available; used to read the
51
+ ``escalation_target_agent_id`` override.
52
+ caller_agent_id: Agent UUID of the CLI / SDK caller. Preferred
53
+ for the role-based fallback so the caller gets pinged about
54
+ their own action rather than a cross-role mis-route.
55
+ project_id: Project scope for the same-role active-agent lookup.
56
+ Defaults to ``experiment.project_id`` when ``experiment`` is
57
+ provided.
58
+ """
59
+ target_id, _tier = resolve_escalation_target_with_tier(
60
+ db,
61
+ experiment=experiment,
62
+ caller_agent_id=caller_agent_id,
63
+ project_id=project_id,
64
+ )
65
+ return target_id
66
+
67
+
68
+ def resolve_escalation_target_with_tier(
69
+ db: Session,
70
+ *,
71
+ experiment: Experiment | None,
72
+ caller_agent_id: uuid.UUID | None,
73
+ project_id: uuid.UUID | None = None,
74
+ ) -> tuple[uuid.UUID | None, str]:
75
+ """Same as ``resolve_escalation_target`` but also returns the chosen tier.
76
+
77
+ Tiers: ``"experiment_override"`` → ``"current_caller"`` →
78
+ ``"same_role_active"`` → ``"admin"`` → ``"none"``. The tier is exposed
79
+ on ``EscalationTargetRead.tier`` so CLI / SDK callers can show the
80
+ user *why* a contact was chosen.
81
+ """
82
+ if experiment is not None and experiment.escalation_target_agent_id is not None:
83
+ return experiment.escalation_target_agent_id, "experiment_override"
84
+
85
+ scope_project_id = project_id or (experiment.project_id if experiment is not None else None)
86
+ if scope_project_id is None:
87
+ target = _admin_agent(db)
88
+ return target, "admin" if target is not None else "none"
89
+
90
+ # Tier 2a: the current caller (if they belong to this project).
91
+ caller: Agent | None = None
92
+ if caller_agent_id is not None:
93
+ caller = db.get(Agent, caller_agent_id)
94
+ if (
95
+ caller is not None
96
+ and caller.project_id == scope_project_id
97
+ and caller.role != AgentRole.admin
98
+ ):
99
+ return caller.id, "current_caller"
100
+
101
+ # Tier 2b: same-role + same-project agent with any recent activity.
102
+ if caller is not None and caller.project_id == scope_project_id:
103
+ target_role = caller.role
104
+ exclude_id = caller.id
105
+ else:
106
+ target_role = AgentRole.agent
107
+ exclude_id = caller.id if caller is not None else None
108
+ same_role_id = _recent_same_role_agent(
109
+ db,
110
+ project_id=scope_project_id,
111
+ role=target_role,
112
+ exclude_agent_id=exclude_id,
113
+ )
114
+ if same_role_id is not None:
115
+ return same_role_id, "same_role_active"
116
+
117
+ # Tier 2c: admin — prefer same-project admin, fall back to any admin.
118
+ target = _admin_agent(db, project_id=scope_project_id)
119
+ return target, "admin" if target is not None else "none"
120
+
121
+
122
+ def _recent_same_role_agent(
123
+ db: Session,
124
+ *,
125
+ project_id: uuid.UUID,
126
+ role: AgentRole,
127
+ exclude_agent_id: uuid.UUID | None,
128
+ ) -> uuid.UUID | None:
129
+ """Return the most recently active same-role agent in the project.
130
+
131
+ "Active" = any row in ``experiment_logs`` / ``comments`` / ``reviews``
132
+ with ``created_at >= now - 7 days`` AND ``author_agent_id`` belongs
133
+ to an Agent of the requested role and project. Excludes
134
+ ``exclude_agent_id`` so the caller doesn't see themselves.
135
+
136
+ Tie-breaker: most recent ``created_at``. Returns ``None`` when no
137
+ same-role agent has any recent activity.
138
+
139
+ Implementation note: we run three small SELECTs (one per source
140
+ table) and pick the max ``created_at`` in Python. This avoids the
141
+ awkward ``CompoundSelect`` chaining for three ``union_all``s and
142
+ keeps the query small enough that the optimizer's row counts stay
143
+ predictable. The 7-day lookback bounds the row count regardless.
144
+ """
145
+ cutoff = datetime.now(UTC) - _ACTIVITY_SINCE
146
+
147
+ agents_in_project = select(Agent.id).where(
148
+ Agent.project_id == project_id, Agent.role == role
149
+ )
150
+ if exclude_agent_id is not None:
151
+ agents_in_project = agents_in_project.where(Agent.id != exclude_agent_id)
152
+ project_role_agents = set(db.scalars(agents_in_project).all())
153
+ if not project_role_agents:
154
+ return None
155
+
156
+ last_seen_by_agent: dict[uuid.UUID, datetime] = {}
157
+
158
+ log_rows = db.execute(
159
+ select(ExperimentLog.author_agent_id, ExperimentLog.created_at).where(
160
+ ExperimentLog.created_at >= cutoff,
161
+ ExperimentLog.author_agent_id.in_(project_role_agents),
162
+ )
163
+ ).all()
164
+ for agent_id, ts in log_rows:
165
+ prev = last_seen_by_agent.get(agent_id)
166
+ if prev is None or ts > prev:
167
+ last_seen_by_agent[agent_id] = ts
168
+
169
+ comment_rows = db.execute(
170
+ select(TopicComment.author_agent_id, TopicComment.created_at).where(
171
+ TopicComment.created_at >= cutoff,
172
+ TopicComment.author_agent_id.in_(project_role_agents),
173
+ )
174
+ ).all()
175
+ for agent_id, ts in comment_rows:
176
+ prev = last_seen_by_agent.get(agent_id)
177
+ if prev is None or ts > prev:
178
+ last_seen_by_agent[agent_id] = ts
179
+
180
+ review_rows = db.execute(
181
+ select(Review.reviewer_agent_id, Review.created_at).where(
182
+ Review.created_at >= cutoff,
183
+ Review.reviewer_agent_id.in_(project_role_agents),
184
+ )
185
+ ).all()
186
+ for agent_id, ts in review_rows:
187
+ prev = last_seen_by_agent.get(agent_id)
188
+ if prev is None or ts > prev:
189
+ last_seen_by_agent[agent_id] = ts
190
+
191
+ if not last_seen_by_agent:
192
+ return None
193
+ return max(last_seen_by_agent, key=lambda k: last_seen_by_agent[k])
194
+
195
+
196
+ def _admin_agent(db: Session, *, project_id: uuid.UUID | None = None) -> uuid.UUID | None:
197
+ """Return an admin agent, preferring the same-project admin.
198
+
199
+ Order: same-project admin (lex order by id) → any-project admin
200
+ (lex order by id). Returns ``None`` when no admin exists anywhere.
201
+ The order is deterministic per-project — when callers need a
202
+ specific admin, they should set ``experiment.escalation_target_agent_id``
203
+ instead.
204
+ """
205
+ if project_id is not None:
206
+ scoped = (
207
+ select(Agent.id)
208
+ .where(Agent.role == AgentRole.admin, Agent.project_id == project_id)
209
+ .order_by(Agent.id)
210
+ .limit(1)
211
+ )
212
+ row = db.execute(scoped).first()
213
+ if row is not None:
214
+ return row[0]
215
+ global_admin = (
216
+ select(Agent.id)
217
+ .where(Agent.role == AgentRole.admin)
218
+ .order_by(Agent.id)
219
+ .limit(1)
220
+ )
221
+ row = db.execute(global_admin).first()
222
+ return row[0] if row is not None else None
223
+
224
+
225
+ def escalation_label(
226
+ db: Session,
227
+ *,
228
+ escalation_target_id: uuid.UUID | None,
229
+ ) -> str:
230
+ """Render the escalation contact for CLI / SDK error messages.
231
+
232
+ Returns the agent name when found, or a placeholder when the agent
233
+ is missing (deleted / cross-project). Never raises.
234
+ """
235
+ if escalation_target_id is None:
236
+ return "<no escalation contact>"
237
+ agent = db.get(Agent, escalation_target_id)
238
+ if agent is None:
239
+ return f"<agent {escalation_target_id} not found>"
240
+ return f"@{agent.name}"
241
+
242
+
243
+ __all__ = [
244
+ "ACTIVE_LOOKBACK_DAYS",
245
+ "escalation_label",
246
+ "resolve_escalation_target",
247
+ "resolve_escalation_target_with_tier",
248
+ ]
@@ -0,0 +1,88 @@
1
+ from dataclasses import dataclass, field
2
+ from typing import Literal
3
+
4
+ from map_client.plan_evidence import (
5
+ PlanEvidenceKeys,
6
+ PlanFrontmatterParseError,
7
+ parse_plan_evidence_keys,
8
+ )
9
+
10
+ # arch experiment (0519e2a3) PR2: ``EVIDENCE_METADATA_KEYS`` and
11
+ # ``metadata_has_completion_evidence`` moved to ``map_sdk.evidence``.
12
+ # Keep them re-exported from here so existing server-side imports
13
+ # keep working without churning every call site in this PR. CLI
14
+ # switched to ``from map_sdk.evidence import ...`` already.
15
+ from map_sdk.evidence import ( # noqa: E402, F401
16
+ EVIDENCE_METADATA_KEYS,
17
+ metadata_has_completion_evidence,
18
+ )
19
+
20
+ # --- 8ac93d4e I1.b — plan evidence_keys soft validation ---------------------
21
+
22
+
23
+ WarningCode = Literal["MISSING_EVIDENCE_KEY"]
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class EvidenceWarning:
28
+ code: WarningCode
29
+ missing_key: str
30
+ plan_required: bool = True
31
+ log_provided: bool = False
32
+
33
+
34
+ @dataclass(frozen=True)
35
+ class EvidenceValidationResult:
36
+ warnings: list[EvidenceWarning] = field(default_factory=list)
37
+ parse_error: str | None = None
38
+ plan_keys: tuple[str, ...] = ()
39
+
40
+ @property
41
+ def valid(self) -> bool:
42
+ """Soft validation: always True. Host is expected to follow up
43
+ with another log if warnings indicate missing evidence."""
44
+ return True
45
+
46
+
47
+ def validate_log_evidence(
48
+ *,
49
+ plan_md: str | None,
50
+ metadata: dict | None,
51
+ ) -> EvidenceValidationResult:
52
+ """Compare ``metadata`` keys against plan ``evidence_keys``.
53
+
54
+ Cases:
55
+ * ``plan_md`` is None or empty → empty result (no plan to validate against).
56
+ * Plan has frontmatter but YAML parse fails → ``parse_error`` set,
57
+ no warnings (caller surfaces stderr warn).
58
+ * Plan frontmatter OK + has ``evidence_keys`` → return
59
+ ``MISSING_EVIDENCE_KEY`` warning for each plan key absent from
60
+ ``metadata``.
61
+ * Plan frontmatter OK but no ``evidence_keys`` key → empty result.
62
+ """
63
+ if not plan_md:
64
+ return EvidenceValidationResult()
65
+
66
+ try:
67
+ parsed: PlanEvidenceKeys = parse_plan_evidence_keys(plan_md)
68
+ except PlanFrontmatterParseError as exc:
69
+ return EvidenceValidationResult(parse_error=str(exc))
70
+
71
+ if not parsed.keys:
72
+ return EvidenceValidationResult(plan_keys=parsed.keys)
73
+
74
+ provided = set(metadata.keys()) if isinstance(metadata, dict) else set()
75
+ warnings: list[EvidenceWarning] = []
76
+ for key in parsed.keys:
77
+ if key not in provided:
78
+ warnings.append(
79
+ EvidenceWarning(
80
+ code="MISSING_EVIDENCE_KEY",
81
+ missing_key=key,
82
+ plan_required=True,
83
+ log_provided=False,
84
+ )
85
+ )
86
+ return EvidenceValidationResult(warnings=warnings, plan_keys=parsed.keys)
87
+
88
+
@@ -0,0 +1,277 @@
1
+ """Per-agent experiment actions and blocked_on (experiment plan v2 AC#3)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import uuid
6
+ from typing import Any
7
+
8
+ from map_types.enums import PhaseOwner
9
+ from sqlalchemy import func, select
10
+ from sqlalchemy.orm import Session, joinedload
11
+
12
+ from server.domain.models import (
13
+ Agent,
14
+ AgentRole,
15
+ Experiment,
16
+ ExperimentLog,
17
+ ExperimentPhase,
18
+ Review,
19
+ ReviewItem,
20
+ ReviewItemKind,
21
+ ReviewItemStatus,
22
+ )
23
+ from server.domain.schemas import ExperimentDetailRead, ExperimentSummaryRead
24
+ from server.services.review_service import (
25
+ _prior_version_reviews_fully_resolved,
26
+ _qualifying_non_creator_reviews,
27
+ _review_has_item_activity,
28
+ compute_legacy_self_review,
29
+ count_open_unreasonable_for_experiment,
30
+ has_review_on_older_plan_version,
31
+ )
32
+
33
+ _REPLY_STATES = (ReviewItemStatus.addressed, ReviewItemStatus.rebutted)
34
+
35
+ # 0db51e10 I3(5d): phase visibility whitelist. ``hidden_for_current_persona``
36
+ # is the per-actor fold semantics for phases excluded by the configured
37
+ # whitelist. Empty / ``None`` whitelist = every phase visible (backward
38
+ # compat). When a whitelist is configured, phases outside it return
39
+ # ``([], "hidden_for_current_persona")`` so the UI can fold the row
40
+ # without raising.
41
+ HIDDEN_FOR_CURRENT_PERSONA = "hidden_for_current_persona"
42
+
43
+
44
+ def _is_phase_visible(
45
+ phase: ExperimentPhase,
46
+ *,
47
+ phase_whitelist: list[ExperimentPhase] | None,
48
+ ) -> bool:
49
+ """Return True iff ``phase`` is in the configured whitelist.
50
+
51
+ ``None`` or empty whitelist = visible to every role (legacy
52
+ behaviour). When the whitelist is non-empty the predicate is
53
+ exact-match against ``phase.value`` so callers can pass either
54
+ ``[ExperimentPhase.result_review]`` or
55
+ ``["result_review"]`` interchangeably.
56
+ """
57
+ if not phase_whitelist:
58
+ return True
59
+ allowed = {p.value if isinstance(p, ExperimentPhase) else str(p) for p in phase_whitelist}
60
+ return phase.value in allowed
61
+
62
+
63
+ def _reviews_for_current_plan(db: Session, experiment: Experiment) -> list[Review]:
64
+ return list(
65
+ db.scalars(
66
+ select(Review)
67
+ .where(
68
+ Review.experiment_id == experiment.id,
69
+ Review.plan_version == experiment.current_plan_version,
70
+ )
71
+ .options(joinedload(Review.items))
72
+ ).unique()
73
+ )
74
+
75
+
76
+ def _has_non_creator_review(reviews: list[Review], creator_id: uuid.UUID) -> bool:
77
+ return len(_qualifying_non_creator_reviews(reviews, creator_id)) > 0
78
+
79
+
80
+ def _actor_review_for_current_plan(reviews: list[Review], actor_id: uuid.UUID) -> Review | None:
81
+ for review in reviews:
82
+ if review.reviewer_agent_id == actor_id:
83
+ return review
84
+ return None
85
+
86
+
87
+ def _count_open_status_unreasonable(db: Session, experiment_id: uuid.UUID) -> int:
88
+ stmt = (
89
+ select(func.count())
90
+ .select_from(ReviewItem)
91
+ .join(Review)
92
+ .where(
93
+ Review.experiment_id == experiment_id,
94
+ ReviewItem.kind == ReviewItemKind.unreasonable,
95
+ ReviewItem.status == ReviewItemStatus.open,
96
+ )
97
+ )
98
+ return db.scalar(stmt) or 0
99
+
100
+
101
+ def _reviewer_has_pending_addressed_items(
102
+ db: Session, experiment_id: uuid.UUID, actor_id: uuid.UUID
103
+ ) -> bool:
104
+ stmt = (
105
+ select(func.count())
106
+ .select_from(ReviewItem)
107
+ .join(Review)
108
+ .where(
109
+ Review.experiment_id == experiment_id,
110
+ Review.reviewer_agent_id == actor_id,
111
+ Review.archived_at.is_(None),
112
+ ReviewItem.kind == ReviewItemKind.unreasonable,
113
+ ReviewItem.status.in_(_REPLY_STATES),
114
+ )
115
+ )
116
+ return (db.scalar(stmt) or 0) > 0
117
+
118
+
119
+ def compute_experiment_capabilities(
120
+ db: Session,
121
+ experiment: Experiment,
122
+ actor: Agent,
123
+ *,
124
+ phase_whitelist: list[ExperimentPhase] | None = None,
125
+ ) -> tuple[list[str], str | None]:
126
+ is_creator = actor.id == experiment.creator_agent_id
127
+ is_admin = actor.role == AgentRole.admin
128
+ phase = experiment.phase
129
+
130
+ # 0db51e10 I3(5d): phase visibility whitelist. When configured and
131
+ # the current phase is NOT in the whitelist, fold the row as
132
+ # ``hidden_for_current_persona`` so the UI can collapse it without
133
+ # raising. Whitelist is dynamic — callers pass the resolved value
134
+ # from server config / per-test override, no module-level global.
135
+ if not _is_phase_visible(phase, phase_whitelist=phase_whitelist):
136
+ return [], HIDDEN_FOR_CURRENT_PERSONA
137
+
138
+ actions: list[str] = []
139
+ blocked_on: str | None = None
140
+
141
+ if phase == ExperimentPhase.result_review:
142
+ if is_creator:
143
+ return [], "awaiting_result_approval"
144
+ return ["accept_result", "reject_result"], "none"
145
+
146
+ if _reviewer_has_pending_addressed_items(db, experiment.id, actor.id):
147
+ return ["resolve_item"], "awaiting_addressed_item_ack"
148
+
149
+ if phase == ExperimentPhase.draft:
150
+ if is_creator or is_admin:
151
+ return ["submit_for_review"], "none"
152
+ return actions, blocked_on
153
+
154
+ if phase == ExperimentPhase.review:
155
+ reviews = _reviews_for_current_plan(db, experiment)
156
+ if is_creator or is_admin:
157
+ open_unreasonable = count_open_unreasonable_for_experiment(db, experiment.id)
158
+ if open_unreasonable > 0:
159
+ blocked_on = "open_unreasonable_item"
160
+ if _count_open_status_unreasonable(db, experiment.id) > 0:
161
+ actions.append("plan_revise")
162
+ elif not _has_non_creator_review(reviews, experiment.creator_agent_id):
163
+ if _prior_version_reviews_fully_resolved(db, experiment):
164
+ # Reviewer resolved every unreasonable item they raised on a
165
+ # prior plan version → counts as accepting the revision, so
166
+ # the creator may approve without a fresh current-version
167
+ # review (mirrors assert_approve_eligibility's carve-out).
168
+ blocked_on = "none"
169
+ actions = ["approve", "withdraw"]
170
+ elif has_review_on_older_plan_version(db, experiment):
171
+ blocked_on = "awaiting_review_for_current_plan_version"
172
+ else:
173
+ blocked_on = "awaiting_non_creator_review"
174
+ else:
175
+ blocked_on = "none"
176
+ actions = ["approve", "withdraw"]
177
+ return actions, blocked_on
178
+
179
+ if actor.id != experiment.creator_agent_id:
180
+ actor_review = _actor_review_for_current_plan(reviews, actor.id)
181
+ if actor_review is None:
182
+ return ["review_add"], "none"
183
+ if not _review_has_item_activity(actor_review):
184
+ return ["review_withdraw"], "none"
185
+ return actions, blocked_on
186
+
187
+ if is_creator or is_admin:
188
+ if phase == ExperimentPhase.approved:
189
+ return ["start"], "none"
190
+ if phase == ExperimentPhase.running:
191
+ return ["complete"], "none"
192
+
193
+ return actions, blocked_on
194
+
195
+
196
+ def experiment_summary_for_actor(
197
+ db: Session,
198
+ experiment: Experiment,
199
+ actor: Agent,
200
+ *,
201
+ extra_updates: dict[str, Any] | None = None,
202
+ phase_whitelist: list[ExperimentPhase] | None = None,
203
+ ) -> ExperimentSummaryRead:
204
+ from server.services.log_service import get_latest_log
205
+ from server.services.phase_owner_resolver import (
206
+ is_informational_only,
207
+ owner_for,
208
+ )
209
+
210
+ actions, blocked_on = compute_experiment_capabilities(
211
+ db, experiment, actor, phase_whitelist=phase_whitelist
212
+ )
213
+ legacy = compute_legacy_self_review(db, experiment)
214
+ # log_count / latest_log_summary: skip the per-experiment SELECTs
215
+ # when extra_updates already provides them — looping callers (e.g.
216
+ # get_todos over many experiments) pre-compute via the bulk helpers
217
+ # in log_service to drop the N+1.
218
+ has_log_count = extra_updates is not None and "log_count" in extra_updates
219
+ has_latest_summary = (
220
+ extra_updates is not None and "latest_log_summary" in extra_updates
221
+ )
222
+ if has_log_count:
223
+ log_count: int = extra_updates["log_count"]
224
+ else:
225
+ log_count = (
226
+ db.scalar(
227
+ select(func.count())
228
+ .select_from(ExperimentLog)
229
+ .where(ExperimentLog.experiment_id == experiment.id)
230
+ )
231
+ or 0
232
+ )
233
+ if has_latest_summary:
234
+ latest_log_summary: str | None = extra_updates["latest_log_summary"]
235
+ else:
236
+ latest_log = get_latest_log(db, experiment.id)
237
+ latest_log_summary = latest_log.summary if latest_log else None
238
+
239
+ update: dict[str, Any] = {
240
+ "actions": actions,
241
+ "blocked_on": blocked_on,
242
+ "legacy_self_review": legacy,
243
+ "log_count": log_count,
244
+ "latest_log_summary": latest_log_summary,
245
+ "phase_owner": owner_for(experiment.phase),
246
+ "informational_only": is_informational_only(
247
+ experiment.phase, actions=actions, blocked_on=blocked_on
248
+ ),
249
+ "hidden_for_current_persona": (
250
+ actions == []
251
+ and blocked_on == HIDDEN_FOR_CURRENT_PERSONA
252
+ ),
253
+ }
254
+ if extra_updates:
255
+ update.update(extra_updates)
256
+ return ExperimentSummaryRead.model_validate(experiment).model_copy(update=update)
257
+
258
+
259
+ def apply_capabilities_to_detail(
260
+ detail: ExperimentDetailRead,
261
+ actions: list[str],
262
+ blocked_on: str | None,
263
+ *,
264
+ legacy_self_review: bool = False,
265
+ phase_owner: PhaseOwner | None = None,
266
+ informational_only: bool | None = None,
267
+ ) -> ExperimentDetailRead:
268
+ update: dict[str, Any] = {
269
+ "actions": actions,
270
+ "blocked_on": blocked_on,
271
+ "legacy_self_review": legacy_self_review,
272
+ }
273
+ if phase_owner is not None:
274
+ update["phase_owner"] = phase_owner
275
+ if informational_only is not None:
276
+ update["informational_only"] = informational_only
277
+ return detail.model_copy(update=update)