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,858 @@
|
|
|
1
|
+
import uuid
|
|
2
|
+
from datetime import datetime
|
|
3
|
+
|
|
4
|
+
from fastapi import APIRouter, Depends, Query, Response, status
|
|
5
|
+
from pydantic import BaseModel, Field
|
|
6
|
+
from sqlalchemy.orm import Session
|
|
7
|
+
|
|
8
|
+
from server.api.background_tasks import bind_background_tasks
|
|
9
|
+
from server.api.common import emit
|
|
10
|
+
from server.api.deps import get_current_agent
|
|
11
|
+
from server.auth import experiment_access
|
|
12
|
+
from server.db.session import get_db
|
|
13
|
+
from server.domain.models import Agent, ExperimentPhase
|
|
14
|
+
from server.domain.schemas import (
|
|
15
|
+
AuditLogRead,
|
|
16
|
+
CommentCreate,
|
|
17
|
+
CommentRead,
|
|
18
|
+
CommentTreeNode,
|
|
19
|
+
CrossPersonaCallRecord,
|
|
20
|
+
EvidenceValidationSchema,
|
|
21
|
+
EvidenceWarningSchema,
|
|
22
|
+
ExperimentBundleRead,
|
|
23
|
+
ExperimentComplete,
|
|
24
|
+
ExperimentCreate,
|
|
25
|
+
ExperimentDetailRead,
|
|
26
|
+
ExperimentLockRead,
|
|
27
|
+
ExperimentLockStalledScanRead,
|
|
28
|
+
ExperimentLogCreate,
|
|
29
|
+
ExperimentLogRead,
|
|
30
|
+
ExperimentResultDecision,
|
|
31
|
+
ExperimentSummaryRead,
|
|
32
|
+
ExperimentUpdate,
|
|
33
|
+
LogCreateResponse,
|
|
34
|
+
PlanRevise,
|
|
35
|
+
PlanVersionRead,
|
|
36
|
+
ReviewCreate,
|
|
37
|
+
ReviewItemRead,
|
|
38
|
+
ReviewItemUpdate,
|
|
39
|
+
ReviewRead,
|
|
40
|
+
SimilarityWarningSchema,
|
|
41
|
+
TemplateValidationSchema,
|
|
42
|
+
TemplateWarningSchema,
|
|
43
|
+
)
|
|
44
|
+
from server.services import (
|
|
45
|
+
audit_service,
|
|
46
|
+
comment_service,
|
|
47
|
+
lock_service,
|
|
48
|
+
log_service,
|
|
49
|
+
notification_service,
|
|
50
|
+
phase_service,
|
|
51
|
+
plan_service,
|
|
52
|
+
review_service,
|
|
53
|
+
)
|
|
54
|
+
from server.services import permissions as perm
|
|
55
|
+
from server.services import project_service as svc
|
|
56
|
+
from server.services.errors import ForbiddenError
|
|
57
|
+
from server.services.experiment_capabilities_service import experiment_summary_for_actor
|
|
58
|
+
from server.services.template_service import validate_result_submission_template
|
|
59
|
+
|
|
60
|
+
HOST_AGENT_NAME = "multi-agent-platform-host"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _summary_for_agent(db: Session, experiment, agent: Agent, **extra) -> ExperimentSummaryRead:
|
|
64
|
+
return experiment_summary_for_actor(db, experiment, agent, extra_updates=extra or None)
|
|
65
|
+
|
|
66
|
+
experiments_router = APIRouter(tags=["experiments"], dependencies=[Depends(bind_background_tasks)])
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@experiments_router.post(
|
|
70
|
+
"/projects/{project_id}/experiments",
|
|
71
|
+
response_model=ExperimentSummaryRead,
|
|
72
|
+
status_code=status.HTTP_201_CREATED,
|
|
73
|
+
)
|
|
74
|
+
def create_experiment(
|
|
75
|
+
project_id: uuid.UUID,
|
|
76
|
+
payload: ExperimentCreate,
|
|
77
|
+
db: Session = Depends(get_db),
|
|
78
|
+
agent: Agent = Depends(get_current_agent),
|
|
79
|
+
) -> ExperimentSummaryRead:
|
|
80
|
+
resolved_project_id = perm.resolve_project_id_for_agent(agent, project_id)
|
|
81
|
+
perm.ensure_project_access(agent, resolved_project_id)
|
|
82
|
+
warnings = svc.create_experiment_warnings(db, resolved_project_id, payload.topic_id)
|
|
83
|
+
experiment = svc.create_experiment(db, resolved_project_id, agent.id, payload)
|
|
84
|
+
emit(
|
|
85
|
+
db,
|
|
86
|
+
agent,
|
|
87
|
+
action="experiment.created",
|
|
88
|
+
target_type="experiment",
|
|
89
|
+
target_id=experiment.id,
|
|
90
|
+
project_id=resolved_project_id,
|
|
91
|
+
summary=f"创建实验「{experiment.title}」",
|
|
92
|
+
event="experiment.created",
|
|
93
|
+
event_payload={"id": str(experiment.id), "title": experiment.title},
|
|
94
|
+
)
|
|
95
|
+
return _summary_for_agent(db, experiment, agent, warnings=warnings)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@experiments_router.get("/projects/{project_id}/experiments", response_model=list[ExperimentSummaryRead])
|
|
99
|
+
def list_experiments(
|
|
100
|
+
project_id: uuid.UUID,
|
|
101
|
+
response: Response,
|
|
102
|
+
phase: ExperimentPhase | None = Query(default=None),
|
|
103
|
+
creator_agent_id: uuid.UUID | None = Query(default=None),
|
|
104
|
+
q: str | None = Query(default=None),
|
|
105
|
+
page: int = Query(default=1, ge=1),
|
|
106
|
+
# cleanup experiment (f12a5638) Exp B: unified ``limit`` query param
|
|
107
|
+
# (default 50). ``page_size`` is kept as a deprecated alias for one
|
|
108
|
+
# minor version; when only ``page_size`` is sent we honour it AND
|
|
109
|
+
# surface ``Deprecation`` + ``Sunset`` response headers so clients
|
|
110
|
+
# migrate before v0.12 (where ``page_size`` will be removed).
|
|
111
|
+
limit: int | None = Query(default=None, ge=1, le=100),
|
|
112
|
+
page_size: int | None = Query(default=None, ge=1, le=100),
|
|
113
|
+
include_archived: bool = Query(default=False),
|
|
114
|
+
db: Session = Depends(get_db),
|
|
115
|
+
agent: Agent = Depends(get_current_agent),
|
|
116
|
+
) -> list[ExperimentSummaryRead]:
|
|
117
|
+
resolved_project_id = perm.resolve_project_id_for_agent(agent, project_id)
|
|
118
|
+
perm.ensure_project_access(agent, resolved_project_id)
|
|
119
|
+
|
|
120
|
+
# Resolve effective page_size: limit wins; else page_size (deprecated);
|
|
121
|
+
# else default 50.
|
|
122
|
+
if limit is not None:
|
|
123
|
+
effective_page_size = limit
|
|
124
|
+
elif page_size is not None:
|
|
125
|
+
effective_page_size = page_size
|
|
126
|
+
response.headers["Deprecation"] = "true"
|
|
127
|
+
response.headers["Sunset"] = "v0.12"
|
|
128
|
+
response.headers["Link"] = (
|
|
129
|
+
f'<{"?limit=" + str(page_size)}>; rel="successor-version"'
|
|
130
|
+
)
|
|
131
|
+
else:
|
|
132
|
+
effective_page_size = 50
|
|
133
|
+
|
|
134
|
+
experiments, total = svc.list_experiments(
|
|
135
|
+
db,
|
|
136
|
+
resolved_project_id,
|
|
137
|
+
phase=phase,
|
|
138
|
+
creator_agent_id=creator_agent_id,
|
|
139
|
+
q=q,
|
|
140
|
+
page=page,
|
|
141
|
+
page_size=effective_page_size,
|
|
142
|
+
include_archived=include_archived,
|
|
143
|
+
)
|
|
144
|
+
response.headers["X-Total-Count"] = str(total)
|
|
145
|
+
return [ExperimentSummaryRead.model_validate(e) for e in experiments]
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
@experiments_router.get("/experiments/{experiment_id}", response_model=ExperimentDetailRead)
|
|
149
|
+
def get_experiment(
|
|
150
|
+
experiment_id: uuid.UUID,
|
|
151
|
+
db: Session = Depends(get_db),
|
|
152
|
+
agent: Agent = Depends(get_current_agent),
|
|
153
|
+
) -> ExperimentDetailRead:
|
|
154
|
+
perm.ensure_experiment_access(db, agent, experiment_id)
|
|
155
|
+
return svc.get_experiment_detail(db, experiment_id, agent)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
@experiments_router.get("/experiments/{experiment_id}/bundle", response_model=ExperimentBundleRead)
|
|
159
|
+
def get_experiment_bundle(
|
|
160
|
+
experiment_id: uuid.UUID,
|
|
161
|
+
db: Session = Depends(get_db),
|
|
162
|
+
agent: Agent = Depends(get_current_agent),
|
|
163
|
+
) -> ExperimentBundleRead:
|
|
164
|
+
perm.ensure_experiment_access(db, agent, experiment_id)
|
|
165
|
+
return svc.get_experiment_bundle(db, experiment_id, agent)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
@experiments_router.patch("/experiments/{experiment_id}", response_model=ExperimentSummaryRead)
|
|
169
|
+
def update_experiment(
|
|
170
|
+
experiment_id: uuid.UUID,
|
|
171
|
+
payload: ExperimentUpdate,
|
|
172
|
+
db: Session = Depends(get_db),
|
|
173
|
+
agent: Agent = Depends(get_current_agent),
|
|
174
|
+
) -> ExperimentSummaryRead:
|
|
175
|
+
# authz PR3: PATCH is a non-state-machine mutation but still
|
|
176
|
+
# touches experiment metadata (title/description). Only the
|
|
177
|
+
# creator or an admin should be able to rename someone else's
|
|
178
|
+
# experiment — the project-level guard alone was too loose.
|
|
179
|
+
experiment_access.ensure_experiment_creator_or_admin(db, agent, experiment_id)
|
|
180
|
+
experiment = svc.update_experiment(db, experiment_id, payload)
|
|
181
|
+
return _summary_for_agent(db, experiment, agent)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
@experiments_router.delete("/experiments/{experiment_id}", status_code=status.HTTP_204_NO_CONTENT)
|
|
185
|
+
def delete_experiment(
|
|
186
|
+
experiment_id: uuid.UUID,
|
|
187
|
+
db: Session = Depends(get_db),
|
|
188
|
+
agent: Agent = Depends(get_current_agent),
|
|
189
|
+
) -> None:
|
|
190
|
+
# authz PR3: same rationale as update_experiment — soft-delete is
|
|
191
|
+
# a destructive mutation, creator/admin only.
|
|
192
|
+
experiment_access.ensure_experiment_creator_or_admin(db, agent, experiment_id)
|
|
193
|
+
svc.soft_delete_experiment(db, experiment_id)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
# --- M2: phase transitions ---
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
@experiments_router.post("/experiments/{experiment_id}/submit-review", response_model=ExperimentSummaryRead)
|
|
200
|
+
def submit_for_review(
|
|
201
|
+
experiment_id: uuid.UUID,
|
|
202
|
+
db: Session = Depends(get_db),
|
|
203
|
+
agent: Agent = Depends(get_current_agent),
|
|
204
|
+
) -> ExperimentSummaryRead:
|
|
205
|
+
perm.ensure_experiment_access(db, agent, experiment_id)
|
|
206
|
+
phase_service.submit_for_review(db, experiment_id, agent)
|
|
207
|
+
experiment = svc.get_experiment(db, experiment_id)
|
|
208
|
+
emit(
|
|
209
|
+
db,
|
|
210
|
+
agent,
|
|
211
|
+
action="experiment.phase_changed",
|
|
212
|
+
target_type="experiment",
|
|
213
|
+
target_id=experiment_id,
|
|
214
|
+
project_id=experiment.project_id,
|
|
215
|
+
summary=f"提交评审({experiment.title})",
|
|
216
|
+
event="experiment.phase_changed",
|
|
217
|
+
event_payload={"id": str(experiment_id), "phase": experiment.phase.value, "title": experiment.title},
|
|
218
|
+
)
|
|
219
|
+
return _summary_for_agent(db, experiment, agent)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
@experiments_router.post("/experiments/{experiment_id}/approve", response_model=ExperimentSummaryRead)
|
|
223
|
+
def approve_experiment(
|
|
224
|
+
experiment_id: uuid.UUID,
|
|
225
|
+
db: Session = Depends(get_db),
|
|
226
|
+
agent: Agent = Depends(get_current_agent),
|
|
227
|
+
) -> ExperimentSummaryRead:
|
|
228
|
+
perm.ensure_experiment_access(db, agent, experiment_id)
|
|
229
|
+
phase_service.approve_experiment(db, experiment_id, agent)
|
|
230
|
+
experiment = svc.get_experiment(db, experiment_id)
|
|
231
|
+
emit(
|
|
232
|
+
db,
|
|
233
|
+
agent,
|
|
234
|
+
action="experiment.phase_changed",
|
|
235
|
+
target_type="experiment",
|
|
236
|
+
target_id=experiment_id,
|
|
237
|
+
project_id=experiment.project_id,
|
|
238
|
+
summary=f"批准实验({experiment.title})",
|
|
239
|
+
event="experiment.phase_changed",
|
|
240
|
+
event_payload={"id": str(experiment_id), "phase": experiment.phase.value, "title": experiment.title},
|
|
241
|
+
)
|
|
242
|
+
return _summary_for_agent(db, experiment, agent)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
@experiments_router.post("/experiments/{experiment_id}/withdraw", response_model=ExperimentSummaryRead)
|
|
246
|
+
def withdraw_from_review(
|
|
247
|
+
experiment_id: uuid.UUID,
|
|
248
|
+
db: Session = Depends(get_db),
|
|
249
|
+
agent: Agent = Depends(get_current_agent),
|
|
250
|
+
) -> ExperimentSummaryRead:
|
|
251
|
+
perm.ensure_experiment_access(db, agent, experiment_id)
|
|
252
|
+
phase_service.withdraw_from_review(db, experiment_id, agent)
|
|
253
|
+
experiment = svc.get_experiment(db, experiment_id)
|
|
254
|
+
# Phase 2 D2: kind-directed SSE so the waker can map to ``experiment_lifecycle``.
|
|
255
|
+
notification_service.emit_kind(
|
|
256
|
+
db,
|
|
257
|
+
project_id=experiment.project_id,
|
|
258
|
+
actor_id=agent.id,
|
|
259
|
+
personas=["host", "reviewer"],
|
|
260
|
+
event="experiment.lifecycle.withdrawn",
|
|
261
|
+
summary=f"实验已撤回评审({experiment.title})",
|
|
262
|
+
target_type="experiment",
|
|
263
|
+
target_id=experiment.id,
|
|
264
|
+
payload={"experiment_id": str(experiment.id), "title": experiment.title, "phase": experiment.phase.value},
|
|
265
|
+
)
|
|
266
|
+
return _summary_for_agent(db, experiment, agent)
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
@experiments_router.post("/experiments/{experiment_id}/cancel", response_model=ExperimentSummaryRead)
|
|
270
|
+
def cancel_experiment(
|
|
271
|
+
experiment_id: uuid.UUID,
|
|
272
|
+
db: Session = Depends(get_db),
|
|
273
|
+
agent: Agent = Depends(get_current_agent),
|
|
274
|
+
) -> ExperimentSummaryRead:
|
|
275
|
+
perm.ensure_experiment_access(db, agent, experiment_id)
|
|
276
|
+
phase_service.cancel_experiment(db, experiment_id, agent)
|
|
277
|
+
experiment = svc.get_experiment(db, experiment_id)
|
|
278
|
+
# Phase 2 D2: kind-directed SSE so the waker can map to ``experiment_lifecycle``.
|
|
279
|
+
notification_service.emit_kind(
|
|
280
|
+
db,
|
|
281
|
+
project_id=experiment.project_id,
|
|
282
|
+
actor_id=agent.id,
|
|
283
|
+
personas=["host", "reviewer"],
|
|
284
|
+
event="experiment.lifecycle.cancelled",
|
|
285
|
+
summary=f"实验已取消({experiment.title})",
|
|
286
|
+
target_type="experiment",
|
|
287
|
+
target_id=experiment.id,
|
|
288
|
+
payload={"experiment_id": str(experiment.id), "title": experiment.title, "phase": experiment.phase.value},
|
|
289
|
+
)
|
|
290
|
+
return _summary_for_agent(db, experiment, agent)
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
# --- M2: plans ---
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
@experiments_router.get("/experiments/{experiment_id}/plans", response_model=list[PlanVersionRead])
|
|
297
|
+
def list_plans(
|
|
298
|
+
experiment_id: uuid.UUID,
|
|
299
|
+
limit: int = Query(default=50, ge=1, le=200),
|
|
300
|
+
db: Session = Depends(get_db),
|
|
301
|
+
agent: Agent = Depends(get_current_agent),
|
|
302
|
+
) -> list[PlanVersionRead]:
|
|
303
|
+
perm.ensure_experiment_access(db, agent, experiment_id)
|
|
304
|
+
plans = plan_service.list_plans(db, experiment_id, limit=limit)
|
|
305
|
+
return [PlanVersionRead.model_validate(p) for p in plans]
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
@experiments_router.get("/experiments/{experiment_id}/plans/{version}", response_model=PlanVersionRead)
|
|
309
|
+
def get_plan_version(
|
|
310
|
+
experiment_id: uuid.UUID,
|
|
311
|
+
version: int,
|
|
312
|
+
db: Session = Depends(get_db),
|
|
313
|
+
agent: Agent = Depends(get_current_agent),
|
|
314
|
+
) -> PlanVersionRead:
|
|
315
|
+
perm.ensure_experiment_access(db, agent, experiment_id)
|
|
316
|
+
plan = plan_service.get_plan_version(db, experiment_id, version)
|
|
317
|
+
return PlanVersionRead.model_validate(plan)
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
@experiments_router.post(
|
|
321
|
+
"/experiments/{experiment_id}/plans",
|
|
322
|
+
response_model=PlanVersionRead,
|
|
323
|
+
status_code=status.HTTP_201_CREATED,
|
|
324
|
+
)
|
|
325
|
+
def revise_plan(
|
|
326
|
+
experiment_id: uuid.UUID,
|
|
327
|
+
payload: PlanRevise,
|
|
328
|
+
db: Session = Depends(get_db),
|
|
329
|
+
agent: Agent = Depends(get_current_agent),
|
|
330
|
+
) -> PlanVersionRead:
|
|
331
|
+
experiment = perm.ensure_experiment_access(db, agent, experiment_id)
|
|
332
|
+
previous_version = experiment.current_plan_version
|
|
333
|
+
plan = plan_service.revise_plan(db, experiment_id, agent, payload)
|
|
334
|
+
if plan.version != previous_version:
|
|
335
|
+
emit(
|
|
336
|
+
db,
|
|
337
|
+
agent,
|
|
338
|
+
action="plan.revised",
|
|
339
|
+
target_type="plan_version",
|
|
340
|
+
target_id=plan.id,
|
|
341
|
+
project_id=experiment.project_id,
|
|
342
|
+
summary=f"修订计划 v{plan.version}",
|
|
343
|
+
event="plan.revised",
|
|
344
|
+
event_payload={"experiment_id": str(experiment_id), "version": plan.version},
|
|
345
|
+
)
|
|
346
|
+
return PlanVersionRead.model_validate(plan)
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
# --- M2: reviews ---
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
@experiments_router.post(
|
|
353
|
+
"/experiments/{experiment_id}/reviews",
|
|
354
|
+
response_model=ReviewRead,
|
|
355
|
+
status_code=status.HTTP_201_CREATED,
|
|
356
|
+
)
|
|
357
|
+
def create_review(
|
|
358
|
+
experiment_id: uuid.UUID,
|
|
359
|
+
payload: ReviewCreate,
|
|
360
|
+
db: Session = Depends(get_db),
|
|
361
|
+
agent: Agent = Depends(get_current_agent),
|
|
362
|
+
) -> ReviewRead:
|
|
363
|
+
experiment = perm.ensure_experiment_access(db, agent, experiment_id)
|
|
364
|
+
review = review_service.create_review(db, experiment_id, agent, payload)
|
|
365
|
+
emit(
|
|
366
|
+
db,
|
|
367
|
+
agent,
|
|
368
|
+
action="review.submitted",
|
|
369
|
+
target_type="review",
|
|
370
|
+
target_id=review.id,
|
|
371
|
+
project_id=experiment.project_id,
|
|
372
|
+
summary="提交评审",
|
|
373
|
+
event="review.submitted",
|
|
374
|
+
event_payload={"experiment_id": str(experiment_id), "review_id": str(review.id)},
|
|
375
|
+
)
|
|
376
|
+
return review_service.review_to_read(db, review)
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
@experiments_router.get("/experiments/{experiment_id}/reviews", response_model=list[ReviewRead])
|
|
380
|
+
def list_reviews(
|
|
381
|
+
experiment_id: uuid.UUID,
|
|
382
|
+
include_archived: bool = Query(
|
|
383
|
+
default=True,
|
|
384
|
+
description=(
|
|
385
|
+
"Whether to include archived reviews. Defaults to true for N=2 "
|
|
386
|
+
"transition (legacy e2e tests + UI initial load expect to see all); "
|
|
387
|
+
"set false to filter to active reviews only."
|
|
388
|
+
),
|
|
389
|
+
),
|
|
390
|
+
plan_version: int | None = None,
|
|
391
|
+
limit: int = Query(default=50, ge=1, le=200),
|
|
392
|
+
db: Session = Depends(get_db),
|
|
393
|
+
agent: Agent = Depends(get_current_agent),
|
|
394
|
+
) -> list[ReviewRead]:
|
|
395
|
+
perm.ensure_experiment_access(db, agent, experiment_id)
|
|
396
|
+
reviews = review_service.list_reviews(
|
|
397
|
+
db,
|
|
398
|
+
experiment_id,
|
|
399
|
+
include_archived=include_archived,
|
|
400
|
+
plan_version=plan_version,
|
|
401
|
+
limit=limit,
|
|
402
|
+
)
|
|
403
|
+
return [review_service.review_to_read(db, r) for r in reviews]
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
@experiments_router.post(
|
|
407
|
+
"/experiments/{experiment_id}/reviews/{review_id}/withdraw",
|
|
408
|
+
status_code=status.HTTP_204_NO_CONTENT,
|
|
409
|
+
)
|
|
410
|
+
def withdraw_review(
|
|
411
|
+
experiment_id: uuid.UUID,
|
|
412
|
+
review_id: uuid.UUID,
|
|
413
|
+
db: Session = Depends(get_db),
|
|
414
|
+
agent: Agent = Depends(get_current_agent),
|
|
415
|
+
) -> None:
|
|
416
|
+
experiment = perm.ensure_experiment_access(db, agent, experiment_id)
|
|
417
|
+
review_service.withdraw_review(db, experiment_id, review_id, agent)
|
|
418
|
+
emit(
|
|
419
|
+
db,
|
|
420
|
+
agent,
|
|
421
|
+
action="review.withdrawn",
|
|
422
|
+
target_type="review",
|
|
423
|
+
target_id=review_id,
|
|
424
|
+
project_id=experiment.project_id,
|
|
425
|
+
summary="撤回评审",
|
|
426
|
+
event="review.withdrawn",
|
|
427
|
+
event_payload={"experiment_id": str(experiment_id), "review_id": str(review_id)},
|
|
428
|
+
)
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
@experiments_router.patch("/review-items/{item_id}", response_model=ReviewItemRead)
|
|
432
|
+
def update_review_item(
|
|
433
|
+
item_id: uuid.UUID,
|
|
434
|
+
payload: ReviewItemUpdate,
|
|
435
|
+
db: Session = Depends(get_db),
|
|
436
|
+
agent: Agent = Depends(get_current_agent),
|
|
437
|
+
) -> ReviewItemRead:
|
|
438
|
+
perm.ensure_review_item_access(db, agent, item_id)
|
|
439
|
+
item = review_service.update_review_item(db, item_id, agent, payload)
|
|
440
|
+
# Phase 2 D2: kind-directed SSE for the addressed/responded/rebutted
|
|
441
|
+
# transition so the waker can map to ``addressed_review_item``.
|
|
442
|
+
experiment = svc.get_experiment(db, item.review.experiment_id)
|
|
443
|
+
notification_service.emit_kind(
|
|
444
|
+
db,
|
|
445
|
+
project_id=experiment.project_id,
|
|
446
|
+
actor_id=agent.id,
|
|
447
|
+
personas=["host"],
|
|
448
|
+
event="review_item.status_changed",
|
|
449
|
+
summary=f"评审项状态变为 {item.status.value if item.status else 'updated'}({experiment.title})",
|
|
450
|
+
target_type="review_item",
|
|
451
|
+
target_id=item.id,
|
|
452
|
+
payload={
|
|
453
|
+
"experiment_id": str(experiment.id),
|
|
454
|
+
"review_id": str(item.review_id),
|
|
455
|
+
"item_id": str(item.id),
|
|
456
|
+
"status": item.status.value if item.status else None,
|
|
457
|
+
},
|
|
458
|
+
)
|
|
459
|
+
return ReviewItemRead.model_validate(item)
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
# --- M2: comments ---
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
@experiments_router.post(
|
|
466
|
+
"/experiments/{experiment_id}/comments",
|
|
467
|
+
response_model=CommentRead,
|
|
468
|
+
status_code=status.HTTP_201_CREATED,
|
|
469
|
+
)
|
|
470
|
+
def create_comment(
|
|
471
|
+
experiment_id: uuid.UUID,
|
|
472
|
+
payload: CommentCreate,
|
|
473
|
+
db: Session = Depends(get_db),
|
|
474
|
+
agent: Agent = Depends(get_current_agent),
|
|
475
|
+
) -> CommentRead:
|
|
476
|
+
experiment = perm.ensure_experiment_access(db, agent, experiment_id)
|
|
477
|
+
comment, unresolved = comment_service.create_comment(db, experiment_id, agent, payload)
|
|
478
|
+
emit(
|
|
479
|
+
db,
|
|
480
|
+
agent,
|
|
481
|
+
action="comment.created",
|
|
482
|
+
target_type="comment",
|
|
483
|
+
target_id=comment.id,
|
|
484
|
+
project_id=experiment.project_id,
|
|
485
|
+
summary="发表评论",
|
|
486
|
+
event="comment.created",
|
|
487
|
+
event_payload={"experiment_id": str(experiment_id), "comment_id": str(comment.id)},
|
|
488
|
+
)
|
|
489
|
+
return comment_service.comment_read(db, comment, unresolved_mentions=unresolved)
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
@experiments_router.get("/experiments/{experiment_id}/comments")
|
|
493
|
+
def list_comments(
|
|
494
|
+
experiment_id: uuid.UUID,
|
|
495
|
+
tree: bool = Query(default=False),
|
|
496
|
+
limit: int = Query(default=100, ge=1, le=500),
|
|
497
|
+
db: Session = Depends(get_db),
|
|
498
|
+
agent: Agent = Depends(get_current_agent),
|
|
499
|
+
) -> list[CommentRead] | list[CommentTreeNode]:
|
|
500
|
+
perm.ensure_experiment_access(db, agent, experiment_id)
|
|
501
|
+
comments = comment_service.list_comments(db, experiment_id, limit=limit)
|
|
502
|
+
if tree:
|
|
503
|
+
return comment_service.build_comment_tree(db, comments)
|
|
504
|
+
return comment_service.comments_to_read(db, comments)
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
# --- M3: execution ---
|
|
508
|
+
|
|
509
|
+
|
|
510
|
+
@experiments_router.post("/experiments/{experiment_id}/start", response_model=ExperimentSummaryRead)
|
|
511
|
+
def start_experiment(
|
|
512
|
+
experiment_id: uuid.UUID,
|
|
513
|
+
db: Session = Depends(get_db),
|
|
514
|
+
agent: Agent = Depends(get_current_agent),
|
|
515
|
+
) -> ExperimentSummaryRead:
|
|
516
|
+
perm.ensure_experiment_access(db, agent, experiment_id)
|
|
517
|
+
phase_service.start_experiment(db, experiment_id, agent)
|
|
518
|
+
experiment = svc.get_experiment(db, experiment_id)
|
|
519
|
+
emit(
|
|
520
|
+
db,
|
|
521
|
+
agent,
|
|
522
|
+
action="experiment.phase_changed",
|
|
523
|
+
target_type="experiment",
|
|
524
|
+
target_id=experiment_id,
|
|
525
|
+
project_id=experiment.project_id,
|
|
526
|
+
summary=f"开始执行({experiment.title})",
|
|
527
|
+
event="experiment.phase_changed",
|
|
528
|
+
event_payload={"id": str(experiment_id), "phase": experiment.phase.value, "title": experiment.title},
|
|
529
|
+
)
|
|
530
|
+
return _summary_for_agent(db, experiment, agent)
|
|
531
|
+
|
|
532
|
+
|
|
533
|
+
@experiments_router.post("/experiments/{experiment_id}/complete", response_model=ExperimentSummaryRead)
|
|
534
|
+
def complete_experiment(
|
|
535
|
+
experiment_id: uuid.UUID,
|
|
536
|
+
payload: ExperimentComplete,
|
|
537
|
+
db: Session = Depends(get_db),
|
|
538
|
+
agent: Agent = Depends(get_current_agent),
|
|
539
|
+
) -> ExperimentSummaryRead:
|
|
540
|
+
perm.ensure_experiment_access(db, agent, experiment_id)
|
|
541
|
+
phase_service.complete_experiment(db, experiment_id, agent, payload)
|
|
542
|
+
experiment = svc.get_experiment(db, experiment_id)
|
|
543
|
+
# b72d0542 I1.b: 4-段 template soft validation. Mirrors evidence
|
|
544
|
+
# validation: never blocks complete; surfaced via response for
|
|
545
|
+
# reviewer + host to triage.
|
|
546
|
+
template_result = validate_result_submission_template(payload.content_md)
|
|
547
|
+
template_validation = TemplateValidationSchema(
|
|
548
|
+
warnings=[
|
|
549
|
+
TemplateWarningSchema(code=w.code, section=w.section, detail=w.detail)
|
|
550
|
+
for w in template_result.warnings
|
|
551
|
+
],
|
|
552
|
+
sections_present=list(template_result.sections_present),
|
|
553
|
+
log_link_count=template_result.log_link_count,
|
|
554
|
+
valid=template_result.valid,
|
|
555
|
+
)
|
|
556
|
+
emit(
|
|
557
|
+
db,
|
|
558
|
+
agent,
|
|
559
|
+
action="experiment.phase_changed",
|
|
560
|
+
target_type="experiment",
|
|
561
|
+
target_id=experiment_id,
|
|
562
|
+
project_id=experiment.project_id,
|
|
563
|
+
summary=f"提交实验结果待审批({experiment.title})",
|
|
564
|
+
event="experiment.phase_changed",
|
|
565
|
+
event_payload={"id": str(experiment_id), "phase": experiment.phase.value, "title": experiment.title},
|
|
566
|
+
)
|
|
567
|
+
return _summary_for_agent(
|
|
568
|
+
db, experiment, agent, template_validation=template_validation
|
|
569
|
+
)
|
|
570
|
+
|
|
571
|
+
|
|
572
|
+
@experiments_router.post("/experiments/{experiment_id}/accept-result", response_model=ExperimentSummaryRead)
|
|
573
|
+
def accept_experiment_result(
|
|
574
|
+
experiment_id: uuid.UUID,
|
|
575
|
+
payload: ExperimentResultDecision,
|
|
576
|
+
db: Session = Depends(get_db),
|
|
577
|
+
agent: Agent = Depends(get_current_agent),
|
|
578
|
+
) -> ExperimentSummaryRead:
|
|
579
|
+
perm.ensure_experiment_access(db, agent, experiment_id)
|
|
580
|
+
phase_service.accept_result(db, experiment_id, agent, payload)
|
|
581
|
+
experiment = svc.get_experiment(db, experiment_id)
|
|
582
|
+
emit(
|
|
583
|
+
db,
|
|
584
|
+
agent,
|
|
585
|
+
action="experiment.phase_changed",
|
|
586
|
+
target_type="experiment",
|
|
587
|
+
target_id=experiment_id,
|
|
588
|
+
project_id=experiment.project_id,
|
|
589
|
+
summary=f"审批通过实验结果({experiment.title})",
|
|
590
|
+
event="experiment.phase_changed",
|
|
591
|
+
event_payload={"id": str(experiment_id), "phase": experiment.phase.value, "title": experiment.title},
|
|
592
|
+
)
|
|
593
|
+
return _summary_for_agent(db, experiment, agent)
|
|
594
|
+
|
|
595
|
+
|
|
596
|
+
@experiments_router.post("/experiments/{experiment_id}/reject-result", response_model=ExperimentSummaryRead)
|
|
597
|
+
def reject_experiment_result(
|
|
598
|
+
experiment_id: uuid.UUID,
|
|
599
|
+
payload: ExperimentResultDecision,
|
|
600
|
+
db: Session = Depends(get_db),
|
|
601
|
+
agent: Agent = Depends(get_current_agent),
|
|
602
|
+
) -> ExperimentSummaryRead:
|
|
603
|
+
perm.ensure_experiment_access(db, agent, experiment_id)
|
|
604
|
+
phase_service.reject_result(db, experiment_id, agent, payload)
|
|
605
|
+
experiment = svc.get_experiment(db, experiment_id)
|
|
606
|
+
emit(
|
|
607
|
+
db,
|
|
608
|
+
agent,
|
|
609
|
+
action="experiment.phase_changed",
|
|
610
|
+
target_type="experiment",
|
|
611
|
+
target_id=experiment_id,
|
|
612
|
+
project_id=experiment.project_id,
|
|
613
|
+
summary=f"驳回实验结果({experiment.title})",
|
|
614
|
+
event="experiment.phase_changed",
|
|
615
|
+
event_payload={"id": str(experiment_id), "phase": experiment.phase.value, "title": experiment.title},
|
|
616
|
+
)
|
|
617
|
+
return _summary_for_agent(db, experiment, agent)
|
|
618
|
+
|
|
619
|
+
|
|
620
|
+
@experiments_router.post(
|
|
621
|
+
"/experiments/{experiment_id}/logs",
|
|
622
|
+
response_model=LogCreateResponse,
|
|
623
|
+
status_code=status.HTTP_201_CREATED,
|
|
624
|
+
)
|
|
625
|
+
def create_log(
|
|
626
|
+
experiment_id: uuid.UUID,
|
|
627
|
+
payload: ExperimentLogCreate,
|
|
628
|
+
db: Session = Depends(get_db),
|
|
629
|
+
agent: Agent = Depends(get_current_agent),
|
|
630
|
+
) -> LogCreateResponse:
|
|
631
|
+
perm.ensure_experiment_access(db, agent, experiment_id)
|
|
632
|
+
log, validation, similarity, force_skip_applied = log_service.create_log(
|
|
633
|
+
db, experiment_id, agent, payload
|
|
634
|
+
)
|
|
635
|
+
validation_schema = EvidenceValidationSchema(
|
|
636
|
+
warnings=[
|
|
637
|
+
EvidenceWarningSchema(
|
|
638
|
+
code=w.code,
|
|
639
|
+
missing_key=w.missing_key,
|
|
640
|
+
plan_required=w.plan_required,
|
|
641
|
+
log_provided=w.log_provided,
|
|
642
|
+
)
|
|
643
|
+
for w in validation.warnings
|
|
644
|
+
],
|
|
645
|
+
parse_error=validation.parse_error,
|
|
646
|
+
plan_keys=list(validation.plan_keys),
|
|
647
|
+
valid=validation.valid,
|
|
648
|
+
)
|
|
649
|
+
# b72d0542 I1.b(2)(e): surface similarity warning unless caller
|
|
650
|
+
# acknowledged via ``force_skip_similarity=True`` (the audit row
|
|
651
|
+
# already landed in ``log_service.append_log``). ``similarity_warning``
|
|
652
|
+
# is None when no warning fires; ``force_skip`` echoes the
|
|
653
|
+
# acknowledgement for downstream consumers.
|
|
654
|
+
similarity_warning: SimilarityWarningSchema | None = None
|
|
655
|
+
if similarity.warnings and not force_skip_applied:
|
|
656
|
+
warning = similarity.warnings[0]
|
|
657
|
+
similarity_warning = SimilarityWarningSchema(
|
|
658
|
+
code=warning.code,
|
|
659
|
+
score=warning.score,
|
|
660
|
+
threshold=warning.threshold,
|
|
661
|
+
ref_log_id=warning.ref_log_id,
|
|
662
|
+
model=warning.model,
|
|
663
|
+
)
|
|
664
|
+
return LogCreateResponse(
|
|
665
|
+
log=ExperimentLogRead.model_validate(log),
|
|
666
|
+
validation=validation_schema,
|
|
667
|
+
similarity_warning=similarity_warning,
|
|
668
|
+
force_skip=force_skip_applied,
|
|
669
|
+
)
|
|
670
|
+
|
|
671
|
+
|
|
672
|
+
@experiments_router.get("/experiments/{experiment_id}/logs", response_model=list[ExperimentLogRead])
|
|
673
|
+
def list_logs(
|
|
674
|
+
experiment_id: uuid.UUID,
|
|
675
|
+
limit: int = Query(default=50, ge=1, le=200),
|
|
676
|
+
db: Session = Depends(get_db),
|
|
677
|
+
agent: Agent = Depends(get_current_agent),
|
|
678
|
+
) -> list[ExperimentLogRead]:
|
|
679
|
+
perm.ensure_experiment_access(db, agent, experiment_id)
|
|
680
|
+
logs = log_service.list_logs(db, experiment_id, limit=limit)
|
|
681
|
+
return [ExperimentLogRead.model_validate(log) for log in logs]
|
|
682
|
+
|
|
683
|
+
|
|
684
|
+
# 0db51e10 I2(5e): record a ``cross_persona_call`` audit row. The CLI
|
|
685
|
+
# facade (``map experiment status --persona-compare``) calls this with
|
|
686
|
+
# the per-persona view diff so admin / R6 metrics can aggregate by
|
|
687
|
+
# ``result_partition_count`` / ``diff_size`` later.
|
|
688
|
+
#
|
|
689
|
+
# authz PR2: capability-gated. Whitelist = ``host`` persona OR
|
|
690
|
+
# ``role == admin`` via ``system:cross_persona_call`` capability. A
|
|
691
|
+
# denied call STILL emits an audit row with ``rejected=True`` + reason
|
|
692
|
+
# so R6 metrics can spot probe attempts vs legitimate aggregations.
|
|
693
|
+
@experiments_router.post(
|
|
694
|
+
"/experiments/{experiment_id}/cross-persona-call",
|
|
695
|
+
response_model=AuditLogRead,
|
|
696
|
+
status_code=status.HTTP_201_CREATED,
|
|
697
|
+
)
|
|
698
|
+
def record_cross_persona_call(
|
|
699
|
+
experiment_id: uuid.UUID,
|
|
700
|
+
payload: CrossPersonaCallRecord,
|
|
701
|
+
db: Session = Depends(get_db),
|
|
702
|
+
agent: Agent = Depends(get_current_agent),
|
|
703
|
+
) -> AuditLogRead:
|
|
704
|
+
perm.ensure_experiment_access(db, agent, experiment_id)
|
|
705
|
+
experiment = svc.get_experiment(db, experiment_id)
|
|
706
|
+
if not agent.has_capability("system:cross_persona_call"):
|
|
707
|
+
# Record the rejected attempt for R6 noise / intrusion detection,
|
|
708
|
+
# then surface the 403 to the caller.
|
|
709
|
+
entry = audit_service.log_cross_persona_call_no_commit(
|
|
710
|
+
db,
|
|
711
|
+
caller_agent_id=agent.id,
|
|
712
|
+
target_experiment_id=experiment_id,
|
|
713
|
+
project_id=experiment.project_id,
|
|
714
|
+
visibility_diff=payload.visibility_diff,
|
|
715
|
+
result_partition_count=payload.result_partition_count,
|
|
716
|
+
diff_size=payload.diff_size,
|
|
717
|
+
rejected=True,
|
|
718
|
+
rejection_reason="missing capability system:cross_persona_call",
|
|
719
|
+
)
|
|
720
|
+
db.commit()
|
|
721
|
+
raise perm.ForbiddenError(
|
|
722
|
+
"Agent lacks capability system:cross_persona_call"
|
|
723
|
+
)
|
|
724
|
+
entry = audit_service.log_cross_persona_call_no_commit(
|
|
725
|
+
db,
|
|
726
|
+
caller_agent_id=agent.id,
|
|
727
|
+
target_experiment_id=experiment_id,
|
|
728
|
+
project_id=experiment.project_id,
|
|
729
|
+
visibility_diff=payload.visibility_diff,
|
|
730
|
+
result_partition_count=payload.result_partition_count,
|
|
731
|
+
diff_size=payload.diff_size,
|
|
732
|
+
)
|
|
733
|
+
db.commit()
|
|
734
|
+
return AuditLogRead.model_validate(entry)
|
|
735
|
+
|
|
736
|
+
|
|
737
|
+
# --- Experiment execution lock (CP-3) -------------------------------------
|
|
738
|
+
|
|
739
|
+
|
|
740
|
+
class ExperimentLockAcquirePayload(BaseModel):
|
|
741
|
+
ttl_seconds: int = Field(default=lock_service.DEFAULT_LOCK_TTL_SECONDS, ge=1)
|
|
742
|
+
|
|
743
|
+
|
|
744
|
+
class ExperimentLockForceReleasePayload(BaseModel):
|
|
745
|
+
reason: str = Field(min_length=1, max_length=512)
|
|
746
|
+
|
|
747
|
+
|
|
748
|
+
class ExperimentLockSkipPayload(BaseModel):
|
|
749
|
+
next_attempt_at: datetime
|
|
750
|
+
|
|
751
|
+
|
|
752
|
+
@experiments_router.post(
|
|
753
|
+
"/experiments/{experiment_id}/lock/acquire",
|
|
754
|
+
response_model=ExperimentLockRead,
|
|
755
|
+
)
|
|
756
|
+
def acquire_experiment_lock_endpoint(
|
|
757
|
+
experiment_id: uuid.UUID,
|
|
758
|
+
payload: ExperimentLockAcquirePayload,
|
|
759
|
+
db: Session = Depends(get_db),
|
|
760
|
+
agent: Agent = Depends(get_current_agent),
|
|
761
|
+
) -> ExperimentLockRead:
|
|
762
|
+
# authz (0e6926fa) PR1: two-gate guard (404 then 403) — must run
|
|
763
|
+
# before any state mutation. Order is load-bearing: a 404 must not
|
|
764
|
+
# be leaked as 403 (or vice versa) for cross-project probes.
|
|
765
|
+
experiment_access.ensure_experiment_creator_or_admin(db, agent, experiment_id)
|
|
766
|
+
result = lock_service.acquire_experiment_lock(
|
|
767
|
+
db,
|
|
768
|
+
experiment_id,
|
|
769
|
+
agent,
|
|
770
|
+
ttl_seconds=payload.ttl_seconds,
|
|
771
|
+
)
|
|
772
|
+
return ExperimentLockRead.model_validate(result)
|
|
773
|
+
|
|
774
|
+
|
|
775
|
+
@experiments_router.post(
|
|
776
|
+
"/experiments/{experiment_id}/lock/release",
|
|
777
|
+
response_model=ExperimentLockRead,
|
|
778
|
+
)
|
|
779
|
+
def release_experiment_lock_endpoint(
|
|
780
|
+
experiment_id: uuid.UUID,
|
|
781
|
+
db: Session = Depends(get_db),
|
|
782
|
+
agent: Agent = Depends(get_current_agent),
|
|
783
|
+
) -> ExperimentLockRead:
|
|
784
|
+
experiment_access.ensure_experiment_creator_or_admin(db, agent, experiment_id)
|
|
785
|
+
result = lock_service.release_experiment_lock(db, experiment_id, agent)
|
|
786
|
+
return ExperimentLockRead.model_validate(result)
|
|
787
|
+
|
|
788
|
+
|
|
789
|
+
@experiments_router.post(
|
|
790
|
+
"/experiments/{experiment_id}/lock/force-release",
|
|
791
|
+
response_model=ExperimentLockRead,
|
|
792
|
+
)
|
|
793
|
+
def force_release_experiment_lock_endpoint(
|
|
794
|
+
experiment_id: uuid.UUID,
|
|
795
|
+
payload: ExperimentLockForceReleasePayload,
|
|
796
|
+
db: Session = Depends(get_db),
|
|
797
|
+
agent: Agent = Depends(get_current_agent),
|
|
798
|
+
) -> ExperimentLockRead:
|
|
799
|
+
experiment_access.ensure_experiment_creator_or_admin(db, agent, experiment_id)
|
|
800
|
+
result = lock_service.force_release_experiment_lock(
|
|
801
|
+
db,
|
|
802
|
+
experiment_id,
|
|
803
|
+
agent,
|
|
804
|
+
reason=payload.reason,
|
|
805
|
+
)
|
|
806
|
+
return ExperimentLockRead.model_validate(result)
|
|
807
|
+
|
|
808
|
+
|
|
809
|
+
@experiments_router.post(
|
|
810
|
+
"/experiments/{experiment_id}/lock/skip",
|
|
811
|
+
response_model=ExperimentLockRead,
|
|
812
|
+
)
|
|
813
|
+
def record_experiment_lock_skip_endpoint(
|
|
814
|
+
experiment_id: uuid.UUID,
|
|
815
|
+
payload: ExperimentLockSkipPayload,
|
|
816
|
+
db: Session = Depends(get_db),
|
|
817
|
+
agent: Agent = Depends(get_current_agent),
|
|
818
|
+
) -> ExperimentLockRead:
|
|
819
|
+
experiment_access.ensure_experiment_creator_or_admin(db, agent, experiment_id)
|
|
820
|
+
result = lock_service.record_experiment_lock_skip(
|
|
821
|
+
db,
|
|
822
|
+
experiment_id,
|
|
823
|
+
agent,
|
|
824
|
+
next_attempt_at=payload.next_attempt_at,
|
|
825
|
+
)
|
|
826
|
+
return ExperimentLockRead.model_validate(result)
|
|
827
|
+
|
|
828
|
+
|
|
829
|
+
@experiments_router.post(
|
|
830
|
+
"/experiments/lock/scan-stalled",
|
|
831
|
+
response_model=ExperimentLockStalledScanRead,
|
|
832
|
+
)
|
|
833
|
+
def scan_stalled_experiment_locks_endpoint(
|
|
834
|
+
db: Session = Depends(get_db),
|
|
835
|
+
agent: Agent = Depends(get_current_agent),
|
|
836
|
+
) -> ExperimentLockStalledScanRead:
|
|
837
|
+
# authz PR3: replace the old ``agent.name == HOST_AGENT_NAME``
|
|
838
|
+
# check with a capability-based one. The name check was brittle —
|
|
839
|
+
# it required the host agent to keep a hard-coded name; renaming
|
|
840
|
+
# it (or registering a second host agent) silently disabled the
|
|
841
|
+
# stalled-lock scan. Use the same capability model as the rest of
|
|
842
|
+
# the system: any agent holding ``system:scan_stalled`` (or an
|
|
843
|
+
# admin) can run this.
|
|
844
|
+
if not (
|
|
845
|
+
perm.is_admin(agent)
|
|
846
|
+
or agent.has_capability("system:scan_stalled")
|
|
847
|
+
):
|
|
848
|
+
raise ForbiddenError(
|
|
849
|
+
"Agent lacks capability system:scan_stalled"
|
|
850
|
+
)
|
|
851
|
+
notification_ids = notification_service.notify_stalled_experiment_locks(
|
|
852
|
+
db,
|
|
853
|
+
project_id=None if perm.is_admin(agent) else agent.project_id,
|
|
854
|
+
)
|
|
855
|
+
return ExperimentLockStalledScanRead(
|
|
856
|
+
notification_ids=notification_ids,
|
|
857
|
+
emitted_count=len(notification_ids),
|
|
858
|
+
)
|