multi-agent-platform 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (144) hide show
  1. cli/__init__.py +0 -0
  2. cli/action_item_escalation.py +177 -0
  3. cli/agent_client.py +554 -0
  4. cli/bridge_state.py +43 -0
  5. cli/commands/__init__.py +13 -0
  6. cli/commands/action.py +142 -0
  7. cli/commands/agent.py +117 -0
  8. cli/commands/audit.py +68 -0
  9. cli/commands/docs.py +179 -0
  10. cli/commands/experiment.py +755 -0
  11. cli/commands/feedback.py +106 -0
  12. cli/commands/notification.py +213 -0
  13. cli/commands/persona.py +63 -0
  14. cli/commands/project.py +87 -0
  15. cli/commands/runtime.py +105 -0
  16. cli/commands/topic.py +361 -0
  17. cli/e2e_collab.py +602 -0
  18. cli/git_checkpoint.py +68 -0
  19. cli/host_worker_types.py +151 -0
  20. cli/main.py +1553 -0
  21. cli/map_command_client.py +497 -0
  22. cli/participant_worker.py +255 -0
  23. cli/reviewer_worker.py +263 -0
  24. cli/runtime/__init__.py +5 -0
  25. cli/runtime/run_lock.py +497 -0
  26. cli/runtime_chat.py +317 -0
  27. cli/session_wake_log.py +235 -0
  28. cli/simple_waker.py +950 -0
  29. cli/table_render.py +113 -0
  30. cli/wake_backend.py +236 -0
  31. cli/worker_cycle_log.py +36 -0
  32. map_client/__init__.py +37 -0
  33. map_client/bootstrap.py +193 -0
  34. map_client/client.py +1045 -0
  35. map_client/config.py +21 -0
  36. map_client/errors.py +283 -0
  37. map_client/exceptions.py +130 -0
  38. map_client/plan_evidence.py +159 -0
  39. map_client/project_config.py +153 -0
  40. map_client/result_template.py +167 -0
  41. map_client/testing.py +27 -0
  42. map_mcp/__init__.py +4 -0
  43. map_mcp/_utils.py +28 -0
  44. map_mcp/auth.py +34 -0
  45. map_mcp/config.py +50 -0
  46. map_mcp/context.py +39 -0
  47. map_mcp/main.py +75 -0
  48. map_mcp/server.py +573 -0
  49. map_mcp/session.py +79 -0
  50. map_sdk/__init__.py +29 -0
  51. map_sdk/evidence.py +68 -0
  52. map_types/__init__.py +203 -0
  53. map_types/enums.py +199 -0
  54. map_types/schemas.py +1351 -0
  55. multi_agent_platform-0.1.0.dist-info/METADATA +298 -0
  56. multi_agent_platform-0.1.0.dist-info/RECORD +144 -0
  57. multi_agent_platform-0.1.0.dist-info/WHEEL +5 -0
  58. multi_agent_platform-0.1.0.dist-info/entry_points.txt +6 -0
  59. multi_agent_platform-0.1.0.dist-info/licenses/LICENSE +21 -0
  60. multi_agent_platform-0.1.0.dist-info/top_level.txt +6 -0
  61. server/__init__.py +0 -0
  62. server/__version__.py +14 -0
  63. server/api/__init__.py +0 -0
  64. server/api/action_items.py +138 -0
  65. server/api/agents.py +412 -0
  66. server/api/audit.py +54 -0
  67. server/api/background_tasks.py +18 -0
  68. server/api/common.py +117 -0
  69. server/api/deps.py +30 -0
  70. server/api/experiments.py +858 -0
  71. server/api/feedback.py +75 -0
  72. server/api/notifications.py +22 -0
  73. server/api/projects.py +209 -0
  74. server/api/router.py +25 -0
  75. server/api/status.py +33 -0
  76. server/api/topics.py +302 -0
  77. server/api/webhooks.py +74 -0
  78. server/auth/__init__.py +8 -0
  79. server/auth/experiment_access.py +66 -0
  80. server/config.py +38 -0
  81. server/db/__init__.py +3 -0
  82. server/db/base.py +5 -0
  83. server/db/deadlock_retry.py +146 -0
  84. server/db/session.py +41 -0
  85. server/domain/__init__.py +3 -0
  86. server/domain/encrypted_types.py +63 -0
  87. server/domain/models.py +713 -0
  88. server/domain/schemas.py +3 -0
  89. server/domain/state_machine.py +79 -0
  90. server/domain/topic_ack_constants.py +9 -0
  91. server/main.py +148 -0
  92. server/scripts/__init__.py +0 -0
  93. server/scripts/migrate_notification_unique.py +231 -0
  94. server/scripts/purge_audit_pollution.py +116 -0
  95. server/services/__init__.py +0 -0
  96. server/services/_lookups.py +26 -0
  97. server/services/acceptance_service.py +90 -0
  98. server/services/action_item_migration_service.py +190 -0
  99. server/services/action_item_service.py +200 -0
  100. server/services/agent_work_service.py +405 -0
  101. server/services/archive_lint_service.py +156 -0
  102. server/services/audit_service.py +457 -0
  103. server/services/auth.py +66 -0
  104. server/services/comment_service.py +173 -0
  105. server/services/errors.py +65 -0
  106. server/services/escalation_resolver.py +248 -0
  107. server/services/evidence_service.py +88 -0
  108. server/services/experiment_capabilities_service.py +277 -0
  109. server/services/inbound_event_service.py +111 -0
  110. server/services/lock_service.py +273 -0
  111. server/services/log_service.py +202 -0
  112. server/services/mention_service.py +730 -0
  113. server/services/notification_service.py +939 -0
  114. server/services/notification_stream.py +138 -0
  115. server/services/permissions.py +147 -0
  116. server/services/persona_activity_service.py +108 -0
  117. server/services/phase_owner_resolver.py +95 -0
  118. server/services/phase_service.py +381 -0
  119. server/services/plan_marker_service.py +235 -0
  120. server/services/plan_service.py +186 -0
  121. server/services/platform_feedback_service.py +114 -0
  122. server/services/project_service.py +534 -0
  123. server/services/project_status_service.py +132 -0
  124. server/services/review_service.py +707 -0
  125. server/services/secret_encryption.py +97 -0
  126. server/services/similarity_service.py +119 -0
  127. server/services/sse_event_schemas.py +17 -0
  128. server/services/status_service.py +68 -0
  129. server/services/template_service.py +134 -0
  130. server/services/text_utils.py +19 -0
  131. server/services/thread_activity.py +180 -0
  132. server/services/todo_persona_filter.py +73 -0
  133. server/services/todo_service.py +604 -0
  134. server/services/topic_ack_service.py +312 -0
  135. server/services/topic_action_item_ops.py +538 -0
  136. server/services/topic_comment_kind.py +14 -0
  137. server/services/topic_comment_service.py +237 -0
  138. server/services/topic_helpers.py +32 -0
  139. server/services/topic_lifecycle_service.py +478 -0
  140. server/services/topic_progress_service.py +40 -0
  141. server/services/topic_resolve_service.py +234 -0
  142. server/services/topic_service.py +102 -0
  143. server/services/topic_work_item_service.py +570 -0
  144. server/services/webhook_service.py +273 -0
