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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (144) hide show
  1. cli/__init__.py +0 -0
  2. cli/action_item_escalation.py +177 -0
  3. cli/agent_client.py +554 -0
  4. cli/bridge_state.py +43 -0
  5. cli/commands/__init__.py +13 -0
  6. cli/commands/action.py +142 -0
  7. cli/commands/agent.py +117 -0
  8. cli/commands/audit.py +68 -0
  9. cli/commands/docs.py +179 -0
  10. cli/commands/experiment.py +755 -0
  11. cli/commands/feedback.py +106 -0
  12. cli/commands/notification.py +213 -0
  13. cli/commands/persona.py +63 -0
  14. cli/commands/project.py +87 -0
  15. cli/commands/runtime.py +105 -0
  16. cli/commands/topic.py +361 -0
  17. cli/e2e_collab.py +602 -0
  18. cli/git_checkpoint.py +68 -0
  19. cli/host_worker_types.py +151 -0
  20. cli/main.py +1553 -0
  21. cli/map_command_client.py +497 -0
  22. cli/participant_worker.py +255 -0
  23. cli/reviewer_worker.py +263 -0
  24. cli/runtime/__init__.py +5 -0
  25. cli/runtime/run_lock.py +497 -0
  26. cli/runtime_chat.py +317 -0
  27. cli/session_wake_log.py +235 -0
  28. cli/simple_waker.py +950 -0
  29. cli/table_render.py +113 -0
  30. cli/wake_backend.py +236 -0
  31. cli/worker_cycle_log.py +36 -0
  32. map_client/__init__.py +37 -0
  33. map_client/bootstrap.py +193 -0
  34. map_client/client.py +1045 -0
  35. map_client/config.py +21 -0
  36. map_client/errors.py +283 -0
  37. map_client/exceptions.py +130 -0
  38. map_client/plan_evidence.py +159 -0
  39. map_client/project_config.py +153 -0
  40. map_client/result_template.py +167 -0
  41. map_client/testing.py +27 -0
  42. map_mcp/__init__.py +4 -0
  43. map_mcp/_utils.py +28 -0
  44. map_mcp/auth.py +34 -0
  45. map_mcp/config.py +50 -0
  46. map_mcp/context.py +39 -0
  47. map_mcp/main.py +75 -0
  48. map_mcp/server.py +573 -0
  49. map_mcp/session.py +79 -0
  50. map_sdk/__init__.py +29 -0
  51. map_sdk/evidence.py +68 -0
  52. map_types/__init__.py +203 -0
  53. map_types/enums.py +199 -0
  54. map_types/schemas.py +1351 -0
  55. multi_agent_platform-0.1.0.dist-info/METADATA +298 -0
  56. multi_agent_platform-0.1.0.dist-info/RECORD +144 -0
  57. multi_agent_platform-0.1.0.dist-info/WHEEL +5 -0
  58. multi_agent_platform-0.1.0.dist-info/entry_points.txt +6 -0
  59. multi_agent_platform-0.1.0.dist-info/licenses/LICENSE +21 -0
  60. multi_agent_platform-0.1.0.dist-info/top_level.txt +6 -0
  61. server/__init__.py +0 -0
  62. server/__version__.py +14 -0
  63. server/api/__init__.py +0 -0
  64. server/api/action_items.py +138 -0
  65. server/api/agents.py +412 -0
  66. server/api/audit.py +54 -0
  67. server/api/background_tasks.py +18 -0
  68. server/api/common.py +117 -0
  69. server/api/deps.py +30 -0
  70. server/api/experiments.py +858 -0
  71. server/api/feedback.py +75 -0
  72. server/api/notifications.py +22 -0
  73. server/api/projects.py +209 -0
  74. server/api/router.py +25 -0
  75. server/api/status.py +33 -0
  76. server/api/topics.py +302 -0
  77. server/api/webhooks.py +74 -0
  78. server/auth/__init__.py +8 -0
  79. server/auth/experiment_access.py +66 -0
  80. server/config.py +38 -0
  81. server/db/__init__.py +3 -0
  82. server/db/base.py +5 -0
  83. server/db/deadlock_retry.py +146 -0
  84. server/db/session.py +41 -0
  85. server/domain/__init__.py +3 -0
  86. server/domain/encrypted_types.py +63 -0
  87. server/domain/models.py +713 -0
  88. server/domain/schemas.py +3 -0
  89. server/domain/state_machine.py +79 -0
  90. server/domain/topic_ack_constants.py +9 -0
  91. server/main.py +148 -0
  92. server/scripts/__init__.py +0 -0
  93. server/scripts/migrate_notification_unique.py +231 -0
  94. server/scripts/purge_audit_pollution.py +116 -0
  95. server/services/__init__.py +0 -0
  96. server/services/_lookups.py +26 -0
  97. server/services/acceptance_service.py +90 -0
  98. server/services/action_item_migration_service.py +190 -0
  99. server/services/action_item_service.py +200 -0
  100. server/services/agent_work_service.py +405 -0
  101. server/services/archive_lint_service.py +156 -0
  102. server/services/audit_service.py +457 -0
  103. server/services/auth.py +66 -0
  104. server/services/comment_service.py +173 -0
  105. server/services/errors.py +65 -0
  106. server/services/escalation_resolver.py +248 -0
  107. server/services/evidence_service.py +88 -0
  108. server/services/experiment_capabilities_service.py +277 -0
  109. server/services/inbound_event_service.py +111 -0
  110. server/services/lock_service.py +273 -0
  111. server/services/log_service.py +202 -0
  112. server/services/mention_service.py +730 -0
  113. server/services/notification_service.py +939 -0
  114. server/services/notification_stream.py +138 -0
  115. server/services/permissions.py +147 -0
  116. server/services/persona_activity_service.py +108 -0
  117. server/services/phase_owner_resolver.py +95 -0
  118. server/services/phase_service.py +381 -0
  119. server/services/plan_marker_service.py +235 -0
  120. server/services/plan_service.py +186 -0
  121. server/services/platform_feedback_service.py +114 -0
  122. server/services/project_service.py +534 -0
  123. server/services/project_status_service.py +132 -0
  124. server/services/review_service.py +707 -0
  125. server/services/secret_encryption.py +97 -0
  126. server/services/similarity_service.py +119 -0
  127. server/services/sse_event_schemas.py +17 -0
  128. server/services/status_service.py +68 -0
  129. server/services/template_service.py +134 -0
  130. server/services/text_utils.py +19 -0
  131. server/services/thread_activity.py +180 -0
  132. server/services/todo_persona_filter.py +73 -0
  133. server/services/todo_service.py +604 -0
  134. server/services/topic_ack_service.py +312 -0
  135. server/services/topic_action_item_ops.py +538 -0
  136. server/services/topic_comment_kind.py +14 -0
  137. server/services/topic_comment_service.py +237 -0
  138. server/services/topic_helpers.py +32 -0
  139. server/services/topic_lifecycle_service.py +478 -0
  140. server/services/topic_progress_service.py +40 -0
  141. server/services/topic_resolve_service.py +234 -0
  142. server/services/topic_service.py +102 -0
  143. server/services/topic_work_item_service.py +570 -0
  144. server/services/webhook_service.py +273 -0
