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,138 @@
1
+ """In-process pub/sub for agent notification SSE streams.
2
+
3
+ 支持 ``Last-Event-ID`` 重连(P2 #9):
4
+
5
+ - 每个 agent 维护一个单调递增的 ``event_id``(从 1 开始),每个发布的
6
+ event 都带一个 id。
7
+ - SSE frame 形如 ``id: {event_id}\\ndata: {json}\\n\\n``,浏览器
8
+ ``EventSource`` 会自动记录最后接收的 id,断线重连时通过
9
+ ``Last-Event-ID`` 请求头传回。
10
+ - 服务端读取该 header,从 per-agent ring buffer 中 replay id 大于它的
11
+ 事件,再进入正常的 pub/sub 循环。
12
+
13
+ 注意:本实现是 in-process(重启即丢),ring buffer 容量 200 条,覆盖
14
+ 常见的网络抖动断线重连场景。跨进程或重启场景需要持久化事件历史,
15
+ 那是后续 P3 的范围。
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import asyncio
21
+ import contextlib
22
+ import json
23
+ import threading
24
+ import uuid
25
+ from collections import deque
26
+ from queue import Empty, Queue
27
+ from typing import Any
28
+
29
+ from fastapi.responses import StreamingResponse
30
+
31
+ HEARTBEAT_SECONDS = 25
32
+ # 每个 agent 保留最近多少条事件用于 Last-Event-ID replay。
33
+ # 200 条对 wakeable 通知足够(远多于一次 remind 周期内的量),
34
+ # 内存开销可控(每条 ~1KB JSON × 200 × N agents)。
35
+ REPLAY_BUFFER_SIZE = 200
36
+
37
+ _subscribers: dict[uuid.UUID, list[Queue[str]]] = {}
38
+ # per-agent 单调事件 id 计数器 + ring buffer。
39
+ # (next_id, [(event_id, payload_json), ...])
40
+ _event_seqs: dict[uuid.UUID, int] = {}
41
+ _replay_buffers: dict[uuid.UUID, deque[tuple[int, str]]] = {}
42
+ _lock = threading.Lock()
43
+
44
+
45
+ def subscribe(agent_id: uuid.UUID) -> Queue[str]:
46
+ queue: Queue[str] = Queue(maxsize=100)
47
+ with _lock:
48
+ _subscribers.setdefault(agent_id, []).append(queue)
49
+ return queue
50
+
51
+
52
+ def unsubscribe(agent_id: uuid.UUID, queue: Queue[str]) -> None:
53
+ with _lock:
54
+ queues = _subscribers.get(agent_id)
55
+ if not queues:
56
+ return
57
+ try:
58
+ queues.remove(queue)
59
+ except ValueError:
60
+ return
61
+ if not queues:
62
+ _subscribers.pop(agent_id, None)
63
+
64
+
65
+ def publish(agent_id: uuid.UUID, event: dict[str, Any]) -> None:
66
+ """发布一个事件,分配单调 id 并写入 per-agent ring buffer。
67
+
68
+ event dict 会被原样序列化(caller 看到的字段不变),SSE 层在 frame
69
+ 外面套 ``id:`` 行;客户端解析时 ``event.id`` 即此 id。
70
+ """
71
+ payload = json.dumps(event, ensure_ascii=False)
72
+ with _lock:
73
+ _event_seqs[agent_id] = _event_seqs.get(agent_id, 0) + 1
74
+ event_id = _event_seqs[agent_id]
75
+ buf = _replay_buffers.setdefault(agent_id, deque(maxlen=REPLAY_BUFFER_SIZE))
76
+ buf.append((event_id, payload))
77
+ queues = list(_subscribers.get(agent_id, []))
78
+ frame = f"id: {event_id}\ndata: {payload}\n\n"
79
+ for queue in queues:
80
+ with contextlib.suppress(Exception):
81
+ queue.put_nowait(frame)
82
+
83
+
84
+ def publish_many(agent_ids: list[uuid.UUID], event: dict[str, Any]) -> None:
85
+ for agent_id in agent_ids:
86
+ publish(agent_id, event)
87
+
88
+
89
+ def _drain_replay(agent_id: uuid.UUID, last_event_id: int) -> list[str]:
90
+ """返回 agent 的 ring buffer 中 id > last_event_id 的事件 frames。"""
91
+ with _lock:
92
+ buf = _replay_buffers.get(agent_id)
93
+ if not buf:
94
+ return []
95
+ return [f"id: {eid}\ndata: {payload}\n\n" for eid, payload in buf if eid > last_event_id]
96
+
97
+
98
+ def _parse_last_event_id(header_value: str | None) -> int:
99
+ """解析 ``Last-Event-ID`` header,非法或缺失返回 0。"""
100
+ if not header_value:
101
+ return 0
102
+ try:
103
+ return max(0, int(header_value.strip()))
104
+ except (ValueError, TypeError):
105
+ return 0
106
+
107
+
108
+ async def _sse_generator(agent_id: uuid.UUID, last_event_id: int = 0):
109
+ # 1. 先 replay 历史事件(id > last_event_id)
110
+ for frame in _drain_replay(agent_id, last_event_id):
111
+ yield frame
112
+ # 2. 再进入正常 pub/sub 循环
113
+ queue = subscribe(agent_id)
114
+ try:
115
+ while True:
116
+ try:
117
+ data = await asyncio.to_thread(queue.get, True, HEARTBEAT_SECONDS)
118
+ yield data
119
+ except Empty:
120
+ yield ": heartbeat\n\n"
121
+ finally:
122
+ unsubscribe(agent_id, queue)
123
+
124
+
125
+ def notification_sse_response(agent_id: uuid.UUID, last_event_id_header: str | None = None) -> StreamingResponse:
126
+ last_event_id = _parse_last_event_id(last_event_id_header)
127
+ return StreamingResponse(
128
+ _sse_generator(agent_id, last_event_id),
129
+ media_type="text/event-stream",
130
+ headers={
131
+ "Cache-Control": "no-cache",
132
+ "Connection": "keep-alive",
133
+ "X-Accel-Buffering": "no",
134
+ # 告知客户端支持 Last-Event-ID 重连(虽然 EventSource 默认就支持,
135
+ # 显式声明便于其他客户端实现识别)。
136
+ "X-Accel-Allow-Last-Event-ID": "true",
137
+ },
138
+ )
@@ -0,0 +1,147 @@
1
+ from __future__ import annotations
2
+
3
+ import uuid
4
+
5
+ from sqlalchemy import select
6
+ from sqlalchemy.orm import Session, joinedload
7
+
8
+ from server.domain.models import (
9
+ Agent,
10
+ AgentRole,
11
+ Comment,
12
+ Experiment,
13
+ PlanVersion,
14
+ ProjectStatusVersion,
15
+ Review,
16
+ ReviewItem,
17
+ Topic,
18
+ TopicActionItem,
19
+ TopicComment,
20
+ )
21
+ from server.services.errors import ForbiddenError, NotFoundError, UnauthorizedError
22
+
23
+
24
+ def is_admin(agent: Agent) -> bool:
25
+ return agent.role == AgentRole.admin
26
+
27
+
28
+ def require_admin(agent: Agent) -> None:
29
+ if not is_admin(agent):
30
+ raise ForbiddenError("Admin role required")
31
+
32
+
33
+ def ensure_project_access(agent: Agent, project_id: uuid.UUID) -> None:
34
+ if is_admin(agent):
35
+ return
36
+ if agent.project_id != project_id:
37
+ raise ForbiddenError("Access denied to this project")
38
+
39
+
40
+ def ensure_can_revise_project_status(agent: Agent, project_id: uuid.UUID) -> None:
41
+ """Admin or project-bound agent (e.g. host persona) may revise Current Status MD."""
42
+ ensure_project_access(agent, project_id)
43
+
44
+
45
+ def ensure_experiment_access(db: Session, agent: Agent, experiment_id: uuid.UUID) -> Experiment:
46
+ experiment = db.get(Experiment, experiment_id)
47
+ if experiment is None or experiment.deleted_at is not None:
48
+ raise NotFoundError("Experiment not found")
49
+ ensure_project_access(agent, experiment.project_id)
50
+ return experiment
51
+
52
+
53
+ def ensure_review_item_access(db: Session, agent: Agent, item_id: uuid.UUID) -> ReviewItem:
54
+ stmt = select(ReviewItem).where(ReviewItem.id == item_id).options(joinedload(ReviewItem.review))
55
+ item = db.scalar(stmt)
56
+ if item is None:
57
+ raise NotFoundError("Review item not found")
58
+ ensure_experiment_access(db, agent, item.review.experiment_id)
59
+ return item
60
+
61
+
62
+ def ensure_topic_access(db: Session, agent: Agent, topic_id: uuid.UUID) -> Topic:
63
+ topic = db.get(Topic, topic_id)
64
+ if topic is None or topic.deleted_at is not None:
65
+ raise NotFoundError("Topic not found")
66
+ ensure_project_access(agent, topic.project_id)
67
+ return topic
68
+
69
+
70
+ def ensure_topic_host_or_admin(db: Session, agent: Agent, topic_id: uuid.UUID) -> Topic:
71
+ topic = ensure_topic_access(db, agent, topic_id)
72
+ if topic.creator_agent_id != agent.id and not is_admin(agent):
73
+ raise ForbiddenError("Only the topic host or admin can manage the topic")
74
+ return topic
75
+
76
+
77
+ def ensure_topic_comment_access(db: Session, agent: Agent, comment_id: uuid.UUID) -> TopicComment:
78
+ comment = db.get(TopicComment, comment_id)
79
+ if comment is None:
80
+ raise NotFoundError("Topic comment not found")
81
+ ensure_topic_access(db, agent, comment.topic_id)
82
+ return comment
83
+
84
+
85
+ def ensure_audit_target_access(
86
+ db: Session,
87
+ agent: Agent,
88
+ target_type: str,
89
+ target_id: uuid.UUID,
90
+ ) -> None:
91
+ if target_type == "experiment":
92
+ ensure_experiment_access(db, agent, target_id)
93
+ elif target_type == "topic":
94
+ ensure_topic_access(db, agent, target_id)
95
+ elif target_type == "plan_version":
96
+ plan = db.get(PlanVersion, target_id)
97
+ if plan is None:
98
+ raise NotFoundError("Plan version not found")
99
+ ensure_experiment_access(db, agent, plan.experiment_id)
100
+ elif target_type == "review":
101
+ review = db.get(Review, target_id)
102
+ if review is None:
103
+ raise NotFoundError("Review not found")
104
+ ensure_experiment_access(db, agent, review.experiment_id)
105
+ elif target_type == "comment":
106
+ comment = db.get(Comment, target_id)
107
+ if comment is None:
108
+ raise NotFoundError("Comment not found")
109
+ ensure_experiment_access(db, agent, comment.experiment_id)
110
+ elif target_type == "project_status_version":
111
+ version = db.get(ProjectStatusVersion, target_id)
112
+ if version is None:
113
+ raise NotFoundError("Project status version not found")
114
+ ensure_project_access(agent, version.project_id)
115
+ elif target_type == "topic_action_item":
116
+ item = db.get(TopicActionItem, target_id)
117
+ if item is None:
118
+ raise NotFoundError("Action item not found")
119
+ ensure_project_access(agent, item.project_id)
120
+ else:
121
+ raise NotFoundError(f"Unknown audit target type: {target_type}")
122
+
123
+
124
+ def resolve_project_id_for_agent(agent: Agent, project_id: uuid.UUID | None) -> uuid.UUID:
125
+ if is_admin(agent):
126
+ if project_id is None:
127
+ raise ForbiddenError("Admin must specify project_id")
128
+ return project_id
129
+ if agent.project_id is None:
130
+ raise ForbiddenError("Agent is not bound to a project")
131
+ if project_id is not None and project_id != agent.project_id:
132
+ raise ForbiddenError("Access denied to this project")
133
+ return agent.project_id
134
+
135
+
136
+ def ensure_can_register_agent(db: Session, actor: Agent | None, role: AgentRole) -> None:
137
+ """Allow unauthenticated bootstrap of the first admin; thereafter require admin."""
138
+ from sqlalchemy import func, select
139
+
140
+ count = db.scalar(select(func.count()).select_from(Agent)) or 0
141
+ if count == 0:
142
+ if role != AgentRole.admin:
143
+ raise ForbiddenError("Bootstrap: first agent must have role=admin")
144
+ return
145
+ if actor is None:
146
+ raise UnauthorizedError("Authentication required")
147
+ require_admin(actor)
@@ -0,0 +1,108 @@
1
+ """0db51e10 I3(5f): 默认选人逻辑(活跃 agent 时间窗 N=7)服务端 helper.
2
+
3
+ 承接 plan v2 (5c) / acceptance (f):
4
+
5
+ > 默认选人逻辑单元测试(覆盖 project_id 边界 + agent 状态过滤 +
6
+ > N=7 时间窗边界:恰好 7 天 / 8 天动作 / 7 天内无动作三种边界 case)
7
+
8
+ 判定: "活跃 agent" = **最近 7 天(N=7)内有 `log` / `topic` /
9
+ `review` 任一动作的同 project agent**。N=7 可配置(默认 7),过期 agent
10
+ 自动排除。
11
+
12
+ 沿用 ``escalation_resolver._recent_same_role_agent`` 的判定来源
13
+ (experiment_logs / comments / reviews 三个 source table),但
14
+ 本函数返回**所有** role 的活跃 agent 集合(用于 CLI
15
+ ``--persona-compare`` 默认选人),不做 single-role 过滤与
16
+ exclude_agent_id。
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import uuid
22
+ from datetime import UTC, datetime, timedelta
23
+
24
+ from sqlalchemy import select
25
+ from sqlalchemy.orm import Session
26
+
27
+ from server.domain.models import Agent, ExperimentLog, Review, TopicComment
28
+
29
+ # 7 天窗口作为 plan v2 (5c) 默认;CLI/SDK 调用方可通过参数覆盖。
30
+ DEFAULT_ACTIVE_WINDOW_DAYS = 7
31
+
32
+
33
+ def list_active_personas(
34
+ db: Session,
35
+ *,
36
+ project_id: uuid.UUID,
37
+ window_days: int = DEFAULT_ACTIVE_WINDOW_DAYS,
38
+ now: datetime | None = None,
39
+ ) -> list[uuid.UUID]:
40
+ """Return agent UUIDs active in ``project_id`` within the time window.
41
+
42
+ "Active" = any row in ``experiment_logs`` / ``comments`` / ``reviews``
43
+ with ``created_at >= now - window_days`` AND the author /
44
+ reviewer agent belongs to ``project_id``. The function returns the
45
+ **distinct set** of agent UUIDs that satisfy the predicate; no
46
+ ordering guarantee beyond stable iteration order.
47
+
48
+ Args:
49
+ db: SQLAlchemy session.
50
+ project_id: Project scope for the lookup.
51
+ window_days: Lookback window in days (default 7, matches plan
52
+ v2 (5c) / (f) acceptance). Must be >= 1; ``window_days=0``
53
+ is rejected because it would always return zero rows and
54
+ provide a confusing default.
55
+ now: Reference "now" timestamp. Defaults to ``datetime.now(UTC)``.
56
+ Tests inject a fixed value to pin the boundary at exactly
57
+ 7 / 8 / 0 days.
58
+
59
+ Returns:
60
+ List of distinct agent UUIDs active in the window. May be empty
61
+ when no agent has any recent action. Stable order across
62
+ invocations given the same DB state.
63
+ """
64
+ if window_days < 1:
65
+ raise ValueError(
66
+ f"window_days must be >= 1 (got {window_days}); "
67
+ "use the default 7 (plan v2 (5c)) or larger."
68
+ )
69
+ if now is None:
70
+ now = datetime.now(UTC)
71
+ cutoff = now - timedelta(days=window_days)
72
+
73
+ project_agents = set(
74
+ db.scalars(select(Agent.id).where(Agent.project_id == project_id)).all()
75
+ )
76
+ if not project_agents:
77
+ return []
78
+
79
+ active: set[uuid.UUID] = set()
80
+
81
+ log_authors = db.execute(
82
+ select(ExperimentLog.author_agent_id).where(
83
+ ExperimentLog.created_at >= cutoff,
84
+ ExperimentLog.author_agent_id.in_(project_agents),
85
+ )
86
+ ).all()
87
+ for (agent_id,) in log_authors:
88
+ active.add(agent_id)
89
+
90
+ comment_authors = db.execute(
91
+ select(TopicComment.author_agent_id).where(
92
+ TopicComment.created_at >= cutoff,
93
+ TopicComment.author_agent_id.in_(project_agents),
94
+ )
95
+ ).all()
96
+ for (agent_id,) in comment_authors:
97
+ active.add(agent_id)
98
+
99
+ reviewer_ids = db.execute(
100
+ select(Review.reviewer_agent_id).where(
101
+ Review.created_at >= cutoff,
102
+ Review.reviewer_agent_id.in_(project_agents),
103
+ )
104
+ ).all()
105
+ for (agent_id,) in reviewer_ids:
106
+ active.add(agent_id)
107
+
108
+ return list(active)
@@ -0,0 +1,95 @@
1
+ """Phase → owner resolution (experiment f873c287 I1(b)).
2
+
3
+ A single source of truth that maps each ``ExperimentPhase`` value to the
4
+ persona who currently holds decision authority to *advance* that phase.
5
+
6
+ This module is intentionally pure (no DB, no side effects) so the
7
+ ``my_open_experiments.partition`` layer, the UI copy helper, and the
8
+ ``experiments_needing_attention`` filter can all import the same table
9
+ without a circular dependency.
10
+
11
+ Schema: ``PhaseOwner`` enum lives in ``sdk/python/map_types/enums.py`` and
12
+ is the public contract; the string values here MUST match it. Keeping a
13
+ local mirror avoids importing the SDK inside Alembic / CLI layers that
14
+ should not pull pydantic.
15
+
16
+ Why per phase? The decision-owner semantics are stable across runs (see
17
+ experiment plan v2 I1(b) acceptance), but the resolver makes it easy to
18
+ re-route by changing one mapping instead of grepping every consumer.
19
+
20
+ Caveats
21
+ -------
22
+ - ``revise`` is not an ``ExperimentPhase`` value (revising happens inside
23
+ the ``review`` phase via ``plan_revise``); we keep it in the table as a
24
+ "logical phase" so callers can ask ``owner_for("revise")`` when
25
+ computing informational_only / UI copy during plan-revise windows.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ from typing import Final
31
+
32
+ # Reuse the SDK enum so the public contract stays single-source. If the
33
+ # enum is ever renamed, this import will surface that immediately at the
34
+ # service layer.
35
+ from map_types.enums import ExperimentPhase, PhaseOwner
36
+
37
+ # Mirror the enum values verbatim so Alembic / non-SDK callers don't need
38
+ # to import pydantic. KEPT IN SYNC with PhaseOwner — assertion below.
39
+ _OWNERS: Final[dict[str, str]] = {
40
+ ExperimentPhase.draft.value: PhaseOwner.host.value,
41
+ ExperimentPhase.review.value: PhaseOwner.reviewer.value,
42
+ # "revise" is a logical sub-phase of review; ``plan_revise`` action
43
+ # is a host decision so the host owns advancement.
44
+ "revise": PhaseOwner.host.value,
45
+ ExperimentPhase.approved.value: PhaseOwner.host.value,
46
+ ExperimentPhase.running.value: PhaseOwner.host.value,
47
+ ExperimentPhase.result_review.value: PhaseOwner.reviewer.value,
48
+ ExperimentPhase.done.value: PhaseOwner.host.value,
49
+ ExperimentPhase.cancelled.value: PhaseOwner.host.value,
50
+ }
51
+
52
+ assert set(_OWNERS) == {*{p.value for p in ExperimentPhase}, "revise"}, (
53
+ f"phase_owner_resolver: phase coverage drifted. "
54
+ f"missing={set(p.value for p in ExperimentPhase) - _OWNERS.keys() - {'revise'}} "
55
+ f"extra={set(_OWNERS) - {p.value for p in ExperimentPhase} - {'revise'}}"
56
+ )
57
+
58
+
59
+ def owner_for(phase: str | ExperimentPhase) -> PhaseOwner:
60
+ """Return the persona who currently holds decision authority for ``phase``.
61
+
62
+ Accepts either an ``ExperimentPhase`` enum member or its raw string
63
+ value. Unknown phases fall back to ``host`` — the conservative default
64
+ that keeps ``informational_only`` false (which means the experiment
65
+ stays in the host's actionable obligation list).
66
+ """
67
+ key = phase.value if isinstance(phase, ExperimentPhase) else phase
68
+ return PhaseOwner(_OWNERS.get(key, PhaseOwner.host.value))
69
+
70
+
71
+ def is_informational_only(
72
+ phase: str | ExperimentPhase,
73
+ *,
74
+ actions: list[str],
75
+ blocked_on: str | None,
76
+ ) -> bool:
77
+ """I1(a) auto-classification predicate.
78
+
79
+ Returns True iff all three preconditions hold:
80
+
81
+ 1. ``actions`` is empty (host has no actionable next step).
82
+ 2. ``blocked_on`` is non-empty (a state-machine gate is engaged).
83
+ 3. ``phase_owner`` is **not** the host (decision authority is held by
84
+ another persona, so the host can only wait).
85
+
86
+ This predicate is the single source of truth for the
87
+ ``my_open_experiments.partition[].informational_only`` field; it is
88
+ re-used by the waker exclusion rule (I1(f)) and by the participant
89
+ filter (I1(e)).
90
+ """
91
+ if actions:
92
+ return False
93
+ if not blocked_on:
94
+ return False
95
+ return owner_for(phase) is not PhaseOwner.host