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
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
import uuid
|
|
2
|
+
from collections import Counter
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from sqlalchemy import select
|
|
6
|
+
from sqlalchemy.orm import Session
|
|
7
|
+
|
|
8
|
+
from server.domain.models import (
|
|
9
|
+
Agent,
|
|
10
|
+
AgentRole,
|
|
11
|
+
ExperimentPhase,
|
|
12
|
+
Review,
|
|
13
|
+
ReviewItem,
|
|
14
|
+
TopicActionItem,
|
|
15
|
+
TopicActionItemStatus,
|
|
16
|
+
)
|
|
17
|
+
from server.domain.schemas import (
|
|
18
|
+
ExperimentComplete,
|
|
19
|
+
ExperimentLogCreate,
|
|
20
|
+
ExperimentResultDecision,
|
|
21
|
+
ReviewVerdict,
|
|
22
|
+
ReviewVerdictFile,
|
|
23
|
+
)
|
|
24
|
+
from server.domain.state_machine import validate_phase_transition
|
|
25
|
+
from server.services import audit_service, topic_service
|
|
26
|
+
from server.services.errors import ForbiddenError, StateTransitionError
|
|
27
|
+
from server.services.evidence_service import EVIDENCE_METADATA_KEYS, metadata_has_completion_evidence
|
|
28
|
+
from server.services.log_service import append_log
|
|
29
|
+
from server.services.phase_owner_resolver import owner_for
|
|
30
|
+
from server.services.project_service import get_experiment
|
|
31
|
+
from server.services.review_service import assert_approve_eligibility
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _sync_phase_owner(experiment) -> None:
|
|
35
|
+
"""Mirror ``experiment.phase_owner`` to the resolver's table.
|
|
36
|
+
|
|
37
|
+
Called immediately after every ``experiment.phase = ...`` assignment
|
|
38
|
+
in this module so the two never drift. Kept as a thin wrapper so a
|
|
39
|
+
future override path (e.g. admin override, experiment.phase_owner
|
|
40
|
+
column hand-edit) can plug in here without touching every site.
|
|
41
|
+
|
|
42
|
+
Type-annotation uses a string forward reference (``Experiment``) so
|
|
43
|
+
this helper can live next to its callers without pulling the heavy
|
|
44
|
+
model imports into this module's top-level namespace.
|
|
45
|
+
"""
|
|
46
|
+
experiment.phase_owner = owner_for(experiment.phase).value
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def submit_for_review(db: Session, experiment_id: uuid.UUID, actor: Agent) -> None:
|
|
50
|
+
experiment = get_experiment(db, experiment_id)
|
|
51
|
+
if experiment.creator_agent_id != actor.id and actor.role != AgentRole.admin:
|
|
52
|
+
raise ForbiddenError("Only the creator can submit for review")
|
|
53
|
+
if experiment.current_plan_version < 1:
|
|
54
|
+
raise StateTransitionError("Experiment must have a plan before review")
|
|
55
|
+
validate_phase_transition(experiment.phase, ExperimentPhase.review)
|
|
56
|
+
experiment.phase = ExperimentPhase.review
|
|
57
|
+
_sync_phase_owner(experiment)
|
|
58
|
+
db.commit()
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def approve_experiment(db: Session, experiment_id: uuid.UUID, actor: Agent) -> None:
|
|
62
|
+
experiment = get_experiment(db, experiment_id)
|
|
63
|
+
if experiment.creator_agent_id != actor.id and actor.role != AgentRole.admin:
|
|
64
|
+
raise ForbiddenError("Only the creator can approve the experiment")
|
|
65
|
+
assert_approve_eligibility(db, experiment)
|
|
66
|
+
validate_phase_transition(experiment.phase, ExperimentPhase.approved)
|
|
67
|
+
experiment.phase = ExperimentPhase.approved
|
|
68
|
+
_sync_phase_owner(experiment)
|
|
69
|
+
db.commit()
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def withdraw_from_review(db: Session, experiment_id: uuid.UUID, actor: Agent) -> None:
|
|
73
|
+
experiment = get_experiment(db, experiment_id)
|
|
74
|
+
if experiment.creator_agent_id != actor.id and actor.role != AgentRole.admin:
|
|
75
|
+
raise ForbiddenError("Only the creator can withdraw from review")
|
|
76
|
+
validate_phase_transition(experiment.phase, ExperimentPhase.draft)
|
|
77
|
+
experiment.phase = ExperimentPhase.draft
|
|
78
|
+
_sync_phase_owner(experiment)
|
|
79
|
+
db.commit()
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def cancel_experiment(db: Session, experiment_id: uuid.UUID, actor: Agent) -> None:
|
|
83
|
+
experiment = get_experiment(db, experiment_id)
|
|
84
|
+
if experiment.creator_agent_id != actor.id and actor.role != AgentRole.admin:
|
|
85
|
+
raise ForbiddenError("Only the creator can cancel the experiment")
|
|
86
|
+
validate_phase_transition(experiment.phase, ExperimentPhase.cancelled)
|
|
87
|
+
experiment.phase = ExperimentPhase.cancelled
|
|
88
|
+
_sync_phase_owner(experiment)
|
|
89
|
+
db.commit()
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def start_experiment(db: Session, experiment_id: uuid.UUID, actor: Agent) -> None:
|
|
93
|
+
experiment = get_experiment(db, experiment_id)
|
|
94
|
+
if experiment.creator_agent_id != actor.id and actor.role != AgentRole.admin:
|
|
95
|
+
raise ForbiddenError("Only the creator can start the experiment")
|
|
96
|
+
validate_phase_transition(experiment.phase, ExperimentPhase.running)
|
|
97
|
+
experiment.phase = ExperimentPhase.running
|
|
98
|
+
_sync_phase_owner(experiment)
|
|
99
|
+
db.commit()
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _ensure_result_reviewer(experiment_creator_id: uuid.UUID, actor: Agent) -> None:
|
|
103
|
+
if actor.role == AgentRole.admin:
|
|
104
|
+
return
|
|
105
|
+
if actor.id == experiment_creator_id:
|
|
106
|
+
raise ForbiddenError("Experiment result must be reviewed by another agent")
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
# I1(d): the host creator of the experiment is structurally forbidden from
|
|
110
|
+
# rejecting their own result — that intent is modelled by the per-item
|
|
111
|
+
# ``resolve-item --status rebutted`` flow during the review phase. Misusing
|
|
112
|
+
# ``reject-result`` for the wrong intent (e.g. trying to reject a single
|
|
113
|
+
# review item) gets a structured subcode instead of a generic 403 so the
|
|
114
|
+
# CLI / SDK can route the user to the right command.
|
|
115
|
+
REJECT_RESULT_MISUSE_HINT = (
|
|
116
|
+
"单 item 驳回请用 resolve-item --status rebutted (review 阶段); "
|
|
117
|
+
"整个实验驳回请让 reviewer / admin 调用 reject-result, "
|
|
118
|
+
"host creator 不可拒绝自己的 result。"
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def raise_reject_result_misuse(
|
|
123
|
+
*,
|
|
124
|
+
actor_id: uuid.UUID,
|
|
125
|
+
experiment_id: uuid.UUID,
|
|
126
|
+
detail: str | None = None,
|
|
127
|
+
) -> None:
|
|
128
|
+
"""Emit a :class:`StateTransitionError` carrying the
|
|
129
|
+
``REVIEW_REJECT_RESULT_MISUSE`` subcode.
|
|
130
|
+
|
|
131
|
+
Used when the host creator attempts to call ``reject-result`` (the wrong
|
|
132
|
+
command for their intent) or any caller tries to use ``reject-result``
|
|
133
|
+
as a stand-in for the per-item ``resolve-item --status rebutted`` flow.
|
|
134
|
+
"""
|
|
135
|
+
message = detail or "reject-result 被错误使用: " + REJECT_RESULT_MISUSE_HINT
|
|
136
|
+
raise StateTransitionError(
|
|
137
|
+
message,
|
|
138
|
+
error_code="REVIEW_REJECT_RESULT_MISUSE",
|
|
139
|
+
hint=REJECT_RESULT_MISUSE_HINT,
|
|
140
|
+
retryable=False,
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _validate_and_summarize_verdict_file(
|
|
145
|
+
db: Session, experiment_id: uuid.UUID, verdict_file: ReviewVerdictFile
|
|
146
|
+
) -> dict[str, Any]:
|
|
147
|
+
"""Validate item_id.review_id ownership and compute R6 verdict breakdown.
|
|
148
|
+
|
|
149
|
+
The plan (b): ownership is keyed on review_id. Two crossing cases rejected:
|
|
150
|
+
- same reviewer across experiments (item_id from a closed experiment)
|
|
151
|
+
- different reviewer same experiment (item_id from another reviewer's
|
|
152
|
+
review on the same experiment)
|
|
153
|
+
|
|
154
|
+
Strict interpretation: each item_id's actual ``review_id`` must equal
|
|
155
|
+
``verdict_file.review_id`` AND that review must belong to the current
|
|
156
|
+
experiment. The verdict_file's review_id must likewise belong to this
|
|
157
|
+
experiment. Anything else → 403.
|
|
158
|
+
|
|
159
|
+
Returns a metadata fragment to merge into ``experiment_logs.metadata_json``
|
|
160
|
+
with ``pre_schema_accept_result='false'`` + the verdict breakdown. Caller
|
|
161
|
+
is responsible for adding it to the log.
|
|
162
|
+
"""
|
|
163
|
+
experiment_review_ids = set(
|
|
164
|
+
db.scalars(select(Review.id).where(Review.experiment_id == experiment_id)).all()
|
|
165
|
+
)
|
|
166
|
+
if verdict_file.review_id not in experiment_review_ids:
|
|
167
|
+
raise ForbiddenError(
|
|
168
|
+
f"verdict_file.review_id {verdict_file.review_id} does not belong "
|
|
169
|
+
f"to experiment {experiment_id}"
|
|
170
|
+
)
|
|
171
|
+
verdict_review_item_ids = set(
|
|
172
|
+
db.scalars(
|
|
173
|
+
select(ReviewItem.id).where(ReviewItem.review_id == verdict_file.review_id)
|
|
174
|
+
).all()
|
|
175
|
+
)
|
|
176
|
+
for verdict in verdict_file.verdicts:
|
|
177
|
+
if verdict.item_id not in verdict_review_item_ids:
|
|
178
|
+
raise ForbiddenError(
|
|
179
|
+
f"verdict item_id {verdict.item_id} does not belong to "
|
|
180
|
+
f"verdict_file.review_id {verdict_file.review_id} on experiment "
|
|
181
|
+
f"{experiment_id}"
|
|
182
|
+
)
|
|
183
|
+
for invariant in verdict_file.invariants:
|
|
184
|
+
if invariant.item_id not in verdict_review_item_ids:
|
|
185
|
+
raise ForbiddenError(
|
|
186
|
+
f"invariant item_id {invariant.item_id} does not belong to "
|
|
187
|
+
f"verdict_file.review_id {verdict_file.review_id} on experiment "
|
|
188
|
+
f"{experiment_id}"
|
|
189
|
+
)
|
|
190
|
+
breakdown = Counter(v.verdict.value for v in verdict_file.verdicts)
|
|
191
|
+
return {
|
|
192
|
+
"pre_schema_accept_result": "false",
|
|
193
|
+
"verdict_breakdown": {
|
|
194
|
+
"passed": breakdown.get(ReviewVerdict.passed.value, 0),
|
|
195
|
+
"failed": breakdown.get(ReviewVerdict.failed.value, 0),
|
|
196
|
+
"waived": breakdown.get(ReviewVerdict.waived.value, 0),
|
|
197
|
+
},
|
|
198
|
+
"verdict_file": verdict_file.model_dump(mode="json"),
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _legacy_accept_result_metadata(payload_metadata: dict[str, Any] | None) -> dict[str, Any]:
|
|
203
|
+
"""Attach the legacy / pre-schema marker when caller omits verdict_file."""
|
|
204
|
+
fragment = {"pre_schema_accept_result": "true", "verdict_breakdown": None}
|
|
205
|
+
if payload_metadata:
|
|
206
|
+
return {**payload_metadata, **fragment}
|
|
207
|
+
return fragment
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def complete_experiment(
|
|
211
|
+
db: Session,
|
|
212
|
+
experiment_id: uuid.UUID,
|
|
213
|
+
actor: Agent,
|
|
214
|
+
payload: ExperimentComplete,
|
|
215
|
+
) -> None:
|
|
216
|
+
experiment = get_experiment(db, experiment_id)
|
|
217
|
+
if experiment.creator_agent_id != actor.id and actor.role != AgentRole.admin:
|
|
218
|
+
raise ForbiddenError("Only the creator can complete the experiment")
|
|
219
|
+
validate_phase_transition(experiment.phase, ExperimentPhase.result_review)
|
|
220
|
+
if not metadata_has_completion_evidence(payload.metadata):
|
|
221
|
+
keys = ", ".join(sorted(EVIDENCE_METADATA_KEYS))
|
|
222
|
+
raise StateTransitionError(
|
|
223
|
+
"Experiment complete requires deployment/test evidence metadata "
|
|
224
|
+
f"(accepted keys include: {keys}; or evidence/allow_missing_evidence)."
|
|
225
|
+
)
|
|
226
|
+
append_log(
|
|
227
|
+
db,
|
|
228
|
+
experiment_id,
|
|
229
|
+
actor,
|
|
230
|
+
ExperimentLogCreate(
|
|
231
|
+
summary=payload.summary,
|
|
232
|
+
content_md=payload.content_md,
|
|
233
|
+
metadata=payload.metadata,
|
|
234
|
+
),
|
|
235
|
+
)
|
|
236
|
+
experiment.phase = ExperimentPhase.result_review
|
|
237
|
+
_sync_phase_owner(experiment)
|
|
238
|
+
db.commit()
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def accept_result(
|
|
242
|
+
db: Session,
|
|
243
|
+
experiment_id: uuid.UUID,
|
|
244
|
+
actor: Agent,
|
|
245
|
+
payload: ExperimentResultDecision,
|
|
246
|
+
) -> None:
|
|
247
|
+
experiment = get_experiment(db, experiment_id)
|
|
248
|
+
_ensure_result_reviewer(experiment.creator_agent_id, actor)
|
|
249
|
+
validate_phase_transition(experiment.phase, ExperimentPhase.done)
|
|
250
|
+
|
|
251
|
+
# === A2 cascade: 同事务把关联的 open action_item 自动 done ===
|
|
252
|
+
open_items = db.scalars(
|
|
253
|
+
select(TopicActionItem).where(
|
|
254
|
+
TopicActionItem.linked_experiment_id == experiment_id,
|
|
255
|
+
TopicActionItem.status == TopicActionItemStatus.open,
|
|
256
|
+
)
|
|
257
|
+
).all()
|
|
258
|
+
cascaded: list[dict[str, Any]] = []
|
|
259
|
+
for item in open_items:
|
|
260
|
+
cascaded.append(
|
|
261
|
+
topic_service._complete_action_item_no_commit(
|
|
262
|
+
db,
|
|
263
|
+
item,
|
|
264
|
+
triggered_by=f"experiment.completed:{experiment_id}",
|
|
265
|
+
)
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
if payload.verdict_file is not None:
|
|
269
|
+
verdict_fragment = _validate_and_summarize_verdict_file(
|
|
270
|
+
db, experiment_id, payload.verdict_file
|
|
271
|
+
)
|
|
272
|
+
merged_metadata = (
|
|
273
|
+
{**(payload.metadata or {}), **verdict_fragment}
|
|
274
|
+
if payload.metadata
|
|
275
|
+
else verdict_fragment
|
|
276
|
+
)
|
|
277
|
+
else:
|
|
278
|
+
merged_metadata = _legacy_accept_result_metadata(payload.metadata)
|
|
279
|
+
|
|
280
|
+
append_log(
|
|
281
|
+
db,
|
|
282
|
+
experiment_id,
|
|
283
|
+
actor,
|
|
284
|
+
ExperimentLogCreate(
|
|
285
|
+
summary=payload.summary,
|
|
286
|
+
content_md=payload.content_md,
|
|
287
|
+
metadata=merged_metadata,
|
|
288
|
+
),
|
|
289
|
+
)
|
|
290
|
+
experiment.phase = ExperimentPhase.done
|
|
291
|
+
_sync_phase_owner(experiment)
|
|
292
|
+
|
|
293
|
+
audit_service.log_no_commit(
|
|
294
|
+
db,
|
|
295
|
+
action="experiment.completed",
|
|
296
|
+
target_type="experiment",
|
|
297
|
+
target_id=experiment_id,
|
|
298
|
+
agent_id=actor.id,
|
|
299
|
+
project_id=experiment.project_id,
|
|
300
|
+
summary=f"实验完成「{experiment.title}」",
|
|
301
|
+
payload={
|
|
302
|
+
"experiment_id": str(experiment_id),
|
|
303
|
+
"cascaded_action_items": cascaded,
|
|
304
|
+
"pre_schema_accept_result": "true"
|
|
305
|
+
if payload.verdict_file is None
|
|
306
|
+
else "false",
|
|
307
|
+
},
|
|
308
|
+
)
|
|
309
|
+
|
|
310
|
+
db.commit()
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def reject_result(
|
|
314
|
+
db: Session,
|
|
315
|
+
experiment_id: uuid.UUID,
|
|
316
|
+
actor: Agent,
|
|
317
|
+
payload: ExperimentResultDecision,
|
|
318
|
+
) -> None:
|
|
319
|
+
experiment = get_experiment(db, experiment_id)
|
|
320
|
+
# I1(d): the host creator of the experiment is structurally forbidden
|
|
321
|
+
# from rejecting their own result. That intent lives on the per-item
|
|
322
|
+
# ``resolve-item --status rebutted`` path during the review phase, so
|
|
323
|
+
# misuse here gets a structured subcode instead of a generic 403.
|
|
324
|
+
if (
|
|
325
|
+
actor.role != AgentRole.admin
|
|
326
|
+
and actor.id == experiment.creator_agent_id
|
|
327
|
+
):
|
|
328
|
+
raise_reject_result_misuse(
|
|
329
|
+
actor_id=actor.id,
|
|
330
|
+
experiment_id=experiment_id,
|
|
331
|
+
detail=(
|
|
332
|
+
"Experiment creator cannot reject their own result. "
|
|
333
|
+
"单 item 驳回请用 resolve-item --status rebutted, "
|
|
334
|
+
"整个实验驳回需 reviewer / admin。"
|
|
335
|
+
),
|
|
336
|
+
)
|
|
337
|
+
# Non-creator non-admin callers (reviewer) still pass through the legacy
|
|
338
|
+
# ``_ensure_result_reviewer`` guard. Admin bypass is intentional for
|
|
339
|
+
# back-compat (admins can substitute-reject when a reviewer is absent).
|
|
340
|
+
_ensure_result_reviewer(experiment.creator_agent_id, actor)
|
|
341
|
+
validate_phase_transition(experiment.phase, ExperimentPhase.running)
|
|
342
|
+
if payload.verdict_file is not None:
|
|
343
|
+
verdict_fragment = _validate_and_summarize_verdict_file(
|
|
344
|
+
db, experiment_id, payload.verdict_file
|
|
345
|
+
)
|
|
346
|
+
merged_metadata = (
|
|
347
|
+
{**(payload.metadata or {}), **verdict_fragment}
|
|
348
|
+
if payload.metadata
|
|
349
|
+
else verdict_fragment
|
|
350
|
+
)
|
|
351
|
+
else:
|
|
352
|
+
merged_metadata = _legacy_accept_result_metadata(payload.metadata)
|
|
353
|
+
append_log(
|
|
354
|
+
db,
|
|
355
|
+
experiment_id,
|
|
356
|
+
actor,
|
|
357
|
+
ExperimentLogCreate(
|
|
358
|
+
summary=payload.summary,
|
|
359
|
+
content_md=payload.content_md,
|
|
360
|
+
metadata=merged_metadata,
|
|
361
|
+
),
|
|
362
|
+
)
|
|
363
|
+
# I1(e): experiment-level reject-result also writes a
|
|
364
|
+
# ``review_item.mutation`` audit row at the experiment scope so admins
|
|
365
|
+
# can filter the audit log by ``--experiment <id>`` and see the
|
|
366
|
+
# reject-result alongside per-item mutations.
|
|
367
|
+
audit_service.log_review_item_mutation_no_commit(
|
|
368
|
+
db,
|
|
369
|
+
item=None,
|
|
370
|
+
experiment_id=experiment_id,
|
|
371
|
+
actor_id=actor.id,
|
|
372
|
+
project_id=experiment.project_id,
|
|
373
|
+
action="reject_result",
|
|
374
|
+
before_state="result_review",
|
|
375
|
+
after_state="running",
|
|
376
|
+
reason=payload.summary,
|
|
377
|
+
target_id=experiment_id,
|
|
378
|
+
)
|
|
379
|
+
experiment.phase = ExperimentPhase.running
|
|
380
|
+
_sync_phase_owner(experiment)
|
|
381
|
+
db.commit()
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
"""a764abf6 I1.(a) — plan frontmatter marker validator.
|
|
2
|
+
|
|
3
|
+
Provides two entry points:
|
|
4
|
+
|
|
5
|
+
* :func:`validate_plan_frontmatter` — soft validator returning a
|
|
6
|
+
:class:`PlanMarkerValidationResult`. ``valid`` is always True. Used by
|
|
7
|
+
the CLI ``plan validate`` command (plan (c)) and by read-only
|
|
8
|
+
diagnostics where failure must not block.
|
|
9
|
+
|
|
10
|
+
* :func:`assert_plan_frontmatter_ok` — hard validator raising
|
|
11
|
+
:class:`StateTransitionError` with ``error_code=STATE_MACHINE_PLAN_MARKER_MISSING``
|
|
12
|
+
+ a ``hint`` listing the missing fields when validation fails. Wired
|
|
13
|
+
into the ``plan_revise`` and ``create_experiment`` paths per plan (a)
|
|
14
|
+
acceptance so missing required fields block the save with a 422.
|
|
15
|
+
|
|
16
|
+
The 4 required frontmatter fields (markdown fenced ``---`` YAML block):
|
|
17
|
+
|
|
18
|
+
* ``title`` — non-empty string
|
|
19
|
+
* ``acceptance`` — non-empty list or non-empty string
|
|
20
|
+
* ``evidence_keys`` — non-empty list or non-empty string
|
|
21
|
+
* ``dependencies`` — non-empty list or non-empty string
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import re
|
|
27
|
+
from dataclasses import dataclass, field
|
|
28
|
+
from typing import Literal
|
|
29
|
+
|
|
30
|
+
import yaml
|
|
31
|
+
|
|
32
|
+
from server.services.errors import StateTransitionError
|
|
33
|
+
|
|
34
|
+
PlanMarkerWarningCode = Literal[
|
|
35
|
+
"PLAN_MARKER_MISSING_FIELD",
|
|
36
|
+
"PLAN_MARKER_EMPTY_LIST",
|
|
37
|
+
"PLAN_MARKER_PARSE_ERROR",
|
|
38
|
+
"PLAN_MARKER_FRONT_MATTER_MISSING",
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass(frozen=True)
|
|
43
|
+
class PlanMarkerWarning:
|
|
44
|
+
code: PlanMarkerWarningCode
|
|
45
|
+
field: str | None = None
|
|
46
|
+
detail: str | None = None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass(frozen=True)
|
|
50
|
+
class PlanMarkerValidationResult:
|
|
51
|
+
warnings: list[PlanMarkerWarning] = field(default_factory=list)
|
|
52
|
+
fields_present: tuple[str, ...] = ()
|
|
53
|
+
frontmatter: dict[str, object] = field(default_factory=dict)
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def valid(self) -> bool:
|
|
57
|
+
return True
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
REQUIRED_PLAN_FIELDS: tuple[str, ...] = (
|
|
61
|
+
"title",
|
|
62
|
+
"acceptance",
|
|
63
|
+
"evidence_keys",
|
|
64
|
+
"dependencies",
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
_FRONT_MATTER_RE = re.compile(
|
|
68
|
+
r"\A\s*---\s*\n(?P<body>.*?)\n---\s*(?:\n|\Z)",
|
|
69
|
+
re.DOTALL,
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _extract_frontmatter(content_md: str | None) -> tuple[str | None, dict[str, object]]:
|
|
74
|
+
"""Return ``(raw_yaml_or_None, parsed_dict)``.
|
|
75
|
+
|
|
76
|
+
* ``(None, {})`` — no frontmatter block detected.
|
|
77
|
+
* ``(raw, {})`` — frontmatter block present but YAML failed to parse
|
|
78
|
+
to a mapping (treat as parse error).
|
|
79
|
+
* ``(raw, dict)`` — frontmatter parsed.
|
|
80
|
+
"""
|
|
81
|
+
if not content_md:
|
|
82
|
+
return None, {}
|
|
83
|
+
match = _FRONT_MATTER_RE.match(content_md)
|
|
84
|
+
if match is None:
|
|
85
|
+
return None, {}
|
|
86
|
+
raw = match.group("body")
|
|
87
|
+
try:
|
|
88
|
+
parsed = yaml.safe_load(raw) or {}
|
|
89
|
+
except yaml.YAMLError:
|
|
90
|
+
return raw, {}
|
|
91
|
+
if not isinstance(parsed, dict):
|
|
92
|
+
return raw, {}
|
|
93
|
+
return raw, dict(parsed)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _is_nonempty_string(value: object) -> bool:
|
|
97
|
+
return isinstance(value, str) and value.strip() != ""
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _is_nonempty_list(value: object) -> bool:
|
|
101
|
+
return isinstance(value, list) and len(value) > 0
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _collect_warnings(parsed: dict[str, object] | None) -> tuple[
|
|
105
|
+
list[PlanMarkerWarning], tuple[str, ...]
|
|
106
|
+
]:
|
|
107
|
+
"""Compute the warning list for a parsed (or None) frontmatter dict.
|
|
108
|
+
|
|
109
|
+
Returns ``(warnings, fields_present)``.
|
|
110
|
+
"""
|
|
111
|
+
if parsed is None:
|
|
112
|
+
warnings: list[PlanMarkerWarning] = [
|
|
113
|
+
PlanMarkerWarning(code="PLAN_MARKER_FRONT_MATTER_MISSING")
|
|
114
|
+
]
|
|
115
|
+
warnings.extend(
|
|
116
|
+
PlanMarkerWarning(code="PLAN_MARKER_MISSING_FIELD", field=name)
|
|
117
|
+
for name in REQUIRED_PLAN_FIELDS
|
|
118
|
+
)
|
|
119
|
+
return warnings, ()
|
|
120
|
+
|
|
121
|
+
if not parsed:
|
|
122
|
+
warnings = [PlanMarkerWarning(code="PLAN_MARKER_PARSE_ERROR")]
|
|
123
|
+
warnings.extend(
|
|
124
|
+
PlanMarkerWarning(code="PLAN_MARKER_MISSING_FIELD", field=name)
|
|
125
|
+
for name in REQUIRED_PLAN_FIELDS
|
|
126
|
+
)
|
|
127
|
+
return warnings, ()
|
|
128
|
+
|
|
129
|
+
warnings = []
|
|
130
|
+
fields_present: list[str] = []
|
|
131
|
+
for name in REQUIRED_PLAN_FIELDS:
|
|
132
|
+
if name not in parsed or parsed[name] is None:
|
|
133
|
+
warnings.append(
|
|
134
|
+
PlanMarkerWarning(code="PLAN_MARKER_MISSING_FIELD", field=name)
|
|
135
|
+
)
|
|
136
|
+
continue
|
|
137
|
+
value = parsed[name]
|
|
138
|
+
if name == "title":
|
|
139
|
+
if _is_nonempty_string(value):
|
|
140
|
+
fields_present.append(name)
|
|
141
|
+
else:
|
|
142
|
+
warnings.append(
|
|
143
|
+
PlanMarkerWarning(code="PLAN_MARKER_MISSING_FIELD", field=name)
|
|
144
|
+
)
|
|
145
|
+
else:
|
|
146
|
+
if _is_nonempty_list(value) or _is_nonempty_string(value):
|
|
147
|
+
fields_present.append(name)
|
|
148
|
+
else:
|
|
149
|
+
warnings.append(
|
|
150
|
+
PlanMarkerWarning(
|
|
151
|
+
code="PLAN_MARKER_EMPTY_LIST", field=name
|
|
152
|
+
)
|
|
153
|
+
)
|
|
154
|
+
return warnings, tuple(fields_present)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def validate_plan_frontmatter(
|
|
158
|
+
content_md: str | None,
|
|
159
|
+
) -> PlanMarkerValidationResult:
|
|
160
|
+
"""Soft validator — never raises. Returns warnings and the fields
|
|
161
|
+
that were detected as present.
|
|
162
|
+
"""
|
|
163
|
+
raw, parsed = _extract_frontmatter(content_md)
|
|
164
|
+
if raw is None:
|
|
165
|
+
warnings, fields_present = _collect_warnings(None)
|
|
166
|
+
return PlanMarkerValidationResult(
|
|
167
|
+
warnings=warnings, fields_present=fields_present, frontmatter={}
|
|
168
|
+
)
|
|
169
|
+
if not parsed:
|
|
170
|
+
warnings, fields_present = _collect_warnings({})
|
|
171
|
+
return PlanMarkerValidationResult(
|
|
172
|
+
warnings=warnings, fields_present=fields_present, frontmatter={}
|
|
173
|
+
)
|
|
174
|
+
warnings, fields_present = _collect_warnings(parsed)
|
|
175
|
+
return PlanMarkerValidationResult(
|
|
176
|
+
warnings=warnings,
|
|
177
|
+
fields_present=fields_present,
|
|
178
|
+
frontmatter=parsed,
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def assert_plan_frontmatter_ok(content_md: str | None) -> PlanMarkerValidationResult:
|
|
183
|
+
"""Hard validator — raises ``StateTransitionError`` with
|
|
184
|
+
``error_code=STATE_MACHINE_PLAN_MARKER_MISSING`` when required
|
|
185
|
+
fields are missing or malformed. Returns the (always valid) result
|
|
186
|
+
on success.
|
|
187
|
+
|
|
188
|
+
Wired into ``plan_revise`` and ``create_experiment`` per plan (a)
|
|
189
|
+
acceptance so a plan without frontmatter or required fields is
|
|
190
|
+
refused with HTTP 422 + structured error envelope.
|
|
191
|
+
"""
|
|
192
|
+
raw, parsed = _extract_frontmatter(content_md)
|
|
193
|
+
if raw is None:
|
|
194
|
+
raise StateTransitionError(
|
|
195
|
+
"Plan frontmatter is missing",
|
|
196
|
+
error_code="STATE_MACHINE_PLAN_MARKER_MISSING",
|
|
197
|
+
hint=(
|
|
198
|
+
"Plan must begin with a YAML frontmatter block containing "
|
|
199
|
+
f"{', '.join(REQUIRED_PLAN_FIELDS)}"
|
|
200
|
+
),
|
|
201
|
+
)
|
|
202
|
+
if not parsed:
|
|
203
|
+
raise StateTransitionError(
|
|
204
|
+
"Plan frontmatter could not be parsed as a YAML mapping",
|
|
205
|
+
error_code="STATE_MACHINE_PLAN_MARKER_MISSING",
|
|
206
|
+
hint=(
|
|
207
|
+
"Frontmatter must be a YAML mapping with required fields: "
|
|
208
|
+
f"{', '.join(REQUIRED_PLAN_FIELDS)}"
|
|
209
|
+
),
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
warnings, fields_present = _collect_warnings(parsed)
|
|
213
|
+
if warnings:
|
|
214
|
+
missing = sorted(
|
|
215
|
+
w.field
|
|
216
|
+
for w in warnings
|
|
217
|
+
if w.code in ("PLAN_MARKER_MISSING_FIELD", "PLAN_MARKER_EMPTY_LIST")
|
|
218
|
+
and w.field
|
|
219
|
+
)
|
|
220
|
+
detail = ", ".join(missing) if missing else "see warnings"
|
|
221
|
+
raise StateTransitionError(
|
|
222
|
+
f"Plan frontmatter is missing required fields: {detail}",
|
|
223
|
+
error_code="STATE_MACHINE_PLAN_MARKER_MISSING",
|
|
224
|
+
hint=(
|
|
225
|
+
"Required frontmatter fields: "
|
|
226
|
+
f"{', '.join(REQUIRED_PLAN_FIELDS)} "
|
|
227
|
+
f"(missing: {detail})"
|
|
228
|
+
),
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
return PlanMarkerValidationResult(
|
|
232
|
+
warnings=[],
|
|
233
|
+
fields_present=fields_present,
|
|
234
|
+
frontmatter=parsed,
|
|
235
|
+
)
|