map_types/schemas.py ADDED
@@ -0,0 +1,1351 @@
1
+ import re
2
+ import uuid
3
+ from datetime import datetime
4
+ from typing import Annotated, Any, Literal
5
+
6
+ from pydantic import AliasChoices, BaseModel, ConfigDict, Field, computed_field, field_validator, model_validator
7
+
8
+ from map_types.enums import (
9
+ AcceptanceType,
10
+ ActionItemCategory,
11
+ AgentRole,
12
+ CommentAnchorType,
13
+ ExperimentPhase,
14
+ FeedbackCategory,
15
+ FeedbackStatus,
16
+ InboundEventSource,
17
+ NotificationCategory,
18
+ NotificationFingerprintVersion,
19
+ PhaseOwner,
20
+ ResolutionReason,
21
+ ReviewArchivedReason,
22
+ ReviewItemKind,
23
+ ReviewItemStatus,
24
+ ReviewSubstituteKind,
25
+ ReviewVerdict,
26
+ TopicActionItemStatus,
27
+ TopicCommentKind,
28
+ TopicDiscussionRound,
29
+ TopicStatus,
30
+ )
31
+
32
+ PROJECT_KEY_PATTERN = re.compile(r"^[a-z0-9][a-z0-9-_]{0,62}$")
33
+
34
+
35
+ class ORMModel(BaseModel):
36
+ model_config = ConfigDict(from_attributes=True)
37
+
38
+
39
+ # --- Project ---
40
+
41
+
42
+ class ProjectCreate(BaseModel):
43
+ project_key: str = Field(min_length=2, max_length=64)
44
+ name: str = Field(min_length=1, max_length=255)
45
+ workspace_path: str = Field(min_length=1, max_length=1024)
46
+ description: str | None = None
47
+
48
+ @field_validator("project_key")
49
+ @classmethod
50
+ def validate_project_key(cls, value: str) -> str:
51
+ key = value.strip().lower()
52
+ if not PROJECT_KEY_PATTERN.match(key):
53
+ raise ValueError("project_key must match [a-z0-9][a-z0-9-_]{0,62}")
54
+ return key
55
+
56
+
57
+ class ProjectUpdate(BaseModel):
58
+ name: str | None = Field(default=None, min_length=1, max_length=255)
59
+ workspace_path: str | None = Field(default=None, min_length=1, max_length=1024)
60
+ description: str | None = None
61
+ archived: bool | None = None
62
+
63
+
64
+ class ProjectRead(ORMModel):
65
+ id: uuid.UUID
66
+ project_key: str
67
+ name: str
68
+ workspace_path: str
69
+ description: str | None
70
+ current_status_version: int
71
+ created_at: datetime
72
+ archived_at: datetime | None
73
+
74
+
75
+ class ProjectStatusRead(BaseModel):
76
+ project: ProjectRead
77
+ experiment_counts_by_phase: dict[str, int]
78
+ active_experiments: list["ExperimentSummaryRead"] = Field(default_factory=list)
79
+ recent_experiments: list["ExperimentSummaryRead"]
80
+ open_topics: list["TopicSummaryRead"] = Field(default_factory=list)
81
+ status_version: int = 0
82
+ status_md: str | None = None
83
+ status_updated_at: datetime | None = None
84
+
85
+
86
+ class ProjectStatusRevise(BaseModel):
87
+ content_md: str = Field(min_length=1)
88
+ change_note: str | None = Field(default=None, max_length=1024)
89
+
90
+
91
+ class ProjectStatusVersionRead(ORMModel):
92
+ id: uuid.UUID
93
+ project_id: uuid.UUID
94
+ version: int
95
+ content_md: str
96
+ author_agent_id: uuid.UUID
97
+ change_note: str | None
98
+ created_at: datetime
99
+
100
+
101
+ # --- Plan ---
102
+
103
+
104
+ class PlanInput(BaseModel):
105
+ content_md: str = Field(min_length=1)
106
+ change_note: str | None = Field(default=None, max_length=1024)
107
+
108
+
109
+ class PlanRevise(BaseModel):
110
+ content_md: str = Field(min_length=1)
111
+ change_note: str | None = Field(default=None, max_length=1024)
112
+ addressed_item_ids: list[uuid.UUID] = Field(default_factory=list)
113
+
114
+
115
+ class PlanVersionRead(ORMModel):
116
+ id: uuid.UUID
117
+ experiment_id: uuid.UUID
118
+ version: int
119
+ content_md: str
120
+ author_agent_id: uuid.UUID
121
+ change_note: str | None
122
+ created_at: datetime
123
+
124
+
125
+ # --- Review ---
126
+
127
+
128
+ class ReviewCreate(BaseModel):
129
+ reasonable_items: list[str] = Field(default_factory=list)
130
+ unreasonable_items: list[str] = Field(default_factory=list)
131
+ substitute_reason: str | None = Field(
132
+ default=None,
133
+ min_length=1,
134
+ description="Required when admin submits a substitute review (admin_for_others).",
135
+ )
136
+
137
+
138
+ class ReviewItemRead(ORMModel):
139
+ id: uuid.UUID
140
+ review_id: uuid.UUID
141
+ kind: ReviewItemKind
142
+ content: str
143
+ status: ReviewItemStatus | None
144
+ last_resolution_reason: ResolutionReason | None = None
145
+ created_at: datetime
146
+ updated_at: datetime
147
+ waived_reason: str | None = Field(
148
+ default=None,
149
+ description=(
150
+ "Populated server-side from the latest accept-result verdict_file "
151
+ "where verdict='waived' for this item. Read-only convenience field "
152
+ "for participant-facing UIs; the source of truth is "
153
+ "experiment_logs.metadata_json.verdict_file."
154
+ ),
155
+ )
156
+
157
+
158
+ class ReviewItemUpdate(BaseModel):
159
+ status: ReviewItemStatus
160
+
161
+
162
+ class ReviewRead(ORMModel):
163
+ id: uuid.UUID
164
+ experiment_id: uuid.UUID
165
+ reviewer_agent_id: uuid.UUID
166
+ plan_version: int
167
+ substitute_kind: ReviewSubstituteKind = ReviewSubstituteKind.none
168
+ created_at: datetime
169
+ archived_at: datetime | None = None
170
+ archived_reason: ReviewArchivedReason | None = None
171
+ items: list[ReviewItemRead] = Field(default_factory=list)
172
+
173
+
174
+ # --- Comment ---
175
+
176
+
177
+ class CommentCreate(BaseModel):
178
+ anchor_type: CommentAnchorType
179
+ anchor_id: uuid.UUID
180
+ parent_id: uuid.UUID | None = None
181
+ body: str = Field(min_length=1)
182
+
183
+
184
+ class CommentRead(ORMModel):
185
+ id: uuid.UUID
186
+ experiment_id: uuid.UUID
187
+ anchor_type: CommentAnchorType
188
+ anchor_id: uuid.UUID
189
+ parent_comment_id: uuid.UUID | None
190
+ author_agent_id: uuid.UUID
191
+ author_name: str | None = None
192
+ body: str
193
+ created_at: datetime
194
+ unresolved_mentions: list[str] = Field(default_factory=list)
195
+
196
+
197
+ class CommentTreeNode(CommentRead):
198
+ children: list["CommentTreeNode"] = Field(default_factory=list)
199
+
200
+
201
+ # --- Experiment ---
202
+
203
+
204
+ class ExperimentCreate(BaseModel):
205
+ title: str = Field(min_length=1, max_length=512)
206
+ description: str | None = None
207
+ plan: PlanInput
208
+ submit_for_review: bool = False
209
+ topic_id: uuid.UUID | None = None
210
+
211
+
212
+ class ExperimentUpdate(BaseModel):
213
+ title: str | None = Field(default=None, min_length=1, max_length=512)
214
+ description: str | None = None
215
+ archived: bool | None = None
216
+
217
+
218
+ class ExperimentSummaryRead(ORMModel):
219
+ id: uuid.UUID
220
+ project_id: uuid.UUID
221
+ creator_agent_id: uuid.UUID
222
+ title: str
223
+ description: str | None
224
+ phase: ExperimentPhase
225
+ current_plan_version: int
226
+ topic_id: uuid.UUID | None = None
227
+ warnings: list[str] = Field(default_factory=list)
228
+ created_at: datetime
229
+ updated_at: datetime
230
+ archived_at: datetime | None = None
231
+ # --- execution lock (per-project; CP-3) ---
232
+ lock_holder_experiment_id: uuid.UUID | None = None
233
+ lock_acquired_at: datetime | None = None
234
+ lock_ttl_seconds: int | None = None
235
+ next_attempt_at: datetime | None = None
236
+ lock_skip_count: int = 0
237
+ # Populated for todos / waker fingerprints; defaults to 0 on list endpoints.
238
+ open_unreasonable_count: int = 0
239
+ log_count: int = 0
240
+ latest_log_summary: str | None = None
241
+ # Per-agent capabilities (experiment plan v2 AC#3); populated when actor context exists.
242
+ actions: list[str] = Field(default_factory=list)
243
+ blocked_on: str | None = None
244
+ # Historical annotation: approved/running/done without qualifying non-creator review.
245
+ legacy_self_review: bool = False
246
+ # --- phase routing (experiment f873c287 I1(b)) -----------------------
247
+ # Decision-owner role for the current phase. Drives the UI "host
248
+ # blocked, waiting on {phase_owner}" copy and the
249
+ # ``informational_only`` partition flag below.
250
+ phase_owner: PhaseOwner = PhaseOwner.host
251
+ # Auto-classification (I1(a)): True iff
252
+ # actions == [] AND blocked_on != None AND phase_owner != host.
253
+ # Surfaces in my_open_experiments so waker / web UI can demote the
254
+ # entry out of the host's actionable obligation bucket. The
255
+ # partition is "informational only" — host can still see and act
256
+ # on it; the UI just shows a "waiting on {phase_owner}" copy.
257
+ informational_only: bool = False
258
+ # 0db51e10 I3(5d): phase visibility whitelist fold. True iff the
259
+ # configured whitelist excludes the current phase for the caller's
260
+ # role. Mirrors ``actions == [] AND blocked_on ==
261
+ # "hidden_for_current_persona"`` so CLI / web UI can render a
262
+ # single "folded" copy without leaking actions.
263
+ hidden_for_current_persona: bool = False
264
+ # b72d0542 I1.b: 4-段 template soft validation result, populated only
265
+ # on ``experiment complete`` calls. None on other endpoints (status,
266
+ # list, etc.) — server sets it explicitly in ``complete_experiment``.
267
+ template_validation: "TemplateValidationSchema | None" = None
268
+
269
+
270
+ class AcceptanceStatusRead(BaseModel):
271
+ id: str
272
+ description: str
273
+ acceptance_type: AcceptanceType
274
+ evidence_provided: bool = False
275
+ reviewer_verdict: str | None = None
276
+
277
+
278
+ class ExperimentDetailRead(ExperimentSummaryRead):
279
+ current_plan: PlanVersionRead | None = None
280
+ plan_version_count: int = 0
281
+ review_count: int = 0
282
+ acceptance_status: list[AcceptanceStatusRead] = Field(default_factory=list)
283
+
284
+
285
+ class ExperimentLockRead(ORMModel):
286
+ """Per-project execution-lock snapshot (CP-3).
287
+
288
+ 定义在 ``map_types.schemas`` 以便 server API 与 SDK 共享同一响应模型;
289
+ server 端用 ``ExperimentLockRead.model_validate(result)`` 从 lock_service
290
+ 的结果对象构造(``ORMModel`` 已开启 ``from_attributes``)。
291
+ """
292
+
293
+ experiment_id: uuid.UUID
294
+ project_id: uuid.UUID
295
+ holder: uuid.UUID | None = None
296
+ acquired_at: datetime | None = None
297
+ ttl_seconds: int | None = None
298
+ next_attempt_at: datetime | None = None
299
+ skip_count: int = 0
300
+
301
+
302
+ class ExperimentLockStalledScanRead(BaseModel):
303
+ notification_ids: list[uuid.UUID]
304
+ emitted_count: int
305
+
306
+
307
+ # --- Log ---
308
+
309
+
310
+ class ExperimentLogCreate(BaseModel):
311
+ summary: str = Field(min_length=1, max_length=1024)
312
+ content_md: str = Field(min_length=1)
313
+ metadata: dict | None = None
314
+ # b72d0542 I1.b(2)(e): when the similarity check would emit a warning,
315
+ # callers can set this to True to acknowledge and skip the soft
316
+ # warning. Server writes a ``log.force_skip`` audit row when the
317
+ # warning was actually suppressed (no-op when no warning fired).
318
+ force_skip_similarity: bool = False
319
+
320
+
321
+ class ExperimentLogRead(ORMModel):
322
+ id: uuid.UUID
323
+ experiment_id: uuid.UUID
324
+ author_agent_id: uuid.UUID
325
+ summary: str
326
+ content_md: str
327
+ metadata_json: dict | None
328
+ created_at: datetime
329
+
330
+
331
+ # --- 8ac93d4e I1.c — Log create response wrapper with evidence validation ---
332
+
333
+ EvidenceWarningCode = Literal["MISSING_EVIDENCE_KEY"]
334
+
335
+
336
+ class EvidenceWarningSchema(BaseModel):
337
+ code: EvidenceWarningCode
338
+ missing_key: str
339
+ plan_required: bool = True
340
+ log_provided: bool = False
341
+
342
+
343
+ class EvidenceValidationSchema(BaseModel):
344
+ """Soft validation result for an ``ExperimentLogCreate`` payload.
345
+
346
+ The validator never blocks the log save — ``valid`` is always True.
347
+ ``warnings`` lists evidence_keys declared in plan frontmatter that are
348
+ missing from the supplied metadata; ``parse_error`` is set when the
349
+ plan frontmatter existed but its YAML failed to parse.
350
+ """
351
+
352
+ warnings: list[EvidenceWarningSchema] = Field(default_factory=list)
353
+ parse_error: str | None = None
354
+ plan_keys: list[str] = Field(default_factory=list)
355
+ valid: bool = True
356
+
357
+
358
+ class LogCreateResponse(BaseModel):
359
+ """Wrapper returned by ``POST /experiments/{id}/logs`` (8ac93d4e I1.c).
360
+
361
+ The persisted ``log`` is unchanged from ``ExperimentLogRead`` so v1
362
+ consumers can still parse the response by reaching into ``log``. The
363
+ new ``validation`` field surfaces soft evidence-key warnings without
364
+ breaking v1 schema.
365
+
366
+ b72d0542 I1.b(2)(e): ``similarity_warning`` carries the soft
367
+ content-similarity check result (always None on endpoints other than
368
+ ``POST /experiments/{id}/logs``). When the warning fires AND
369
+ ``force_skip_similarity=True`` was supplied, the warning is suppressed
370
+ in this response and a ``log.force_skip`` audit row is written
371
+ instead. ``force_skip`` field echoes whether the caller opted in
372
+ (False on responses without a similarity check).
373
+ """
374
+
375
+ log: ExperimentLogRead
376
+ validation: EvidenceValidationSchema
377
+ similarity_warning: "SimilarityWarningSchema | None" = None
378
+ force_skip: bool = False
379
+
380
+
381
+ # --- b72d0542 I1.b — Result submission 4-段 template validation ---
382
+
383
+ TemplateWarningCode = Literal[
384
+ "MISSING_TEMPLATE_SECTION",
385
+ "NO_LINK_IN_LOG_SECTION",
386
+ "MALFORMED_MARKDOWN_LINK",
387
+ ]
388
+
389
+
390
+ class TemplateWarningSchema(BaseModel):
391
+ code: TemplateWarningCode
392
+ section: str | None = None
393
+ detail: str | None = None
394
+
395
+
396
+ class TemplateValidationSchema(BaseModel):
397
+ """Soft validation result for a result submission (b72d0542 I1.b).
398
+
399
+ Mirrors :class:`EvidenceValidationSchema` (8ac93d4e I1.c): ``valid``
400
+ is always True (soft validation never blocks ``experiment complete``).
401
+ ``warnings`` lists missing 4-段 sections or malformed markdown links
402
+ detected in the ``## 实施 log`` section body.
403
+ """
404
+
405
+ warnings: list[TemplateWarningSchema] = Field(default_factory=list)
406
+ sections_present: list[str] = Field(default_factory=list)
407
+ log_link_count: int = 0
408
+ valid: bool = True
409
+
410
+
411
+ # --- b72d0542 I1.b(2)(e) — Log similarity soft warning ------------------
412
+
413
+ SimilarityWarningCode = Literal["HIGH_CONTENT_SIMILARITY"]
414
+
415
+
416
+ class SimilarityWarningSchema(BaseModel):
417
+ """Soft warning fired when the new log body is too similar to a
418
+ previous log on the same experiment (b72d0542 I1.b(2)(b)).
419
+
420
+ ``score`` is the cosine similarity in ``[0.0, 1.0]`` between the new
421
+ log's content embedding and the most-similar previous log on the
422
+ same experiment. ``threshold`` is the warn threshold (plan default
423
+ 0.7). ``ref_log_id`` points to the previous log the score was
424
+ computed against. ``model`` is the embedding model id used (e.g.
425
+ ``sentence-transformers/all-MiniLM-L6-v2``).
426
+
427
+ The warning is non-blocking; callers may pass
428
+ ``force_skip_similarity=True`` to suppress it AND write a
429
+ ``log.force_skip`` audit row instead.
430
+ """
431
+
432
+ code: SimilarityWarningCode
433
+ score: float
434
+ threshold: float
435
+ ref_log_id: uuid.UUID
436
+ model: str
437
+
438
+
439
+ class ExperimentComplete(BaseModel):
440
+ summary: str = Field(min_length=1, max_length=1024)
441
+ content_md: str = Field(min_length=1)
442
+ metadata: dict | None = None
443
+
444
+
445
+ # --- Review verdict file (accept-result structured) -----------------------
446
+
447
+
448
+ # WaivedReason v2: min_length=50 (Round 2 Summary consensus) +
449
+ # max_length=1000 (v2 expanded to fit waiver four-part rationale:
450
+ # "豁免什么 / 为何豁免 / 影响哪些下游消费方 / 是否有补偿措施").
451
+ WaivedReason = Annotated[
452
+ str,
453
+ Field(
454
+ min_length=50,
455
+ max_length=1000,
456
+ description="Waiver rationale (review item verdict == waived).",
457
+ ),
458
+ ]
459
+
460
+
461
+ class ReviewVerdictItem(BaseModel):
462
+ item_id: uuid.UUID
463
+ verdict: ReviewVerdict
464
+ reason: WaivedReason | None = Field(
465
+ default=None,
466
+ description="Required when verdict == waived; otherwise optional.",
467
+ )
468
+
469
+ @field_validator("verdict", mode="before")
470
+ @classmethod
471
+ def _accept_review_verdict_aliases(cls, v: object) -> object:
472
+ """cli-ux PR3: accept accept|reject|dismiss as aliases.
473
+
474
+ Maps to the canonical ``ReviewVerdict`` enum:
475
+
476
+ * ``accept`` / ``passed`` → ``ReviewVerdict.passed``
477
+ * ``reject`` / ``failed`` → ``ReviewVerdict.failed``
478
+ * ``dismiss`` / ``waived`` → ``ReviewVerdict.waived``
479
+
480
+ Serialized output is still the canonical ``passed|failed|waived``
481
+ string, so DB rows and downstream JSON consumers see no change.
482
+ """
483
+ if isinstance(v, str):
484
+ normalized = v.strip().lower()
485
+ mapping: dict[str, ReviewVerdict] = {
486
+ "accept": ReviewVerdict.passed,
487
+ "passed": ReviewVerdict.passed,
488
+ "reject": ReviewVerdict.failed,
489
+ "failed": ReviewVerdict.failed,
490
+ "dismiss": ReviewVerdict.waived,
491
+ "waived": ReviewVerdict.waived,
492
+ }
493
+ if normalized in mapping:
494
+ return mapping[normalized]
495
+ return v
496
+
497
+ @model_validator(mode="after")
498
+ def _waived_requires_reason(self) -> "ReviewVerdictItem":
499
+ if self.verdict == ReviewVerdict.waived and not (self.reason and self.reason.strip()):
500
+ raise ValueError("verdict 'waived' requires non-empty reason (WaivedReason, 50-1000 chars)")
501
+ return self
502
+
503
+
504
+ class ReviewInvariantCheck(BaseModel):
505
+ item_id: uuid.UUID
506
+ verified: bool
507
+ note: str | None = Field(default=None, max_length=1000)
508
+
509
+
510
+ class ReviewVerdictFile(BaseModel):
511
+ review_id: uuid.UUID
512
+ verdicts: list[ReviewVerdictItem] = Field(default_factory=list)
513
+ invariants: list[ReviewInvariantCheck] = Field(default_factory=list)
514
+
515
+ @field_validator("verdicts")
516
+ @classmethod
517
+ def _no_duplicate_item_ids(cls, v: list[ReviewVerdictItem]) -> list[ReviewVerdictItem]:
518
+ seen: set[uuid.UUID] = set()
519
+ for item in v:
520
+ if item.item_id in seen:
521
+ raise ValueError(f"Duplicate item_id in verdicts: {item.item_id}")
522
+ seen.add(item.item_id)
523
+ return v
524
+
525
+
526
+ # --- Result decision -------------------------------------------------------
527
+
528
+
529
+ class ExperimentResultDecision(BaseModel):
530
+ summary: str = Field(min_length=1, max_length=1024)
531
+ content_md: str = Field(min_length=1)
532
+ metadata: dict | None = None
533
+ verdict_file: ReviewVerdictFile | None = Field(
534
+ default=None,
535
+ description=(
536
+ "Optional structured verdict file (CLI: --review-verdict-file). "
537
+ "When provided, server validates item_id.review_id ownership and "
538
+ "records pre_schema_accept_result='false' + verdict breakdown in "
539
+ "log metadata. Omit (legacy) → pre_schema_accept_result='true' with "
540
+ "an info-level warning."
541
+ ),
542
+ )
543
+
544
+
545
+ class ExperimentBundleRead(BaseModel):
546
+ """Aggregated experiment page payload (detail + plans + reviews + comment tree + logs)."""
547
+
548
+ experiment: ExperimentDetailRead
549
+ plans: list[PlanVersionRead] = Field(default_factory=list)
550
+ reviews: list[ReviewRead] = Field(default_factory=list)
551
+ comments: list[CommentTreeNode] = Field(default_factory=list)
552
+ logs: list[ExperimentLogRead] = Field(default_factory=list)
553
+
554
+
555
+ # --- Status ---
556
+
557
+
558
+ class GlobalStatusRead(BaseModel):
559
+ total_experiments_by_phase: dict[str, int]
560
+ projects: list[ProjectStatusRead]
561
+ recent_experiments: list[ExperimentSummaryRead]
562
+
563
+
564
+ class AgentRead(ORMModel):
565
+ id: uuid.UUID
566
+ name: str
567
+ role: AgentRole
568
+ project_id: uuid.UUID | None
569
+ project_key: str | None = None
570
+ created_at: datetime
571
+
572
+
573
+ class AgentCreateResponse(AgentRead):
574
+ api_token: str
575
+
576
+
577
+ class AgentCreate(BaseModel):
578
+ """Body for ``POST /api/v1/agents`` (cleanup experiment f12a5638 P2 #5).
579
+
580
+ Replaces the legacy query-param creation contract so the endpoint
581
+ follows the same body-driven pattern as every other write endpoint
582
+ in the API. ``project_id`` and ``project_key`` are both accepted for
583
+ caller convenience — at most one must resolve to a project for
584
+ ``role=agent`` (admin-only). When both are omitted the service layer
585
+ raises ``ValueError`` which maps to 400.
586
+ """
587
+
588
+ name: str = Field(min_length=1, max_length=128)
589
+ role: AgentRole = AgentRole.agent
590
+ project_id: uuid.UUID | None = None
591
+ project_key: str | None = None
592
+
593
+
594
+ # --- Topic ---
595
+
596
+
597
+ class TopicCreate(BaseModel):
598
+ title: str = Field(min_length=1, max_length=512)
599
+ description: str | None = None
600
+
601
+
602
+ class TopicUpdate(BaseModel):
603
+ title: str | None = Field(default=None, min_length=1, max_length=512)
604
+ description: str | None = None
605
+ pinned: bool | None = None
606
+ archived: bool | None = None
607
+
608
+
609
+ class TopicAdvanceRound(BaseModel):
610
+ increment_summary: bool = True
611
+ acknowledged_by: list[uuid.UUID] = Field(default_factory=list)
612
+ ack: Literal["accept", "reject", "dismiss"] | None = None
613
+
614
+
615
+ class TopicActionItemCreate(BaseModel):
616
+ title: str = Field(min_length=1, max_length=512)
617
+ description: str | None = None
618
+ owner_agent_id: uuid.UUID | None = None
619
+ due_at: datetime | None = None
620
+ linked_experiment_id: uuid.UUID | None = None
621
+ category: ActionItemCategory | None = None
622
+ # Optional id enables resolve-time upsert. Service layer preserves status when id
623
+ # matches an existing item; new ids are inserted as open. None means "always insert".
624
+ id: uuid.UUID | None = None
625
+
626
+
627
+ # Minimum reason length enforced per-category; mirrors server-side validation so the
628
+ # CLI and API stay consistent (A1 acceptance A1-3 + A1-4).
629
+ _CANCEL_REASON_MIN_LENGTH: dict[ActionItemCategory | None, int] = {
630
+ None: 8,
631
+ ActionItemCategory.unspecified: 8,
632
+ ActionItemCategory.implementation: 8,
633
+ ActionItemCategory.decision: 16,
634
+ }
635
+
636
+
637
+ def _cancel_reason_min_length(category: ActionItemCategory | None) -> int:
638
+ return _CANCEL_REASON_MIN_LENGTH.get(category, 8)
639
+
640
+
641
+ class ActionItemCancel(BaseModel):
642
+ """Body of ``POST /api/v1/action-items/{id}/cancel`` and ``map action cancel``."""
643
+
644
+ reason: str = Field(min_length=1)
645
+ category: ActionItemCategory | None = None
646
+
647
+ @model_validator(mode="after")
648
+ def validate_reason_length(self) -> "ActionItemCancel":
649
+ threshold = _cancel_reason_min_length(self.category)
650
+ if len(self.reason.strip()) < threshold:
651
+ raise ValueError(
652
+ f"reason too short: {len(self.reason.strip())} < {threshold} "
653
+ f"(category={self.category.value if self.category else 'unspecified'})"
654
+ )
655
+ return self
656
+
657
+
658
+ class TopicResolve(BaseModel):
659
+ decision: str | None = Field(default=None, min_length=1)
660
+ rationale: str | None = None
661
+ rejected_options: str | None = None
662
+ open_questions: str | None = None
663
+ no_decision_reason: str | None = Field(default=None, min_length=1)
664
+ action_items: list[TopicActionItemCreate] = Field(default_factory=list)
665
+
666
+ @field_validator("decision", "rationale", "rejected_options", "open_questions", "no_decision_reason", mode="before")
667
+ @classmethod
668
+ def normalize_blank_text(cls, value):
669
+ if value is None:
670
+ return None
671
+ if not isinstance(value, str):
672
+ return value
673
+ text = value.strip()
674
+ return text or None
675
+
676
+ @model_validator(mode="after")
677
+ def validate_resolution_content(self) -> "TopicResolve":
678
+ if not self.decision and not self.no_decision_reason:
679
+ raise ValueError("decision or no_decision_reason is required")
680
+ return self
681
+
682
+
683
+ class TopicActionItemRead(BaseModel):
684
+ id: uuid.UUID
685
+ decision_id: uuid.UUID
686
+ project_id: uuid.UUID
687
+ topic_id: uuid.UUID
688
+ title: str
689
+ description: str | None = None
690
+ owner_agent_id: uuid.UUID | None = None
691
+ owner_name: str | None = None
692
+ status: TopicActionItemStatus
693
+ due_at: datetime | None = None
694
+ linked_experiment_id: uuid.UUID | None = None
695
+ # 当前关联实验所处阶段。当 action_item 仍为 open 但实验已进入
696
+ # result_review 时,用户可据此判断"在等 reviewer 审批"而非 host 待办。
697
+ linked_experiment_phase: ExperimentPhase | None = None
698
+ category: ActionItemCategory | None = None
699
+ cancel_reason: str | None = None
700
+ suggested_linked_experiment_id: uuid.UUID | None = None
701
+ suggested_linked_experiment_title: str | None = None
702
+ # Wake / stale escalation fields (experiment B, plan §2 §3). Surfaced so
703
+ # the runtime-waker can run should_wake_action_item() against the same
704
+ # payload it gets from the todos endpoint, avoiding a second fetch.
705
+ wake_count: int = 0
706
+ first_open_at: datetime | None = None
707
+ last_woken_at: datetime | None = None
708
+ stale_at: datetime | None = None
709
+ created_at: datetime
710
+ updated_at: datetime
711
+
712
+
713
+ class TopicDecisionRead(BaseModel):
714
+ id: uuid.UUID
715
+ project_id: uuid.UUID
716
+ topic_id: uuid.UUID
717
+ topic_title: str | None = None
718
+ author_agent_id: uuid.UUID
719
+ author_name: str | None = None
720
+ decision: str | None = None
721
+ rationale: str | None = None
722
+ rejected_options: str | None = None
723
+ open_questions: str | None = None
724
+ no_decision_reason: str | None = None
725
+ action_items: list[TopicActionItemRead] = Field(default_factory=list)
726
+ created_at: datetime
727
+ updated_at: datetime
728
+
729
+
730
+ class TopicSummaryRead(BaseModel):
731
+ model_config = ConfigDict(populate_by_name=True)
732
+
733
+ id: uuid.UUID
734
+ project_id: uuid.UUID
735
+ creator_agent_id: uuid.UUID
736
+ creator_name: str | None = None
737
+ title: str
738
+ description: str | None
739
+ status: TopicStatus
740
+ pinned: bool = False
741
+ discussion_round: TopicDiscussionRound = TopicDiscussionRound.round1
742
+ round_summary_count: int = 0
743
+ comment_count: int = 0
744
+ experiment_count: int = 0
745
+ last_comment_id: uuid.UUID | None = None
746
+ last_comment_author_agent_id: uuid.UUID | None = None
747
+ last_comment_author_name: str | None = None
748
+ last_comment_excerpt: str | None = None
749
+ my_comment_count: int | None = None
750
+ dismissed_at: datetime | None = None
751
+ stale_since: datetime | None = Field(
752
+ default=None,
753
+ validation_alias=AliasChoices("stale_since", "advance_round_pending_since"),
754
+ description="When this topic's pending action started waiting (round ack, reply, etc.). Renamed from advance_round_pending_since in N=2; old name remains readable until N=2.",
755
+ )
756
+ created_at: datetime
757
+ updated_at: datetime
758
+ archived_at: datetime | None = None
759
+
760
+ @computed_field # type: ignore[prop-decorator]
761
+ @property
762
+ def advance_round_pending_since(self) -> datetime | None:
763
+ """DEPRECATED alias for stale_since — kept readable for clients still using the old name."""
764
+ return self.stale_since
765
+
766
+
767
+ class TopicCommentCreate(BaseModel):
768
+ body: str = Field(min_length=1)
769
+ parent_id: uuid.UUID | None = None
770
+
771
+
772
+ class TopicCommentRead(ORMModel):
773
+ id: uuid.UUID
774
+ topic_id: uuid.UUID
775
+ author_agent_id: uuid.UUID
776
+ author_name: str | None = None
777
+ parent_comment_id: uuid.UUID | None
778
+ body: str
779
+ kind: TopicCommentKind = TopicCommentKind.user
780
+ comment_seq: int
781
+ created_at: datetime
782
+ unresolved_mentions: list[str] = Field(default_factory=list)
783
+
784
+
785
+ class TopicCommentTreeNode(TopicCommentRead):
786
+ children: list["TopicCommentTreeNode"] = Field(default_factory=list)
787
+
788
+
789
+ class TopicRead(TopicSummaryRead):
790
+ experiments: list[ExperimentSummaryRead] = Field(default_factory=list)
791
+ comments: list[TopicCommentTreeNode] = Field(default_factory=list)
792
+ decision: TopicDecisionRead | None = None
793
+
794
+
795
+ class TopicProgressCommentRead(ORMModel):
796
+ id: uuid.UUID
797
+ author_agent_id: uuid.UUID
798
+ author_name: str | None = None
799
+ parent_comment_id: uuid.UUID | None
800
+ body: str
801
+ excerpt: str
802
+ created_at: datetime
803
+
804
+
805
+ class TopicWorkItemRead(BaseModel):
806
+ model_config = ConfigDict(populate_by_name=True)
807
+
808
+ kind: str
809
+ priority: str
810
+ topic_id: uuid.UUID
811
+ topic_title: str
812
+ source_comment_id: uuid.UUID | None = None
813
+ thread_root_id: uuid.UUID | None = None
814
+ required_agent_id: uuid.UUID
815
+ reason: str
816
+ idempotency_key: str
817
+ clear_action: str
818
+ excerpt: str
819
+ created_at: datetime
820
+ discussion_round: str | None = None
821
+ stale_since: datetime | None = Field(
822
+ default=None,
823
+ validation_alias=AliasChoices("stale_since", "advance_round_pending_since"),
824
+ )
825
+
826
+ @computed_field # type: ignore[prop-decorator]
827
+ @property
828
+ def advance_round_pending_since(self) -> datetime | None:
829
+ """DEPRECATED alias for stale_since."""
830
+ return self.stale_since
831
+
832
+
833
+ class TopicProgressItemRead(BaseModel):
834
+ topic_id: uuid.UUID
835
+ topic_title: str
836
+ discussion_round: TopicDiscussionRound
837
+ last_comment_author_agent_id: uuid.UUID | None = None
838
+ last_comment_author_name: str | None = None
839
+ my_last_comment_id: uuid.UUID | None = None
840
+ new_comments: list[TopicProgressCommentRead] = Field(default_factory=list)
841
+ new_comment_count: int = 0
842
+ work_items: list[TopicWorkItemRead] = Field(default_factory=list)
843
+
844
+
845
+ class TopicProgressListRead(BaseModel):
846
+ items: list[TopicProgressItemRead] = Field(default_factory=list)
847
+ total: int = 0
848
+
849
+
850
+ class PendingReplyRead(BaseModel):
851
+ item_id: uuid.UUID
852
+ experiment_id: uuid.UUID
853
+ experiment_title: str
854
+ content: str
855
+ status: ReviewItemStatus
856
+ updated_at: datetime
857
+
858
+
859
+ class PendingPlanRevisionRead(BaseModel):
860
+ experiment_id: uuid.UUID
861
+ experiment_title: str
862
+ current_plan_version: int
863
+ open_unreasonable_count: int
864
+ blocked_on: str = "open_unreasonable_item"
865
+ actions: list[str] = Field(default_factory=lambda: ["plan_revise"])
866
+ updated_at: datetime
867
+
868
+
869
+ class PendingTopicReplyTodoRead(BaseModel):
870
+ topic_id: uuid.UUID
871
+ topic_title: str
872
+ comment_id: uuid.UUID
873
+ parent_comment_id: uuid.UUID | None = None
874
+ thread_root_id: uuid.UUID
875
+ author_agent_id: uuid.UUID
876
+ author_name: str | None = None
877
+ excerpt: str
878
+ created_at: datetime
879
+
880
+
881
+ class PendingRoundAckTodoRead(BaseModel):
882
+ model_config = ConfigDict(populate_by_name=True)
883
+
884
+ topic_id: uuid.UUID
885
+ topic_title: str
886
+ discussion_round: TopicDiscussionRound
887
+ round_summary_count: int = 0
888
+ summary_comment_id: uuid.UUID | None = None
889
+ summary_excerpt: str | None = None
890
+ stale_since: datetime | None = Field(
891
+ default=None,
892
+ validation_alias=AliasChoices("stale_since", "advance_round_pending_since"),
893
+ )
894
+ updated_at: datetime
895
+
896
+ @computed_field # type: ignore[prop-decorator]
897
+ @property
898
+ def advance_round_pending_since(self) -> datetime | None:
899
+ """DEPRECATED alias for stale_since."""
900
+ return self.stale_since
901
+
902
+
903
+ class PendingAdvanceRoundTodoRead(BaseModel):
904
+ """Host-owned topics where participant acks are complete and advance-round is due."""
905
+
906
+ model_config = ConfigDict(populate_by_name=True)
907
+
908
+ topic_id: uuid.UUID
909
+ topic_title: str
910
+ discussion_round: TopicDiscussionRound
911
+ round_summary_count: int = 0
912
+ stale_since: datetime | None = Field(
913
+ default=None,
914
+ validation_alias=AliasChoices("stale_since", "advance_round_pending_since"),
915
+ )
916
+ updated_at: datetime
917
+
918
+ @computed_field # type: ignore[prop-decorator]
919
+ @property
920
+ def advance_round_pending_since(self) -> datetime | None:
921
+ """DEPRECATED alias for stale_since."""
922
+ return self.stale_since
923
+
924
+
925
+ class StaleOpenTopicTodoRead(BaseModel):
926
+ """Host-owned open topic that has had no activity for the stale threshold."""
927
+
928
+ topic_id: uuid.UUID
929
+ topic_title: str
930
+ discussion_round: TopicDiscussionRound
931
+ round_summary_count: int = 0
932
+ stale_since: datetime
933
+ updated_at: datetime
934
+
935
+
936
+ class MentionTodoRead(BaseModel):
937
+ id: uuid.UUID
938
+ mentioned_agent_id: uuid.UUID
939
+ author_agent_id: uuid.UUID
940
+ author_name: str | None = None
941
+ source_type: str
942
+ source_id: uuid.UUID
943
+ project_id: uuid.UUID
944
+ experiment_id: uuid.UUID | None
945
+ topic_id: uuid.UUID | None
946
+ excerpt: str
947
+ created_at: datetime
948
+ dismissed_at: datetime | None = None
949
+
950
+
951
+ class DismissMentionResultRead(BaseModel):
952
+ id: uuid.UUID
953
+ dismissed_at: datetime
954
+
955
+
956
+ class DismissAllMentionsResultRead(BaseModel):
957
+ dismissed: int
958
+
959
+
960
+ class TopicReadCursorRead(BaseModel):
961
+ topic_id: uuid.UUID
962
+ agent_id: uuid.UUID
963
+ last_read_comment_seq: int
964
+ updated_at: datetime
965
+
966
+
967
+ class TopicActionItemTodoRead(BaseModel):
968
+ id: uuid.UUID
969
+ decision_id: uuid.UUID
970
+ project_id: uuid.UUID
971
+ topic_id: uuid.UUID
972
+ topic_title: str
973
+ title: str
974
+ description: str | None = None
975
+ status: TopicActionItemStatus
976
+ due_at: datetime | None = None
977
+ linked_experiment_id: uuid.UUID | None = None
978
+ # 同 TopicActionItemRead.linked_experiment_phase:让 todos 消费者(CLI /
979
+ # waker / Web)能区分"等 reviewer 审批"与"host 自身待办"。
980
+ linked_experiment_phase: ExperimentPhase | None = None
981
+ # Wake / stale escalation fields (experiment B). The waker applies
982
+ # should_wake_action_item() directly to this payload — see
983
+ # cli/action_item_escalation.py scan_pending_action_items() for the caller.
984
+ wake_count: int = 0
985
+ first_open_at: datetime | None = None
986
+ last_woken_at: datetime | None = None
987
+ stale_at: datetime | None = None
988
+ created_at: datetime
989
+ updated_at: datetime
990
+
991
+
992
+ class ExperimentReviewInformationalRead(BaseModel):
993
+ """Read-only experiment review snapshot for non-reviewer personas."""
994
+
995
+ experiment_title: str
996
+ phase: ExperimentPhase
997
+ updated_at: datetime
998
+ review_progress: str
999
+
1000
+
1001
+ class TodoRead(BaseModel):
1002
+ my_open_experiments: list[ExperimentSummaryRead] = Field(default_factory=list)
1003
+ pending_reviews: list[ExperimentSummaryRead] = Field(default_factory=list)
1004
+ pending_result_reviews: list[ExperimentSummaryRead] = Field(default_factory=list)
1005
+ experiment_review_informational: list[ExperimentReviewInformationalRead] = Field(
1006
+ default_factory=list
1007
+ )
1008
+ pending_replies: list[PendingReplyRead] = Field(default_factory=list)
1009
+ pending_plan_revisions: list[PendingPlanRevisionRead] = Field(default_factory=list)
1010
+ pending_topic_replies: list[PendingTopicReplyTodoRead] = Field(default_factory=list)
1011
+ pending_round_acks: list[PendingRoundAckTodoRead] = Field(default_factory=list)
1012
+ pending_advance_rounds: list[PendingAdvanceRoundTodoRead] = Field(default_factory=list)
1013
+ stale_open_topics: list[StaleOpenTopicTodoRead] = Field(default_factory=list)
1014
+ my_open_topics: list[TopicSummaryRead] = Field(default_factory=list)
1015
+ mentions: list[MentionTodoRead] = Field(default_factory=list)
1016
+ action_items: list[TopicActionItemTodoRead] = Field(default_factory=list)
1017
+
1018
+
1019
+ # --- Notification ---
1020
+
1021
+
1022
+ class NotificationRead(ORMModel):
1023
+ id: uuid.UUID
1024
+ recipient_agent_id: uuid.UUID
1025
+ project_id: uuid.UUID | None
1026
+ event: str
1027
+ summary: str
1028
+ target_type: str
1029
+ target_id: uuid.UUID | None
1030
+ payload_json: dict | None
1031
+ category: NotificationCategory = NotificationCategory.digest
1032
+ group_key: str | None = None
1033
+ wake_version: int = 1
1034
+ fingerprint_version: NotificationFingerprintVersion = NotificationFingerprintVersion.v2
1035
+ event_count: int = 1
1036
+ first_event_at: datetime | None = None
1037
+ last_event_at: datetime | None = None
1038
+ read_at: datetime | None
1039
+ created_at: datetime
1040
+ updated_at: datetime | None = None
1041
+
1042
+
1043
+ class NotificationListRead(BaseModel):
1044
+ items: list[NotificationRead]
1045
+ total: int
1046
+ unread_count: int
1047
+
1048
+
1049
+ class AgentWorkRead(BaseModel):
1050
+ """Unified work snapshot for Web 待办, waker, and CLI ``map work``."""
1051
+
1052
+ agent: AgentRead
1053
+ topic_progress: TopicProgressListRead
1054
+ todos: TodoRead
1055
+ notifications: NotificationListRead
1056
+
1057
+
1058
+ BucketVisibility = Literal["all", "host_only", "reviewer_only", "participant_only"]
1059
+ SummaryBucketKind = Literal[
1060
+ "mention",
1061
+ "round_ack",
1062
+ "pending_reply",
1063
+ "explicit_only",
1064
+ "informational_only",
1065
+ "action_items",
1066
+ ]
1067
+
1068
+
1069
+ class SummaryBucketItem(BaseModel):
1070
+ """A short summary of one item inside a bucket. The full item is in
1071
+ ``map work``'s ``todos`` and ``topic_progress``; this is the slice
1072
+ rendered on the /work summary card.
1073
+ """
1074
+
1075
+ kind: SummaryBucketKind
1076
+ topic_id: uuid.UUID | None = None
1077
+ topic_title: str | None = None
1078
+ excerpt: str | None = None
1079
+ updated_at: datetime | None = None
1080
+
1081
+
1082
+ class SummaryBucket(BaseModel):
1083
+ """One of the 6 summary buckets exposed by ``map work --summary``.
1084
+
1085
+ A bucket is a kind-based aggregation of action items / context items so the
1086
+ UI can render a top-of-page card without scanning the full partition
1087
+ list.
1088
+ """
1089
+
1090
+ model_config = ConfigDict(populate_by_name=True)
1091
+
1092
+ kind: SummaryBucketKind
1093
+ count: int
1094
+ visibility: BucketVisibility = "all"
1095
+ items: list[SummaryBucketItem] = Field(default_factory=list)
1096
+ top_excerpt: str | None = None
1097
+
1098
+ @computed_field # type: ignore[prop-decorator]
1099
+ @property
1100
+ def partition_visibility(self) -> BucketVisibility:
1101
+ """DEPRECATED alias for visibility — kept readable for clients still using the old name."""
1102
+ return self.visibility
1103
+
1104
+
1105
+ class AgentWorkSummaryRead(BaseModel):
1106
+ """6-bucket by_kind summary returned by ``map work --summary``.
1107
+
1108
+ Compact view intended for waker quick-scans and the Web /work top card.
1109
+ Full per-partition detail stays in ``AgentWorkRead``.
1110
+ """
1111
+
1112
+ agent: AgentRead
1113
+ buckets: list[SummaryBucket] = Field(default_factory=list)
1114
+ topics_needing_attention: int = 0
1115
+ experiments_needing_attention: int = 0
1116
+ # f873c287 I1(e): per-phase_owner breakdown of the experiment attention
1117
+ # counter so host can see "3 are mine, 2 are waiting on reviewer" at a
1118
+ # glance. Keys are ``PhaseOwner.value`` strings ("host" / "reviewer" /
1119
+ # "participant" / "admin"). Visibility-filtered: under
1120
+ # ``visibility_filter_applied=True`` the ``host`` key is dropped because
1121
+ # the underlying bucket is host_only.
1122
+ experiments_needing_attention_by_owner: dict[str, int] = Field(
1123
+ default_factory=dict
1124
+ )
1125
+ topics_truncated: int = 0
1126
+ experiments_truncated: int = 0
1127
+ visibility_filter_applied: bool = True
1128
+ topics_limit: int = 10
1129
+ experiments_limit: int = 5
1130
+
1131
+
1132
+ # --- InboundEvent ---
1133
+
1134
+
1135
+ class InboundEventCreate(BaseModel):
1136
+ """Waker-side record of a notification it intends to act on.
1137
+
1138
+ ``fingerprint`` is the dedup key — server enforces ``UNIQUE(fingerprint)``
1139
+ and returns 409 Conflict on replay. ``event_id`` should match the upstream
1140
+ ``notification.id`` so the three audit layers stay joinable.
1141
+ """
1142
+
1143
+ event_id: uuid.UUID
1144
+ event_type: str = Field(min_length=1, max_length=64)
1145
+ source: InboundEventSource = InboundEventSource.polling
1146
+ fingerprint: str = Field(min_length=1, max_length=128)
1147
+ payload: dict | None = None
1148
+
1149
+
1150
+ class InboundEventRead(ORMModel):
1151
+ id: uuid.UUID
1152
+ agent_id: uuid.UUID
1153
+ event_id: uuid.UUID
1154
+ event_type: str
1155
+ source: InboundEventSource
1156
+ fingerprint: str
1157
+ payload: dict | None
1158
+ received_at: datetime
1159
+ acked_at: datetime | None
1160
+ rejection_count: int = 0
1161
+
1162
+
1163
+ class InboundEventRecordResult(BaseModel):
1164
+ """Return shape for ``POST /me/inbound-events``.
1165
+
1166
+ ``status="recorded"`` → first time, waker may proceed.
1167
+ ``status="duplicate"`` → fingerprint already existed; treat as already woken
1168
+ (Phase 1 server gate; see plan D6).
1169
+ ``status="rejected_v1"`` → legacy v1 fingerprint (``inbound:<event_id>``);
1170
+ inbound_event row is persisted (or upserted) with ``rejection_count`` bumped
1171
+ so the audit table still records the sighting, but the waker MUST NOT
1172
+ resume — see plan I2 / M30A acceptance #4.
1173
+ """
1174
+
1175
+ status: Literal["recorded", "duplicate", "rejected_v1"]
1176
+ event: InboundEventRead
1177
+
1178
+
1179
+ # --- Webhook ---
1180
+
1181
+
1182
+ class WebhookCreate(BaseModel):
1183
+ url: str = Field(min_length=1, max_length=2048)
1184
+ events: list[str] = Field(default_factory=list)
1185
+ project_id: uuid.UUID | None = None
1186
+
1187
+
1188
+ class WebhookUpdate(BaseModel):
1189
+ url: str | None = Field(default=None, min_length=1, max_length=2048)
1190
+ events: list[str] | None = None
1191
+ active: bool | None = None
1192
+
1193
+
1194
+ class WebhookRead(ORMModel):
1195
+ id: uuid.UUID
1196
+ project_id: uuid.UUID | None
1197
+ url: str
1198
+ events: list[str]
1199
+ active: bool
1200
+ created_at: datetime
1201
+
1202
+
1203
+ class WebhookCreateResponse(WebhookRead):
1204
+ secret: str
1205
+
1206
+
1207
+ class WebhookDeliveryRead(ORMModel):
1208
+ id: uuid.UUID
1209
+ webhook_id: uuid.UUID
1210
+ event: str
1211
+ payload: dict
1212
+ status_code: int | None
1213
+ attempts: int
1214
+ success: bool
1215
+ last_attempt_at: datetime | None
1216
+ last_error: str | None
1217
+ created_at: datetime
1218
+
1219
+
1220
+ # --- Audit ---
1221
+
1222
+
1223
+ class AuditLogRead(ORMModel):
1224
+ id: uuid.UUID
1225
+ agent_id: uuid.UUID | None
1226
+ project_id: uuid.UUID | None
1227
+ action: str
1228
+ target_type: str
1229
+ target_id: uuid.UUID | None
1230
+ summary: str | None
1231
+ payload_json: dict | None
1232
+ created_at: datetime
1233
+
1234
+
1235
+ # 0db51e10 I2(5e): request body for ``POST /experiments/{id}/cross-persona-call``.
1236
+ # Persisted in audit_logs.payload_json alongside caller_agent_id /
1237
+ # target_experiment_id / timestamp (created_at column).
1238
+ class CrossPersonaCallRecord(BaseModel):
1239
+ visibility_diff: dict[str, Any] = Field(default_factory=dict)
1240
+ result_partition_count: int = Field(default=0, ge=0)
1241
+ diff_size: int = Field(default=0, ge=0)
1242
+
1243
+
1244
+ # --- Action Item audit payloads (B I2: waker escalation 三段式) ---
1245
+
1246
+
1247
+ ACTION_ITEM_WAKE_SENT = "action_item.wake_sent"
1248
+ ACTION_ITEM_STALE = "action_item.stale"
1249
+
1250
+
1251
+ class ActionItemWakeSentPayload(BaseModel):
1252
+ """Payload of ``action_item.wake_sent`` audit event.
1253
+
1254
+ Fired by ``runtime-waker.scan_pending_action_items`` whenever ``should_wake_action_item``
1255
+ decides to wake the assignee (T+24h / T+72h / every 7d up to 4 times). Pairs with the
1256
+ ``action_item.stale`` event but is a distinct, lower-severity audit signal.
1257
+ """
1258
+
1259
+ action_item_id: uuid.UUID
1260
+ owner_agent_id: uuid.UUID
1261
+ topic_id: uuid.UUID
1262
+ decision_id: uuid.UUID | None = None
1263
+ linked_experiment_id: uuid.UUID | None = None
1264
+ wake_count: int
1265
+ last_woken_at: datetime
1266
+ first_open_at: datetime
1267
+ elapsed_since_first_open_seconds: int
1268
+ triggered_by: str = "waker.scan_pending_action_items"
1269
+
1270
+
1271
+ class ActionItemStalePayload(BaseModel):
1272
+ """Payload of ``action_item.stale`` audit event.
1273
+
1274
+ Fired after the 4th unanswered wake at the next 7d boundary. Pairs with admin
1275
+ notification + creator audit-only mark; the runtime-waker stops waking the
1276
+ assignee once ``stale_at`` is set. The event itself is independent of any
1277
+ state transition (unlike ``action_item.completed`` / ``action_item.cancelled``):
1278
+ it is a diagnostic signal that the open item has not progressed.
1279
+ """
1280
+
1281
+ action_item_id: uuid.UUID
1282
+ owner_agent_id: uuid.UUID
1283
+ topic_id: uuid.UUID
1284
+ decision_id: uuid.UUID | None = None
1285
+ linked_experiment_id: uuid.UUID | None = None
1286
+ last_woken_at: datetime | None
1287
+ wake_count: int
1288
+ stale_after_attempt: int
1289
+ admin_notified: bool
1290
+ creator_audit_only: bool
1291
+
1292
+
1293
+ # --- Platform Feedback ---
1294
+
1295
+
1296
+ class PlatformFeedbackCreate(BaseModel):
1297
+ """A free-text feedback entry any authenticated agent may submit.
1298
+
1299
+ `project_id` is an optional source-context hint (the project the agent was
1300
+ working in); it is NOT an access boundary — feedback is platform-wide.
1301
+ """
1302
+
1303
+ body: str = Field(min_length=1, max_length=20000)
1304
+ project_id: uuid.UUID | None = None
1305
+ category: FeedbackCategory | None = None
1306
+ metadata: dict | None = None
1307
+
1308
+
1309
+ class PlatformFeedbackUpdate(BaseModel):
1310
+ """Admin-only triage fields."""
1311
+
1312
+ status: FeedbackStatus | None = None
1313
+ category: FeedbackCategory | None = None
1314
+ archived: bool | None = None
1315
+
1316
+
1317
+ class PlatformFeedbackRead(ORMModel):
1318
+ id: uuid.UUID
1319
+ author_agent_id: uuid.UUID
1320
+ author_name: str | None = None
1321
+ project_id: uuid.UUID | None
1322
+ body: str
1323
+ category: FeedbackCategory | None
1324
+ status: FeedbackStatus
1325
+ metadata_json: dict | None
1326
+ created_at: datetime
1327
+ updated_at: datetime
1328
+ archived_at: datetime | None
1329
+
1330
+
1331
+ # --- STATE_MACHINE.* escalation contact (experiment 156172e9 I1(c)) ---
1332
+
1333
+
1334
+ class EscalationTargetRead(ORMModel):
1335
+ """Resolution of a STATE_MACHINE.* error's escalation contact.
1336
+
1337
+ Returned by ``GET /api/v1/agents/me/escalation-target?experiment_id=...``.
1338
+ The CLI uses this on ``MAPHTTPError`` with a STATE_MACHINE.* error_code
1339
+ so the user knows who to ping about a state-machine refusal.
1340
+ """
1341
+
1342
+ experiment_id: uuid.UUID | None
1343
+ escalation_target_id: uuid.UUID | None
1344
+ escalation_label: str # "@name" or "<no escalation contact>" placeholder
1345
+ tier: str # "experiment_override" | "current_caller" | "same_role_active" | "admin" | "none"
1346
+
1347
+
1348
+ ProjectStatusRead.model_rebuild()
1349
+ CommentTreeNode.model_rebuild()
1350
+ TopicRead.model_rebuild()
1351
+ TopicCommentTreeNode.model_rebuild()