@@ -0,0 +1,534 @@
1
+ import uuid
2
+ from datetime import UTC, datetime
3
+
4
+ from map_types.enums import TopicActionItemStatus, TopicDiscussionRound, TopicStatus
5
+ from map_types.schemas import TopicSummaryRead
6
+ from sqlalchemy import func, select
7
+ from sqlalchemy.exc import IntegrityError
8
+ from sqlalchemy.orm import Session
9
+
10
+ from server.domain.models import (
11
+ Agent,
12
+ AgentRole,
13
+ Experiment,
14
+ ExperimentPhase,
15
+ PlanVersion,
16
+ Project,
17
+ ProjectStatusVersion,
18
+ Topic,
19
+ )
20
+ from server.domain.schemas import (
21
+ ExperimentBundleRead,
22
+ ExperimentCreate,
23
+ ExperimentDetailRead,
24
+ ExperimentLogRead,
25
+ ExperimentSummaryRead,
26
+ ExperimentUpdate,
27
+ PlanVersionRead,
28
+ ProjectCreate,
29
+ ProjectRead,
30
+ ProjectStatusRead,
31
+ ProjectUpdate,
32
+ TopicActionItemRead,
33
+ TopicDecisionRead,
34
+ )
35
+ from server.domain.state_machine import TERMINAL_PHASES
36
+ from server.services import project_status_service as status_doc_service
37
+ from server.services import topic_service
38
+
39
+ # ``get_project`` 下沉到 ``_lookups`` 以打破 project_service ↔ topic_service 循环 import;
40
+ # 这里 re-export 保持 ``from server.services.project_service import get_project`` 兼容。
41
+ from server.services._lookups import get_project
42
+ from server.services.acceptance_service import parse_acceptance_status
43
+ from server.services.errors import ConflictError, ForbiddenError, NotFoundError
44
+
45
+ _ACTIVE_TOPIC_EXPERIMENT_PHASES = (
46
+ ExperimentPhase.draft,
47
+ ExperimentPhase.review,
48
+ ExperimentPhase.approved,
49
+ ExperimentPhase.running,
50
+ ExperimentPhase.result_review,
51
+ )
52
+
53
+
54
+ def create_project(db: Session, payload: ProjectCreate, *, author_agent_id: uuid.UUID) -> Project:
55
+ existing = db.scalar(select(Project).where(Project.project_key == payload.project_key))
56
+ if existing is not None:
57
+ raise ConflictError("project_key already exists")
58
+ project = Project(**payload.model_dump())
59
+ db.add(project)
60
+ db.flush()
61
+ status_doc_service.create_initial_status(db, project=project, author_agent_id=author_agent_id)
62
+ db.commit()
63
+ db.refresh(project)
64
+ return project
65
+
66
+
67
+ def list_projects(
68
+ db: Session,
69
+ *,
70
+ include_archived: bool = False,
71
+ project_id: uuid.UUID | None = None,
72
+ ) -> list[Project]:
73
+ stmt = select(Project).order_by(Project.created_at.desc())
74
+ if project_id is not None:
75
+ stmt = stmt.where(Project.id == project_id)
76
+ if not include_archived:
77
+ stmt = stmt.where(Project.archived_at.is_(None))
78
+ return list(db.scalars(stmt))
79
+
80
+
81
+ def get_project_by_key(db: Session, project_key: str) -> Project:
82
+ project = db.scalar(select(Project).where(Project.project_key == project_key))
83
+ if project is None:
84
+ raise NotFoundError("Project not found")
85
+ return project
86
+
87
+
88
+ def update_project(db: Session, project_id: uuid.UUID, payload: ProjectUpdate) -> Project:
89
+ project = get_project(db, project_id)
90
+ data = payload.model_dump(exclude_unset=True)
91
+ archived = data.pop("archived", None)
92
+ for key, value in data.items():
93
+ setattr(project, key, value)
94
+ if archived is not None:
95
+ project.archived_at = datetime.now(UTC) if archived else None
96
+ db.commit()
97
+ db.refresh(project)
98
+ return project
99
+
100
+
101
+ def get_project_status(db: Session, project_id: uuid.UUID) -> ProjectStatusRead:
102
+ project = get_project(db, project_id)
103
+ return build_projects_status(db, [project])[0]
104
+
105
+
106
+ def list_project_decisions(
107
+ db: Session,
108
+ project_id: uuid.UUID,
109
+ *,
110
+ limit: int = 20,
111
+ ) -> list[TopicDecisionRead]:
112
+ get_project(db, project_id)
113
+ return topic_service.list_project_decisions(db, project_id, limit=limit)
114
+
115
+
116
+ def list_project_action_items(
117
+ db: Session,
118
+ project_id: uuid.UUID,
119
+ *,
120
+ owner_agent_id: uuid.UUID | None = None,
121
+ status: TopicActionItemStatus | None = None,
122
+ limit: int = 100,
123
+ ) -> list[TopicActionItemRead]:
124
+ get_project(db, project_id)
125
+ return topic_service.list_action_items(
126
+ db,
127
+ project_id,
128
+ owner_agent_id=owner_agent_id,
129
+ status=status,
130
+ limit=limit,
131
+ )
132
+
133
+
134
+ def build_projects_status(db: Session, projects: list[Project]) -> list[ProjectStatusRead]:
135
+ if not projects:
136
+ return []
137
+
138
+ project_ids = [project.id for project in projects]
139
+ counts_map: dict[uuid.UUID, dict[str, int]] = {pid: {} for pid in project_ids}
140
+ counts_stmt = (
141
+ select(Experiment.project_id, Experiment.phase, func.count())
142
+ .where(
143
+ Experiment.project_id.in_(project_ids),
144
+ Experiment.deleted_at.is_(None),
145
+ Experiment.archived_at.is_(None),
146
+ )
147
+ .group_by(Experiment.project_id, Experiment.phase)
148
+ )
149
+ for project_id, phase, count in db.execute(counts_stmt):
150
+ counts_map[project_id][phase.value] = count
151
+ for project_id in project_ids:
152
+ for phase in ExperimentPhase:
153
+ counts_map[project_id].setdefault(phase.value, 0)
154
+
155
+ active_phases = (
156
+ ExperimentPhase.draft,
157
+ ExperimentPhase.review,
158
+ ExperimentPhase.approved,
159
+ ExperimentPhase.running,
160
+ ExperimentPhase.result_review,
161
+ )
162
+ active_map: dict[uuid.UUID, list[ExperimentSummaryRead]] = {pid: [] for pid in project_ids}
163
+ active_stmt = (
164
+ select(Experiment)
165
+ .where(
166
+ Experiment.project_id.in_(project_ids),
167
+ Experiment.deleted_at.is_(None),
168
+ Experiment.archived_at.is_(None),
169
+ Experiment.phase.in_(active_phases),
170
+ )
171
+ .order_by(Experiment.project_id, Experiment.updated_at.desc())
172
+ )
173
+ for experiment in db.scalars(active_stmt):
174
+ active_map[experiment.project_id].append(ExperimentSummaryRead.model_validate(experiment))
175
+
176
+ recent_map: dict[uuid.UUID, list[ExperimentSummaryRead]] = {pid: [] for pid in project_ids}
177
+ recent_stmt = (
178
+ select(Experiment)
179
+ .where(
180
+ Experiment.project_id.in_(project_ids),
181
+ Experiment.deleted_at.is_(None),
182
+ Experiment.archived_at.is_(None),
183
+ )
184
+ .order_by(Experiment.project_id, Experiment.updated_at.desc())
185
+ )
186
+ for experiment in db.scalars(recent_stmt):
187
+ recent = recent_map[experiment.project_id]
188
+ if len(recent) < 5:
189
+ recent.append(ExperimentSummaryRead.model_validate(experiment))
190
+
191
+ open_topics_by_project: dict[uuid.UUID, list[Topic]] = {pid: [] for pid in project_ids}
192
+ open_topics_stmt = (
193
+ select(Topic)
194
+ .where(
195
+ Topic.project_id.in_(project_ids),
196
+ Topic.deleted_at.is_(None),
197
+ Topic.archived_at.is_(None),
198
+ Topic.status == TopicStatus.open,
199
+ )
200
+ .order_by(Topic.pinned.desc(), Topic.updated_at.desc())
201
+ )
202
+ for topic in db.scalars(open_topics_stmt):
203
+ open_topics_by_project[topic.project_id].append(topic)
204
+
205
+ open_topics_map: dict[uuid.UUID, list[TopicSummaryRead]] = {
206
+ project_id: topic_service.topic_summaries_for_topics(db, topics)
207
+ for project_id, topics in open_topics_by_project.items()
208
+ }
209
+
210
+ status_rows = list(
211
+ db.scalars(select(ProjectStatusVersion).where(ProjectStatusVersion.project_id.in_(project_ids)))
212
+ )
213
+ status_by_key = {(row.project_id, row.version): row for row in status_rows}
214
+
215
+ results: list[ProjectStatusRead] = []
216
+ for project in projects:
217
+ status_version = 0
218
+ status_md: str | None = None
219
+ status_updated_at = None
220
+ if project.current_status_version > 0:
221
+ row = status_by_key.get((project.id, project.current_status_version))
222
+ if row is not None:
223
+ status_version = row.version
224
+ status_md = row.content_md
225
+ status_updated_at = row.created_at
226
+ else:
227
+ status_version = project.current_status_version
228
+
229
+ results.append(
230
+ ProjectStatusRead(
231
+ project=ProjectRead.model_validate(project),
232
+ experiment_counts_by_phase=counts_map[project.id],
233
+ active_experiments=active_map[project.id],
234
+ recent_experiments=recent_map[project.id],
235
+ open_topics=open_topics_map[project.id],
236
+ status_version=status_version,
237
+ status_md=status_md,
238
+ status_updated_at=status_updated_at,
239
+ )
240
+ )
241
+ return results
242
+
243
+
244
+ def create_experiment_warnings(
245
+ db: Session,
246
+ project_id: uuid.UUID,
247
+ topic_id: uuid.UUID | None,
248
+ ) -> list[str]:
249
+ if topic_id is not None:
250
+ topic = db.get(Topic, topic_id)
251
+ if topic is None or topic.deleted_at is not None or topic.project_id != project_id:
252
+ return []
253
+ if topic.discussion_round != TopicDiscussionRound.ready:
254
+ return ["topic_not_ready_for_experiment"]
255
+ return []
256
+ open_count = (
257
+ db.scalar(
258
+ select(func.count())
259
+ .select_from(Topic)
260
+ .where(
261
+ Topic.project_id == project_id,
262
+ Topic.status == TopicStatus.open,
263
+ Topic.deleted_at.is_(None),
264
+ )
265
+ )
266
+ or 0
267
+ )
268
+ if open_count > 0:
269
+ return ["no_topic_id"]
270
+ return []
271
+
272
+
273
+ def create_experiment(
274
+ db: Session,
275
+ project_id: uuid.UUID,
276
+ creator_agent_id: uuid.UUID,
277
+ payload: ExperimentCreate,
278
+ ) -> Experiment:
279
+ get_project(db, project_id)
280
+ # a764abf6 I1.(a): enforce plan frontmatter lint at create time
281
+ # so missing required fields fail with STATE_MACHINE_PLAN_MARKER_MISSING
282
+ # instead of writing a plan that will be rejected at revision.
283
+ from server.services.plan_marker_service import assert_plan_frontmatter_ok
284
+
285
+ assert_plan_frontmatter_ok(payload.plan.content_md)
286
+ if payload.topic_id is not None:
287
+ topic = db.get(Topic, payload.topic_id)
288
+ if topic is None or topic.deleted_at is not None or topic.project_id != project_id:
289
+ raise NotFoundError("Topic not found")
290
+ if topic.status != TopicStatus.open:
291
+ raise ConflictError("Cannot create experiment on a closed topic")
292
+ creator = db.get(Agent, creator_agent_id)
293
+ if topic.creator_agent_id != creator_agent_id and (creator is None or creator.role != AgentRole.admin):
294
+ raise ForbiddenError("Only the topic host can create an experiment from this topic")
295
+ active = db.scalar(
296
+ select(Experiment).where(
297
+ Experiment.topic_id == payload.topic_id,
298
+ Experiment.deleted_at.is_(None),
299
+ Experiment.archived_at.is_(None),
300
+ Experiment.phase.in_(_ACTIVE_TOPIC_EXPERIMENT_PHASES),
301
+ )
302
+ )
303
+ if active is not None:
304
+ raise ConflictError(
305
+ f"Topic already has an active experiment ({active.id}); complete or cancel it first"
306
+ )
307
+ phase = ExperimentPhase.review if payload.submit_for_review else ExperimentPhase.draft
308
+ # I1(b): mirror the phase_owner column to the resolver's answer at
309
+ # creation time so the ``informational_only`` auto-classification
310
+ # works for the create-with-submit path too (not just for the
311
+ # post-create submit_for_review path, which goes through
312
+ # ``phase_service.submit_for_review``).
313
+ from server.services.phase_owner_resolver import owner_for
314
+
315
+ experiment = Experiment(
316
+ project_id=project_id,
317
+ creator_agent_id=creator_agent_id,
318
+ title=payload.title,
319
+ description=payload.description,
320
+ phase=phase,
321
+ current_plan_version=1,
322
+ topic_id=payload.topic_id,
323
+ phase_owner=owner_for(phase).value,
324
+ )
325
+ db.add(experiment)
326
+ db.flush()
327
+
328
+ plan = PlanVersion(
329
+ experiment_id=experiment.id,
330
+ version=1,
331
+ content_md=payload.plan.content_md,
332
+ author_agent_id=creator_agent_id,
333
+ change_note=payload.plan.change_note or "初始版本",
334
+ )
335
+ db.add(plan)
336
+ try:
337
+ db.commit()
338
+ except IntegrityError as exc:
339
+ db.rollback()
340
+ raise ConflictError(
341
+ "Topic already has an active experiment; complete or cancel it first"
342
+ ) from exc
343
+ db.refresh(experiment)
344
+ return experiment
345
+
346
+
347
+ def list_experiments(
348
+ db: Session,
349
+ project_id: uuid.UUID,
350
+ *,
351
+ phase: ExperimentPhase | None = None,
352
+ creator_agent_id: uuid.UUID | None = None,
353
+ q: str | None = None,
354
+ page: int = 1,
355
+ page_size: int = 100,
356
+ include_archived: bool = False,
357
+ ) -> tuple[list[Experiment], int]:
358
+ get_project(db, project_id)
359
+ stmt = select(Experiment).where(
360
+ Experiment.project_id == project_id, Experiment.deleted_at.is_(None)
361
+ )
362
+ if not include_archived:
363
+ stmt = stmt.where(Experiment.archived_at.is_(None))
364
+ if phase is not None:
365
+ stmt = stmt.where(Experiment.phase == phase)
366
+ if creator_agent_id is not None:
367
+ stmt = stmt.where(Experiment.creator_agent_id == creator_agent_id)
368
+ if q:
369
+ pattern = f"%{q}%"
370
+ stmt = stmt.where(Experiment.title.ilike(pattern) | Experiment.description.ilike(pattern))
371
+ total = db.scalar(select(func.count()).select_from(stmt.subquery())) or 0
372
+ page = max(1, page)
373
+ page_size = max(1, min(page_size, 100))
374
+ stmt = (
375
+ stmt.order_by(Experiment.updated_at.desc())
376
+ .offset((page - 1) * page_size)
377
+ .limit(page_size)
378
+ )
379
+ return list(db.scalars(stmt)), total
380
+
381
+
382
+ def get_experiment(db: Session, experiment_id: uuid.UUID) -> Experiment:
383
+ experiment = db.get(Experiment, experiment_id)
384
+ if experiment is None or experiment.deleted_at is not None:
385
+ raise NotFoundError("Experiment not found")
386
+ return experiment
387
+
388
+
389
+ def get_experiment_detail(
390
+ db: Session, experiment_id: uuid.UUID, actor: Agent | None = None
391
+ ) -> ExperimentDetailRead:
392
+ from server.domain.models import ExperimentLog, Review
393
+ from server.services.experiment_capabilities_service import (
394
+ apply_capabilities_to_detail,
395
+ compute_experiment_capabilities,
396
+ compute_legacy_self_review,
397
+ )
398
+ from server.services.log_service import get_latest_log
399
+ from server.services.phase_owner_resolver import (
400
+ is_informational_only,
401
+ owner_for,
402
+ )
403
+ from server.services.review_service import count_open_unreasonable_for_experiment
404
+
405
+ experiment = get_experiment(db, experiment_id)
406
+ current_plan = None
407
+ acceptance_status = []
408
+ latest = get_latest_log(db, experiment.id)
409
+ if experiment.current_plan_version > 0:
410
+ plan_stmt = select(PlanVersion).where(
411
+ PlanVersion.experiment_id == experiment.id,
412
+ PlanVersion.version == experiment.current_plan_version,
413
+ )
414
+ plan = db.scalar(plan_stmt)
415
+ if plan:
416
+ current_plan = PlanVersionRead.model_validate(plan)
417
+ acceptance_status = parse_acceptance_status(
418
+ plan.content_md,
419
+ completion_metadata=latest.metadata_json if latest else None,
420
+ )
421
+
422
+ plan_version_count = db.scalar(
423
+ select(func.count()).select_from(PlanVersion).where(PlanVersion.experiment_id == experiment.id)
424
+ ) or 0
425
+ review_count = db.scalar(
426
+ select(func.count()).select_from(Review).where(Review.experiment_id == experiment.id)
427
+ ) or 0
428
+ log_count = db.scalar(
429
+ select(func.count()).select_from(ExperimentLog).where(ExperimentLog.experiment_id == experiment.id)
430
+ ) or 0
431
+
432
+ detail = ExperimentDetailRead(
433
+ id=experiment.id,
434
+ project_id=experiment.project_id,
435
+ creator_agent_id=experiment.creator_agent_id,
436
+ title=experiment.title,
437
+ description=experiment.description,
438
+ phase=experiment.phase,
439
+ current_plan_version=experiment.current_plan_version,
440
+ topic_id=experiment.topic_id,
441
+ created_at=experiment.created_at,
442
+ updated_at=experiment.updated_at,
443
+ archived_at=experiment.archived_at,
444
+ current_plan=current_plan,
445
+ plan_version_count=plan_version_count,
446
+ open_unreasonable_count=count_open_unreasonable_for_experiment(db, experiment.id),
447
+ review_count=review_count,
448
+ acceptance_status=acceptance_status,
449
+ log_count=log_count,
450
+ latest_log_summary=latest.summary if latest else None,
451
+ lock_holder_experiment_id=experiment.lock_holder_experiment_id,
452
+ lock_acquired_at=experiment.lock_acquired_at,
453
+ lock_ttl_seconds=experiment.lock_ttl_seconds,
454
+ next_attempt_at=experiment.next_attempt_at,
455
+ lock_skip_count=int(experiment.lock_skip_count or 0),
456
+ # I1(b): phase_owner read straight from the ORM column — the
457
+ # column is kept in sync by ``phase_service._sync_phase_owner``
458
+ # on every transition and by ``create_experiment`` at creation
459
+ # time, so this is the single source of truth.
460
+ phase_owner=owner_for(experiment.phase),
461
+ )
462
+ if actor is not None:
463
+ actions, blocked_on = compute_experiment_capabilities(db, experiment, actor)
464
+ legacy = compute_legacy_self_review(db, experiment)
465
+ informational_only = is_informational_only(
466
+ experiment.phase, actions=actions, blocked_on=blocked_on
467
+ )
468
+ return apply_capabilities_to_detail(
469
+ detail,
470
+ actions,
471
+ blocked_on,
472
+ legacy_self_review=legacy,
473
+ phase_owner=owner_for(experiment.phase),
474
+ informational_only=informational_only,
475
+ )
476
+ return detail
477
+
478
+
479
+ def get_experiment_bundle(
480
+ db: Session, experiment_id: uuid.UUID, actor: Agent | None = None
481
+ ) -> ExperimentBundleRead:
482
+ from server.services import comment_service, log_service, plan_service, review_service
483
+
484
+ experiment = get_experiment_detail(db, experiment_id, actor)
485
+ plans = [PlanVersionRead.model_validate(p) for p in plan_service.list_plans(db, experiment_id)]
486
+ # perf experiment (193a5074) PR4 Layer 3: hoist the verdict-reasons
487
+ # lookup out of the ``review_to_read`` loop. The previous code issued
488
+ # one ``SELECT … FROM experiment_logs`` per review — every query hit
489
+ # the same latest verdict log for this experiment_id. Computing it
490
+ # once and passing the map collapses R queries to 1.
491
+ reviews_orm = review_service.list_reviews(db, experiment_id)
492
+ verdict_reasons = (
493
+ review_service._latest_verdict_reasons_by_item(db, experiment_id)
494
+ if reviews_orm
495
+ else {}
496
+ )
497
+ reviews = [review_service.review_to_read(db, r, verdict_reasons=verdict_reasons) for r in reviews_orm]
498
+ comments = comment_service.build_comment_tree(db, comment_service.list_comments(db, experiment_id))
499
+ logs = [ExperimentLogRead.model_validate(entry) for entry in log_service.list_logs(db, experiment_id)]
500
+ return ExperimentBundleRead(
501
+ experiment=experiment,
502
+ plans=plans,
503
+ reviews=reviews,
504
+ comments=comments,
505
+ logs=logs,
506
+ )
507
+
508
+
509
+ def update_experiment(
510
+ db: Session,
511
+ experiment_id: uuid.UUID,
512
+ payload: ExperimentUpdate,
513
+ ) -> Experiment:
514
+ experiment = get_experiment(db, experiment_id)
515
+ data = payload.model_dump(exclude_unset=True)
516
+ archived = data.pop("archived", None)
517
+ for key, value in data.items():
518
+ setattr(experiment, key, value)
519
+ if archived is not None:
520
+ if archived and experiment.phase not in TERMINAL_PHASES:
521
+ raise ConflictError(
522
+ "Cannot archive experiment while it is "
523
+ f"{experiment.phase.value}; complete or cancel it first"
524
+ )
525
+ experiment.archived_at = datetime.now(UTC) if archived else None
526
+ db.commit()
527
+ db.refresh(experiment)
528
+ return experiment
529
+
530
+
531
+ def soft_delete_experiment(db: Session, experiment_id: uuid.UUID) -> None:
532
+ experiment = get_experiment(db, experiment_id)
533
+ experiment.deleted_at = datetime.now(UTC)
534
+ db.commit()
@@ -0,0 +1,132 @@
1
+ from __future__ import annotations
2
+
3
+ import uuid
4
+ from datetime import UTC, datetime
5
+
6
+ from sqlalchemy import select
7
+ from sqlalchemy.orm import Session
8
+
9
+ from server.domain.models import Project, ProjectStatusVersion
10
+ from server.domain.schemas import ProjectStatusRevise, ProjectStatusVersionRead
11
+ from server.services.errors import NotFoundError
12
+
13
+
14
+ def _require_project(db: Session, project_id: uuid.UUID) -> Project:
15
+ project = db.get(Project, project_id)
16
+ if project is None:
17
+ raise NotFoundError("Project not found")
18
+ return project
19
+
20
+
21
+ def default_status_template(*, project_key: str, created_at: datetime | None = None) -> str:
22
+ # 使用 tz-aware 的 UTC 时间,避免向 DateTime(timezone=True) 列写入 naive datetime
23
+ # 导致时区信息丢失(与 server 其余路径保持一致)。
24
+ ts = (created_at or datetime.now(UTC)).isoformat()
25
+ return f"""# Current Status — {project_key}
26
+
27
+ ## 当前目标
28
+
29
+ - (待填写)
30
+
31
+ ## 阻塞 / 风险
32
+
33
+ - 无
34
+
35
+ ## 下一步
36
+
37
+ - (待填写)
38
+
39
+ ---
40
+ _最后更新:{ts} · 版本 v1_
41
+
42
+ > 实验/话题清单由 `get_project_status` 快照字段提供(`active_experiments`、`recent_experiments` 等),勿在本 MD 中维护。
43
+ """
44
+
45
+
46
+ def create_initial_status(
47
+ db: Session,
48
+ *,
49
+ project: Project,
50
+ author_agent_id: uuid.UUID,
51
+ ) -> ProjectStatusVersion:
52
+ version = ProjectStatusVersion(
53
+ project_id=project.id,
54
+ version=1,
55
+ content_md=default_status_template(project_key=project.project_key, created_at=project.created_at),
56
+ author_agent_id=author_agent_id,
57
+ change_note="初始版本",
58
+ )
59
+ project.current_status_version = 1
60
+ db.add(version)
61
+ return version
62
+
63
+
64
+ def get_current_status_md(db: Session, project_id: uuid.UUID) -> tuple[int, str | None, datetime | None]:
65
+ project = db.get(Project, project_id)
66
+ if project is None:
67
+ raise NotFoundError("Project not found")
68
+ if project.current_status_version <= 0:
69
+ return 0, None, None
70
+ row = _get_status_version_row(db, project_id, project.current_status_version)
71
+ if row is None:
72
+ return project.current_status_version, None, None
73
+ return row.version, row.content_md, row.created_at
74
+
75
+
76
+ def revise_status(
77
+ db: Session,
78
+ project_id: uuid.UUID,
79
+ author_agent_id: uuid.UUID,
80
+ payload: ProjectStatusRevise,
81
+ ) -> ProjectStatusVersion:
82
+ project = _require_project(db, project_id)
83
+ new_version_number = project.current_status_version + 1
84
+ version = ProjectStatusVersion(
85
+ project_id=project.id,
86
+ version=new_version_number,
87
+ content_md=payload.content_md,
88
+ author_agent_id=author_agent_id,
89
+ change_note=payload.change_note,
90
+ )
91
+ project.current_status_version = new_version_number
92
+ db.add(version)
93
+ db.commit()
94
+ db.refresh(version)
95
+ return version
96
+
97
+
98
+ def list_status_versions(
99
+ db: Session,
100
+ project_id: uuid.UUID,
101
+ *,
102
+ limit: int = 50,
103
+ ) -> list[ProjectStatusVersionRead]:
104
+ _require_project(db, project_id)
105
+ stmt = (
106
+ select(ProjectStatusVersion)
107
+ .where(ProjectStatusVersion.project_id == project_id)
108
+ .order_by(ProjectStatusVersion.version.desc())
109
+ .limit(max(1, min(limit, 200)))
110
+ )
111
+ rows = list(db.scalars(stmt))
112
+ return [ProjectStatusVersionRead.model_validate(row) for row in rows]
113
+
114
+
115
+ def get_status_version(db: Session, project_id: uuid.UUID, version: int) -> ProjectStatusVersionRead:
116
+ _require_project(db, project_id)
117
+ row = _get_status_version_row(db, project_id, version)
118
+ if row is None:
119
+ raise NotFoundError("Project status version not found")
120
+ return ProjectStatusVersionRead.model_validate(row)
121
+
122
+
123
+ def _get_status_version_row(
124
+ db: Session,
125
+ project_id: uuid.UUID,
126
+ version: int,
127
+ ) -> ProjectStatusVersion | None:
128
+ stmt = select(ProjectStatusVersion).where(
129
+ ProjectStatusVersion.project_id == project_id,
130
+ ProjectStatusVersion.version == version,
131
+ )
132
+ return db.scalar(stmt)