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.
- cli/__init__.py +0 -0
- cli/action_item_escalation.py +177 -0
- cli/agent_client.py +554 -0
- cli/bridge_state.py +43 -0
- cli/commands/__init__.py +13 -0
- cli/commands/action.py +142 -0
- cli/commands/agent.py +117 -0
- cli/commands/audit.py +68 -0
- cli/commands/docs.py +179 -0
- cli/commands/experiment.py +755 -0
- cli/commands/feedback.py +106 -0
- cli/commands/notification.py +213 -0
- cli/commands/persona.py +63 -0
- cli/commands/project.py +87 -0
- cli/commands/runtime.py +105 -0
- cli/commands/topic.py +361 -0
- cli/e2e_collab.py +602 -0
- cli/git_checkpoint.py +68 -0
- cli/host_worker_types.py +151 -0
- cli/main.py +1553 -0
- cli/map_command_client.py +497 -0
- cli/participant_worker.py +255 -0
- cli/reviewer_worker.py +263 -0
- cli/runtime/__init__.py +5 -0
- cli/runtime/run_lock.py +497 -0
- cli/runtime_chat.py +317 -0
- cli/session_wake_log.py +235 -0
- cli/simple_waker.py +950 -0
- cli/table_render.py +113 -0
- cli/wake_backend.py +236 -0
- cli/worker_cycle_log.py +36 -0
- map_client/__init__.py +37 -0
- map_client/bootstrap.py +193 -0
- map_client/client.py +1045 -0
- map_client/config.py +21 -0
- map_client/errors.py +283 -0
- map_client/exceptions.py +130 -0
- map_client/plan_evidence.py +159 -0
- map_client/project_config.py +153 -0
- map_client/result_template.py +167 -0
- map_client/testing.py +27 -0
- map_mcp/__init__.py +4 -0
- map_mcp/_utils.py +28 -0
- map_mcp/auth.py +34 -0
- map_mcp/config.py +50 -0
- map_mcp/context.py +39 -0
- map_mcp/main.py +75 -0
- map_mcp/server.py +573 -0
- map_mcp/session.py +79 -0
- map_sdk/__init__.py +29 -0
- map_sdk/evidence.py +68 -0
- map_types/__init__.py +203 -0
- map_types/enums.py +199 -0
- map_types/schemas.py +1351 -0
- multi_agent_platform-0.1.0.dist-info/METADATA +298 -0
- multi_agent_platform-0.1.0.dist-info/RECORD +144 -0
- multi_agent_platform-0.1.0.dist-info/WHEEL +5 -0
- multi_agent_platform-0.1.0.dist-info/entry_points.txt +6 -0
- multi_agent_platform-0.1.0.dist-info/licenses/LICENSE +21 -0
- multi_agent_platform-0.1.0.dist-info/top_level.txt +6 -0
- server/__init__.py +0 -0
- server/__version__.py +14 -0
- server/api/__init__.py +0 -0
- server/api/action_items.py +138 -0
- server/api/agents.py +412 -0
- server/api/audit.py +54 -0
- server/api/background_tasks.py +18 -0
- server/api/common.py +117 -0
- server/api/deps.py +30 -0
- server/api/experiments.py +858 -0
- server/api/feedback.py +75 -0
- server/api/notifications.py +22 -0
- server/api/projects.py +209 -0
- server/api/router.py +25 -0
- server/api/status.py +33 -0
- server/api/topics.py +302 -0
- server/api/webhooks.py +74 -0
- server/auth/__init__.py +8 -0
- server/auth/experiment_access.py +66 -0
- server/config.py +38 -0
- server/db/__init__.py +3 -0
- server/db/base.py +5 -0
- server/db/deadlock_retry.py +146 -0
- server/db/session.py +41 -0
- server/domain/__init__.py +3 -0
- server/domain/encrypted_types.py +63 -0
- server/domain/models.py +713 -0
- server/domain/schemas.py +3 -0
- server/domain/state_machine.py +79 -0
- server/domain/topic_ack_constants.py +9 -0
- server/main.py +148 -0
- server/scripts/__init__.py +0 -0
- server/scripts/migrate_notification_unique.py +231 -0
- server/scripts/purge_audit_pollution.py +116 -0
- server/services/__init__.py +0 -0
- server/services/_lookups.py +26 -0
- server/services/acceptance_service.py +90 -0
- server/services/action_item_migration_service.py +190 -0
- server/services/action_item_service.py +200 -0
- server/services/agent_work_service.py +405 -0
- server/services/archive_lint_service.py +156 -0
- server/services/audit_service.py +457 -0
- server/services/auth.py +66 -0
- server/services/comment_service.py +173 -0
- server/services/errors.py +65 -0
- server/services/escalation_resolver.py +248 -0
- server/services/evidence_service.py +88 -0
- server/services/experiment_capabilities_service.py +277 -0
- server/services/inbound_event_service.py +111 -0
- server/services/lock_service.py +273 -0
- server/services/log_service.py +202 -0
- server/services/mention_service.py +730 -0
- server/services/notification_service.py +939 -0
- server/services/notification_stream.py +138 -0
- server/services/permissions.py +147 -0
- server/services/persona_activity_service.py +108 -0
- server/services/phase_owner_resolver.py +95 -0
- server/services/phase_service.py +381 -0
- server/services/plan_marker_service.py +235 -0
- server/services/plan_service.py +186 -0
- server/services/platform_feedback_service.py +114 -0
- server/services/project_service.py +534 -0
- server/services/project_status_service.py +132 -0
- server/services/review_service.py +707 -0
- server/services/secret_encryption.py +97 -0
- server/services/similarity_service.py +119 -0
- server/services/sse_event_schemas.py +17 -0
- server/services/status_service.py +68 -0
- server/services/template_service.py +134 -0
- server/services/text_utils.py +19 -0
- server/services/thread_activity.py +180 -0
- server/services/todo_persona_filter.py +73 -0
- server/services/todo_service.py +604 -0
- server/services/topic_ack_service.py +312 -0
- server/services/topic_action_item_ops.py +538 -0
- server/services/topic_comment_kind.py +14 -0
- server/services/topic_comment_service.py +237 -0
- server/services/topic_helpers.py +32 -0
- server/services/topic_lifecycle_service.py +478 -0
- server/services/topic_progress_service.py +40 -0
- server/services/topic_resolve_service.py +234 -0
- server/services/topic_service.py +102 -0
- server/services/topic_work_item_service.py +570 -0
- 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
|
+
]
|
map_client/exceptions.py
ADDED
|
@@ -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)
|