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
map_client/config.py ADDED
@@ -0,0 +1,21 @@
1
+ import os
2
+ from pathlib import Path
3
+ from typing import Any
4
+
5
+ import yaml
6
+
7
+
8
+ def load_config(config_path: Path | None = None) -> dict[str, Any]:
9
+ path = config_path or Path.home() / ".map" / "config.yaml"
10
+ config: dict[str, Any] = {}
11
+ if path.exists():
12
+ config = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
13
+ api_url = config.get("api_url") or os.environ.get("MAP_API_URL", "http://localhost:8000")
14
+ token = config.get("token") or os.environ.get("MAP_TOKEN")
15
+ project_key = config.get("project_key") or os.environ.get("MAP_PROJECT_KEY")
16
+ return {
17
+ "api_url": str(api_url).rstrip("/"),
18
+ "token": token,
19
+ "project_key": project_key,
20
+ "config_path": path,
21
+ }
map_client/errors.py ADDED
@@ -0,0 +1,283 @@
1
+ """STATE_MACHINE.* error registry + recovery hint helper (experiment 156172e9 I1(a)).
2
+
3
+ The MAP state machine raises :class:`server.services.errors.StateTransitionError`
4
+ on illegal transitions (phase / review-item / topic-status). The exception
5
+ carries an optional ``error_code`` string (e.g. ``REVIEW_REJECT_RESULT_MISUSE``)
6
+ that callers can branch on programmatically instead of pattern-matching the
7
+ human-readable ``detail`` text.
8
+
9
+ This module:
10
+
11
+ 1. Defines the canonical ``STATE_MACHINE_*`` error code constants so callers
12
+ can ``from map_client.errors import STATE_MACHINE_REJECT_RESULT_MISUSE``
13
+ instead of typo-prone string literals.
14
+ 2. Maintains a :data:`RECOVERY_HINTS` registry mapping each code to a
15
+ short, actionable recovery hint (one canonical line that says "try X" or
16
+ "see Y").
17
+ 3. Exposes :func:`recovery_hint` so SDK callers — and the CLI ``_run``
18
+ handler — can look up the hint by error code (or by passing the
19
+ ``MAPHTTPError`` instance directly).
20
+
21
+ The hint registry is the **single source of truth** for the SDK-side
22
+ recovery narrative; the server-side ``StateTransitionError.hint`` field is
23
+ still authoritative for HTTP errors. The SDK helper only kicks in when:
24
+
25
+ - the caller wants a hint for a known STATE_MACHINE.* code without making
26
+ an HTTP call (e.g. unit tests, scripted pre-flight, agent planning); or
27
+ - the server did not include a ``hint`` field in the response (older API
28
+ versions / legacy fallbacks).
29
+
30
+ Fixtures live in ``tests/fixtures/state_machine_errors.yaml`` and are
31
+ loaded by tests; the registry in this module is the canonical Python
32
+ mapping.
33
+ """
34
+
35
+ from __future__ import annotations
36
+
37
+ from typing import TYPE_CHECKING
38
+
39
+ if TYPE_CHECKING:
40
+ from map_client.exceptions import MAPHTTPError
41
+
42
+
43
+ # --- STATE_MACHINE.* error code constants ---------------------------------
44
+ #
45
+ # Naming convention: ``STATE_MACHINE_<DOMAIN>_<REASON>``. Each constant
46
+ # corresponds to one server-side ``StateTransitionError(error_code=...)``
47
+ # call site; the test suite asserts every constant has a non-empty hint.
48
+ #
49
+ # When the server adds a new code, add the constant here + a hint in
50
+ # RECOVERY_HINTS in the same commit; do not let the constants drift.
51
+
52
+ # Phase machine: experiment / topic phase transitions.
53
+ STATE_MACHINE_INVALID_PHASE_TRANSITION = "STATE_MACHINE_INVALID_PHASE_TRANSITION"
54
+ STATE_MACHINE_TERMINAL_PHASE = "STATE_MACHINE_TERMINAL_PHASE"
55
+ STATE_MACHINE_PLAN_REQUIRED = "STATE_MACHINE_PLAN_REQUIRED"
56
+ STATE_MACHINE_MISSING_EVIDENCE = "STATE_MACHINE_MISSING_EVIDENCE"
57
+ STATE_MACHINE_REVISE_OUT_OF_PHASE = "STATE_MACHINE_REVISE_OUT_OF_PHASE"
58
+ STATE_MACHINE_LOG_OUT_OF_PHASE = "STATE_MACHINE_LOG_OUT_OF_PHASE"
59
+ STATE_MACHINE_TOPIC_INVALID_TRANSITION = "STATE_MACHINE_TOPIC_INVALID_TRANSITION"
60
+ STATE_MACHINE_ACCEPTANCE_UNKNOWN_TYPE = "STATE_MACHINE_ACCEPTANCE_UNKNOWN_TYPE"
61
+
62
+ # Review-item machine: per-item status transitions.
63
+ STATE_MACHINE_REVIEW_ITEM_INVALID_TRANSITION = "STATE_MACHINE_REVIEW_ITEM_INVALID_TRANSITION"
64
+ STATE_MACHINE_REVIEW_ITEM_ACTOR_DENIED = "STATE_MACHINE_REVIEW_ITEM_ACTOR_DENIED"
65
+ STATE_MACHINE_REVIEW_ITEM_NOT_UNREASONABLE = "STATE_MACHINE_REVIEW_ITEM_NOT_UNREASONABLE"
66
+ STATE_MACHINE_REVIEW_ITEM_NO_STATUS = "STATE_MACHINE_REVIEW_ITEM_NO_STATUS"
67
+ STATE_MACHINE_REVIEW_ITEM_REBUT_AFTER_REVIEW = "STATE_MACHINE_REVIEW_ITEM_REBUT_AFTER_REVIEW"
68
+
69
+ # Review aggregate: review-submission / withdraw gates.
70
+ STATE_MACHINE_REVIEW_NOT_IN_REVIEW_PHASE = "STATE_MACHINE_REVIEW_NOT_IN_REVIEW_PHASE"
71
+ STATE_MACHINE_REVIEW_WITHDRAW_NOT_IN_REVIEW_PHASE = (
72
+ "STATE_MACHINE_REVIEW_WITHDRAW_NOT_IN_REVIEW_PHASE"
73
+ )
74
+
75
+ # Specific misuse subcode (carried forward from I1(d) — the first
76
+ # STATE_MACHINE.* code with a server-side hint).
77
+ STATE_MACHINE_REJECT_RESULT_MISUSE = "REVIEW_REJECT_RESULT_MISUSE"
78
+
79
+ # I1(d) — archived review cannot be mutated. Triggered by
80
+ # update_review_item / withdraw_review when the parent review row
81
+ # carries archived_at != NULL (set by plan_revise auto-archive, manual
82
+ # admin archive, or the migration 034 backfill marker).
83
+ STATE_MACHINE_REVIEW_ALREADY_ARCHIVED = "REVIEW_ALREADY_ARCHIVED"
84
+
85
+
86
+ # Default error code used by server-side ``StateTransitionError`` when no
87
+ # explicit ``error_code`` is supplied (legacy / pre-I1(d) call sites).
88
+ STATE_MACHINE_GENERIC = "state_machine_error"
89
+
90
+
91
+ # All known STATE_MACHINE.* error codes. Used by ``test_recovery_hint_*``
92
+ # to assert every code has a hint. Keep in sync with the constants above
93
+ # + any new ones; the test will fail loudly if you forget.
94
+ STATE_MACHINE_ERROR_CODES: frozenset[str] = frozenset(
95
+ {
96
+ # Phase
97
+ STATE_MACHINE_INVALID_PHASE_TRANSITION,
98
+ STATE_MACHINE_TERMINAL_PHASE,
99
+ STATE_MACHINE_PLAN_REQUIRED,
100
+ STATE_MACHINE_MISSING_EVIDENCE,
101
+ STATE_MACHINE_REVISE_OUT_OF_PHASE,
102
+ STATE_MACHINE_LOG_OUT_OF_PHASE,
103
+ STATE_MACHINE_TOPIC_INVALID_TRANSITION,
104
+ STATE_MACHINE_ACCEPTANCE_UNKNOWN_TYPE,
105
+ # Review item
106
+ STATE_MACHINE_REVIEW_ITEM_INVALID_TRANSITION,
107
+ STATE_MACHINE_REVIEW_ITEM_ACTOR_DENIED,
108
+ STATE_MACHINE_REVIEW_ITEM_NOT_UNREASONABLE,
109
+ STATE_MACHINE_REVIEW_ITEM_NO_STATUS,
110
+ STATE_MACHINE_REVIEW_ITEM_REBUT_AFTER_REVIEW,
111
+ # Review aggregate
112
+ STATE_MACHINE_REVIEW_NOT_IN_REVIEW_PHASE,
113
+ STATE_MACHINE_REVIEW_WITHDRAW_NOT_IN_REVIEW_PHASE,
114
+ # Specific misuse subcode
115
+ STATE_MACHINE_REJECT_RESULT_MISUSE,
116
+ # I1(d) — archived review refusal
117
+ STATE_MACHINE_REVIEW_ALREADY_ARCHIVED,
118
+ # Generic
119
+ STATE_MACHINE_GENERIC,
120
+ }
121
+ )
122
+
123
+
124
+ # Hint registry: code → recovery hint (one canonical actionable line).
125
+ #
126
+ # Conventions:
127
+ # - One short sentence per code (≤ 200 chars).
128
+ # - Lead with the action ("Run X", "Use Y", "Wait for Z"); avoid generic
129
+ # "Contact admin".
130
+ # - Reference the canonical CLI command when one exists.
131
+ # - When no command exists, point to the docs path.
132
+ #
133
+ # Tests assert that every value is a non-empty string. Empty / ``None``
134
+ # hints fail ``test_recovery_hint_covers_all_state_machine_codes``.
135
+ RECOVERY_HINTS: dict[str, str] = {
136
+ STATE_MACHINE_INVALID_PHASE_TRANSITION: (
137
+ "实验当前 phase 不允许此操作;运行 `map --persona host experiment "
138
+ "show --id <uuid>` 查看 current_phase + allowed transitions。"
139
+ ),
140
+ STATE_MACHINE_TERMINAL_PHASE: (
141
+ "实验已进入终态 (done / cancelled);变更实验需创建新实验。"
142
+ ),
143
+ STATE_MACHINE_PLAN_REQUIRED: (
144
+ "实验需要至少 1 个 plan 版本;先 `map --persona host experiment "
145
+ "plan revise --id <uuid> --plan-file <path>` 再 submit-review。"
146
+ ),
147
+ STATE_MACHINE_MISSING_EVIDENCE: (
148
+ "complete 需要 evidence metadata (pytest_summary / alembic_current / "
149
+ "api_health / image_digest 至少一项);参考 `map --persona host "
150
+ "experiment pre-complete --help`。非部署型实验用 "
151
+ "`--allow-missing-evidence`。"
152
+ ),
153
+ STATE_MACHINE_REVISE_OUT_OF_PHASE: (
154
+ "plan 只能在 draft / review / running 阶段修订;`map --persona host "
155
+ "experiment status --id <uuid>` 查看 phase。"
156
+ ),
157
+ STATE_MACHINE_LOG_OUT_OF_PHASE: (
158
+ "log 只能在 running / result_review / done 阶段追加;先 `start` 实验 "
159
+ "或等待 reviewer accept-result。"
160
+ ),
161
+ STATE_MACHINE_TOPIC_INVALID_TRANSITION: (
162
+ "话题状态机拒绝此转换;`map --persona host topic show --id <uuid>` "
163
+ "查看 allowed transitions(如 open↔closed 不能跨 archive)。"
164
+ ),
165
+ STATE_MACHINE_ACCEPTANCE_UNKNOWN_TYPE: (
166
+ "acceptance_type 必须是 migration / smoke / unit_test / integration / "
167
+ "manual 之一;详见 `map docs error-codes --search acceptance`。"
168
+ ),
169
+ STATE_MACHINE_REVIEW_ITEM_INVALID_TRANSITION: (
170
+ "review item 状态机拒绝此转换;`map --persona host experiment review "
171
+ "list --id <uuid>` 查看当前 status + allowed transitions。"
172
+ ),
173
+ STATE_MACHINE_REVIEW_ITEM_ACTOR_DENIED: (
174
+ "当前 persona 无权做此 item 转换;reviewer 转换需 is_reviewer,"
175
+ "host 转换需 is_creator。换 persona 或请 admin 介入。"
176
+ ),
177
+ STATE_MACHINE_REVIEW_ITEM_NOT_UNREASONABLE: (
178
+ "只有 unreasonable item 有 mutable status;reasonable item 是只读 "
179
+ "observation,调用方不需要 resolve。"
180
+ ),
181
+ STATE_MACHINE_REVIEW_ITEM_NO_STATUS: (
182
+ "review item 当前没有 status(通常是数据迁移遗留);"
183
+ "`map --persona reviewer experiment review add` 重新提交 review。"
184
+ ),
185
+ STATE_MACHINE_REVIEW_ITEM_REBUT_AFTER_REVIEW: (
186
+ "实验已过 review 阶段,单 item rebut 不再生效;若要驳回整个 result,"
187
+ "让 reviewer / admin 调用 `map --persona reviewer experiment "
188
+ "reject-result --id <uuid>`。"
189
+ ),
190
+ STATE_MACHINE_REVIEW_NOT_IN_REVIEW_PHASE: (
191
+ "review 只能在 review phase 提交;`map --persona host experiment "
192
+ "show --id <uuid>` 确认 phase=review。"
193
+ ),
194
+ STATE_MACHINE_REVIEW_WITHDRAW_NOT_IN_REVIEW_PHASE: (
195
+ "withdraw_review 只能在 review phase 执行;实验已进入下游 phase,"
196
+ "review 不能再撤。"
197
+ ),
198
+ STATE_MACHINE_REJECT_RESULT_MISUSE: (
199
+ "单 item 驳回请用 resolve-item --status rebutted (review 阶段); "
200
+ "整个实验驳回请让 reviewer / admin 调用 reject-result, "
201
+ "host creator 不可拒绝自己的 result。"
202
+ ),
203
+ STATE_MACHINE_REVIEW_ALREADY_ARCHIVED: (
204
+ "查看 `map experiment review list --include-archived --plan-version <v>` 历史; "
205
+ "若需要修改 item, 请在新的 plan_version 提交新 review。"
206
+ ),
207
+ STATE_MACHINE_GENERIC: (
208
+ "状态机拒绝此操作;详见 `map docs error-codes` 或 `map --persona "
209
+ "host experiment show --id <uuid>`。"
210
+ ),
211
+ }
212
+
213
+
214
+ # Default generic hint for codes outside the registry. Used when
215
+ # ``recovery_hint`` is called with an unknown code (forward compatibility
216
+ # for codes the SDK does not yet know about).
217
+ _GENERIC_FALLBACK_HINT = (
218
+ "未识别的 state_machine_error_code;查看 `map docs error-codes` 列表,"
219
+ "或升级 SDK 到最新版本以获得专属 hint。"
220
+ )
221
+
222
+
223
+ def recovery_hint(
224
+ exc_or_code: MAPHTTPError | str | None,
225
+ ) -> str:
226
+ """Return the recovery hint for a STATE_MACHINE.* error.
227
+
228
+ Accepts either:
229
+
230
+ - a :class:`MAPHTTPError` instance (reads its ``error_code`` attribute);
231
+ - a raw error code string;
232
+ - ``None`` (returns the generic fallback).
233
+
234
+ Lookup order:
235
+
236
+ 1. If the value carries an ``error_code`` attribute (or is a string),
237
+ use it as the lookup key.
238
+ 2. Look up the key in :data:`RECOVERY_HINTS`.
239
+ 3. If the key is not in the registry, return the generic fallback so
240
+ callers never see a ``KeyError`` / ``None`` from this helper.
241
+
242
+ The function never raises — callers can use it as the single entry
243
+ point in error handlers without ``try``/``except``.
244
+ """
245
+ if exc_or_code is None:
246
+ return _GENERIC_FALLBACK_HINT
247
+ code = getattr(exc_or_code, "error_code", None)
248
+ if code is None and isinstance(exc_or_code, str):
249
+ code = exc_or_code
250
+ if not code:
251
+ return _GENERIC_FALLBACK_HINT
252
+ return RECOVERY_HINTS.get(code, _GENERIC_FALLBACK_HINT)
253
+
254
+
255
+ __all__ = [
256
+ "STATE_MACHINE_ERROR_CODES",
257
+ "RECOVERY_HINTS",
258
+ "recovery_hint",
259
+ # Phase
260
+ "STATE_MACHINE_INVALID_PHASE_TRANSITION",
261
+ "STATE_MACHINE_TERMINAL_PHASE",
262
+ "STATE_MACHINE_PLAN_REQUIRED",
263
+ "STATE_MACHINE_MISSING_EVIDENCE",
264
+ "STATE_MACHINE_REVISE_OUT_OF_PHASE",
265
+ "STATE_MACHINE_LOG_OUT_OF_PHASE",
266
+ "STATE_MACHINE_TOPIC_INVALID_TRANSITION",
267
+ "STATE_MACHINE_ACCEPTANCE_UNKNOWN_TYPE",
268
+ # Review item
269
+ "STATE_MACHINE_REVIEW_ITEM_INVALID_TRANSITION",
270
+ "STATE_MACHINE_REVIEW_ITEM_ACTOR_DENIED",
271
+ "STATE_MACHINE_REVIEW_ITEM_NOT_UNREASONABLE",
272
+ "STATE_MACHINE_REVIEW_ITEM_NO_STATUS",
273
+ "STATE_MACHINE_REVIEW_ITEM_REBUT_AFTER_REVIEW",
274
+ # Review aggregate
275
+ "STATE_MACHINE_REVIEW_NOT_IN_REVIEW_PHASE",
276
+ "STATE_MACHINE_REVIEW_WITHDRAW_NOT_IN_REVIEW_PHASE",
277
+ # Misuse subcode
278
+ "STATE_MACHINE_REJECT_RESULT_MISUSE",
279
+ # I1(d) archived review refusal
280
+ "STATE_MACHINE_REVIEW_ALREADY_ARCHIVED",
281
+ # Generic
282
+ "STATE_MACHINE_GENERIC",
283
+ ]
@@ -0,0 +1,130 @@
1
+ """SDK 异常体系(P2 #2 错误统一映射)。
2
+
3
+ 层级:
4
+ MAPError
5
+ └── MAPHTTPError (4xx/5xx 通用,保留 status_code + detail)
6
+ ├── MAPClientError (4xx 客户端错误)
7
+ │ ├── MAPValidationError (400 / 422)
8
+ │ ├── MAPAuthenticationError (401)
9
+ │ ├── MAPPermissionError (403)
10
+ │ ├── MAPNotFoundError (404)
11
+ │ ├── MAPConflictError (409)
12
+ │ └── MAPRateLimitError (429)
13
+ └── MAPServerError (5xx 服务端错误)
14
+
15
+ 设计要点:
16
+ - 所有子类都是 ``MAPHTTPError`` 的子类,``except MAPHTTPError`` 仍能 catch
17
+ 全部 HTTP 错误(向后兼容)。
18
+ - ``MAPHTTPError`` 本身不再直接 raise;``_raise_for_status`` 根据 status_code
19
+ 实例化具体子类。CLI 可以 ``except MAPNotFoundError`` 写语义化错误处理,
20
+ 不用再 ``if exc.status_code == 404``。
21
+ - 未识别的 4xx 落到 ``MAPClientError``,未识别的 5xx 落到 ``MAPServerError``,
22
+ 方便 CLI 兜底。
23
+ """
24
+
25
+
26
+ class MAPError(Exception):
27
+ """Base SDK error."""
28
+
29
+
30
+ class MAPHTTPError(MAPError):
31
+ """HTTP error from MAP API.
32
+
33
+ 保留 ``status_code`` / ``detail`` 字段供调用方读取;具体子类对应
34
+ 常见 HTTP 状态码,调用方可 catch 子类写语义化处理。
35
+
36
+ I1(d) 还携带三个可选的结构化字段(由 server 端 StateTransitionError
37
+ 通过 exception handler 暴露):
38
+
39
+ - ``error_code`` — stable machine identifier,便于调用方按错误类型
40
+ 分支(例如 ``REVIEW_REJECT_RESULT_MISUSE`` 提示换用别的 CLI)。
41
+ - ``hint`` — 人类可读的修复提示,CLI 兜底展示。
42
+ - ``retryable`` — 是否值得用同样输入重试;misuse 子码恒为 ``False``。
43
+ """
44
+
45
+ def __init__(
46
+ self,
47
+ status_code: int,
48
+ detail: str,
49
+ *,
50
+ error_code: str | None = None,
51
+ hint: str | None = None,
52
+ retryable: bool | None = None,
53
+ ) -> None:
54
+ self.status_code = status_code
55
+ self.detail = detail
56
+ self.error_code = error_code
57
+ self.hint = hint
58
+ self.retryable = retryable
59
+ super().__init__(f"HTTP {status_code}: {detail}")
60
+
61
+
62
+ class MAPClientError(MAPHTTPError):
63
+ """4xx client error (未细分子类的兜底)。"""
64
+
65
+
66
+ class MAPServerError(MAPHTTPError):
67
+ """5xx server error."""
68
+
69
+
70
+ class MAPValidationError(MAPClientError):
71
+ """400 / 422 — 请求参数校验失败或语义错误。"""
72
+
73
+
74
+ class MAPAuthenticationError(MAPClientError):
75
+ """401 — 未认证或 token 失效。"""
76
+
77
+
78
+ class MAPPermissionError(MAPClientError):
79
+ """403 — 已认证但无权限。"""
80
+
81
+
82
+ class MAPNotFoundError(MAPClientError):
83
+ """404 — 资源不存在。"""
84
+
85
+
86
+ class MAPConflictError(MAPClientError):
87
+ """409 — 状态冲突(重复提交、状态机非法转换等)。"""
88
+
89
+
90
+ class MAPRateLimitError(MAPClientError):
91
+ """429 — 限流。"""
92
+
93
+
94
+ # status_code → exception class 映射表。
95
+ # 未列出的 4xx 落到 MAPClientError,5xx 落到 MAPServerError。
96
+ _STATUS_CODE_MAP: dict[int, type[MAPHTTPError]] = {
97
+ 400: MAPValidationError,
98
+ 401: MAPAuthenticationError,
99
+ 403: MAPPermissionError,
100
+ 404: MAPNotFoundError,
101
+ 409: MAPConflictError,
102
+ 422: MAPValidationError,
103
+ 429: MAPRateLimitError,
104
+ }
105
+
106
+
107
+ def raise_for_status(
108
+ status_code: int,
109
+ detail: str,
110
+ *,
111
+ error_code: str | None = None,
112
+ hint: str | None = None,
113
+ retryable: bool | None = None,
114
+ ) -> None:
115
+ """根据 status_code raise 对应的 MAPHTTPError 子类。
116
+
117
+ 单一入口,供 ``MAPClient._request`` 在 ``status_code >= 400`` 时调用。
118
+ 透传 server 端的结构化字段(I1(d) 起):``error_code`` / ``hint`` /
119
+ ``retryable``,CLI 与调用方可以按 subcode 路由。
120
+ """
121
+ exc_cls = _STATUS_CODE_MAP.get(status_code)
122
+ if exc_cls is None:
123
+ exc_cls = MAPServerError if status_code >= 500 else MAPClientError
124
+ raise exc_cls(
125
+ status_code,
126
+ detail,
127
+ error_code=error_code,
128
+ hint=hint,
129
+ retryable=retryable,
130
+ )
@@ -0,0 +1,159 @@
1
+ """8ac93d4e I1.a — Plan evidence_keys frontmatter parser.
2
+
3
+ Extracts ``evidence_keys`` from YAML frontmatter in plan markdown so the
4
+ CLI / SDK / server can validate that ``ExperimentLogCreate.metadata``
5
+ covers every key the plan declared. Soft validation only — a missing
6
+ key is a warning, never a block.
7
+
8
+ Frontmatter format::
9
+
10
+ ---
11
+ evidence_keys:
12
+ - pytest_summary
13
+ - alembic_current
14
+ - api_health
15
+ ---
16
+
17
+ Behavior contract (pinned by ``docs/MAP-EVIDENCE-METADATA.md``):
18
+
19
+ * No frontmatter → ``PlanEvidenceKeys(keys=(), has_frontmatter=False)``.
20
+ * Frontmatter without ``evidence_keys`` key → ``keys=(), has_frontmatter=True``.
21
+ * Frontmatter with malformed YAML → raises :class:`PlanFrontmatterParseError`.
22
+ * ``evidence_keys`` must be a list of non-empty strings; other shapes
23
+ (dict, scalar, list of non-strings) raise ``PlanFrontmatterParseError``.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ from dataclasses import dataclass
29
+
30
+ import yaml
31
+
32
+
33
+ class PlanFrontmatterParseError(Exception):
34
+ """Raised when plan frontmatter YAML cannot be parsed.
35
+
36
+ Carries the raw frontmatter block + underlying YAML error so the
37
+ caller can render a friendly stderr warning without losing the
38
+ diagnostic detail.
39
+ """
40
+
41
+ def __init__(self, message: str, *, frontmatter: str | None = None) -> None:
42
+ super().__init__(message)
43
+ self.frontmatter = frontmatter
44
+
45
+
46
+ @dataclass(frozen=True)
47
+ class PlanEvidenceKeys:
48
+ """Result of parsing ``evidence_keys`` from plan markdown frontmatter."""
49
+
50
+ keys: tuple[str, ...]
51
+ has_frontmatter: bool
52
+ parse_error: str | None = None
53
+
54
+ def __bool__(self) -> bool:
55
+ return bool(self.keys)
56
+
57
+
58
+ _FRONTMATTER_OPEN = "---"
59
+ _LIST_TYPES = (list, tuple)
60
+
61
+
62
+ def _split_frontmatter(plan_md: str) -> tuple[str | None, str]:
63
+ """Return ``(frontmatter_body_or_none, remainder)``.
64
+
65
+ Frontmatter is detected by the document starting with ``---\\n`` and
66
+ terminated by ``\\n---\\n`` / ``\\n---`` / ``---\\n`` at the start of
67
+ body / ``---`` at end-of-string. Returns ``(None, plan_md)`` when no
68
+ frontmatter is present.
69
+ """
70
+ if not plan_md.startswith(_FRONTMATTER_OPEN + "\n"):
71
+ return None, plan_md
72
+ body = plan_md[len(_FRONTMATTER_OPEN) + 1 :]
73
+ # Closing fence variants (in priority order):
74
+ # 1. ``\\n---\\n`` somewhere after the opening fence.
75
+ end_with_newline = body.find("\n" + _FRONTMATTER_OPEN + "\n", 3)
76
+ if end_with_newline != -1:
77
+ return (
78
+ body[:end_with_newline],
79
+ body[end_with_newline + len(_FRONTMATTER_OPEN) + 2 :],
80
+ )
81
+ # 2. ``---\\n`` at the very start of body (empty frontmatter case).
82
+ if body.startswith(_FRONTMATTER_OPEN + "\n"):
83
+ return "", body[len(_FRONTMATTER_OPEN) + 1 :]
84
+ # 3. ``---\\n`` at end-of-string with trailing newline.
85
+ if body.endswith(_FRONTMATTER_OPEN + "\n"):
86
+ return body[: -len(_FRONTMATTER_OPEN) - 1], ""
87
+ # 4. ``---`` at end-of-string without trailing newline.
88
+ if body.endswith(_FRONTMATTER_OPEN) and len(body) > len(_FRONTMATTER_OPEN):
89
+ return body[: -len(_FRONTMATTER_OPEN)], ""
90
+ return None, plan_md
91
+
92
+
93
+ def _validate_evidence_keys_value(value: object) -> tuple[str, ...]:
94
+ """Coerce parsed YAML ``evidence_keys`` value into a tuple of strings.
95
+
96
+ Raises :class:`PlanFrontmatterParseError` for non-list shapes or
97
+ non-string / empty-string entries.
98
+ """
99
+ if not isinstance(value, _LIST_TYPES):
100
+ raise PlanFrontmatterParseError(
101
+ "evidence_keys must be a YAML list, got "
102
+ f"{type(value).__name__}"
103
+ )
104
+ keys: list[str] = []
105
+ for idx, item in enumerate(value):
106
+ if not isinstance(item, str):
107
+ raise PlanFrontmatterParseError(
108
+ f"evidence_keys[{idx}] must be a string, got "
109
+ f"{type(item).__name__}"
110
+ )
111
+ stripped = item.strip()
112
+ if not stripped:
113
+ raise PlanFrontmatterParseError(
114
+ f"evidence_keys[{idx}] must be non-empty"
115
+ )
116
+ keys.append(stripped)
117
+ # Dedupe while preserving order (first occurrence wins).
118
+ seen: set[str] = set()
119
+ deduped: list[str] = []
120
+ for key in keys:
121
+ if key not in seen:
122
+ seen.add(key)
123
+ deduped.append(key)
124
+ return tuple(deduped)
125
+
126
+
127
+ def parse_plan_evidence_keys(plan_md: str) -> PlanEvidenceKeys:
128
+ """Extract ``evidence_keys`` from YAML frontmatter in plan markdown.
129
+
130
+ See module docstring for the full behavior contract.
131
+ """
132
+ frontmatter, _ = _split_frontmatter(plan_md)
133
+ if frontmatter is None:
134
+ return PlanEvidenceKeys(keys=(), has_frontmatter=False)
135
+
136
+ try:
137
+ parsed = yaml.safe_load(frontmatter)
138
+ except yaml.YAMLError as exc:
139
+ raise PlanFrontmatterParseError(
140
+ f"frontmatter YAML parse failed: {exc}",
141
+ frontmatter=frontmatter,
142
+ ) from exc
143
+
144
+ if parsed is None:
145
+ # Frontmatter present but empty.
146
+ return PlanEvidenceKeys(keys=(), has_frontmatter=True)
147
+
148
+ if not isinstance(parsed, dict):
149
+ raise PlanFrontmatterParseError(
150
+ "frontmatter top-level must be a YAML mapping, got "
151
+ f"{type(parsed).__name__}",
152
+ frontmatter=frontmatter,
153
+ )
154
+
155
+ if "evidence_keys" not in parsed:
156
+ return PlanEvidenceKeys(keys=(), has_frontmatter=True)
157
+
158
+ keys = _validate_evidence_keys_value(parsed["evidence_keys"])
159
+ return PlanEvidenceKeys(keys=keys, has_frontmatter=True)