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,405 @@
1
+ """Unified agent work snapshot (whoami + topic-progress + todos + wakeable notifications)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections import defaultdict
6
+ from collections.abc import Iterable
7
+ from datetime import datetime
8
+
9
+ from map_types.enums import AgentRole, NotificationCategory
10
+ from sqlalchemy.orm import Session
11
+
12
+ from server.domain.models import Agent
13
+ from server.domain.schemas import (
14
+ AgentRead,
15
+ AgentWorkRead,
16
+ AgentWorkSummaryRead,
17
+ BucketVisibility,
18
+ NotificationListRead,
19
+ NotificationRead,
20
+ SummaryBucket,
21
+ SummaryBucketItem,
22
+ SummaryBucketKind,
23
+ )
24
+ from server.services import notification_service, todo_service, topic_progress_service
25
+ from server.services import project_service as svc
26
+ from server.services import topic_work_item_service as work_items
27
+ from server.services.notification_service import PERSONA_AGENT_NAMES
28
+
29
+ _BUCKET_KIND_VISIBILITY: dict[SummaryBucketKind, BucketVisibility] = {
30
+ "mention": "all",
31
+ "round_ack": "all",
32
+ "pending_reply": "all",
33
+ "explicit_only": "host_only",
34
+ "informational_only": "host_only",
35
+ "action_items": "host_only",
36
+ }
37
+
38
+
39
+ def _is_host_persona(agent: Agent) -> bool:
40
+ """A persona 'sees host-only buckets' when they are the host (admin
41
+ always sees everything).
42
+ """
43
+ if agent.role == AgentRole.admin:
44
+ return True
45
+ host_name = PERSONA_AGENT_NAMES.get("host", "")
46
+ return bool(host_name) and agent.name == host_name
47
+
48
+
49
+ def _make_item(*, kind: SummaryBucketKind, topic_id=None, topic_title=None, excerpt=None, updated_at=None) -> SummaryBucketItem:
50
+ return SummaryBucketItem(
51
+ kind=kind, topic_id=topic_id, topic_title=topic_title, excerpt=excerpt, updated_at=updated_at
52
+ )
53
+
54
+
55
+ def _bucket(
56
+ *,
57
+ kind: SummaryBucketKind,
58
+ items: Iterable[SummaryBucketItem],
59
+ top_excerpt: str | None = None,
60
+ ) -> SummaryBucket:
61
+ items = list(items)
62
+ return SummaryBucket(
63
+ kind=kind,
64
+ count=len(items),
65
+ visibility=_BUCKET_KIND_VISIBILITY[kind],
66
+ items=items[:5],
67
+ top_excerpt=top_excerpt or (items[0].excerpt if items else None),
68
+ )
69
+
70
+
71
+ def get_agent_work(
72
+ db: Session,
73
+ agent: Agent,
74
+ *,
75
+ notification_limit: int = 50,
76
+ notification_category: NotificationCategory | None = NotificationCategory.wakeable,
77
+ ) -> AgentWorkRead:
78
+ project_key: str | None = None
79
+ if agent.project_id is not None:
80
+ project_key = svc.get_project(db, agent.project_id).project_key
81
+
82
+ agent_read = AgentRead(
83
+ id=agent.id,
84
+ name=agent.name,
85
+ role=agent.role,
86
+ project_id=agent.project_id,
87
+ project_key=project_key,
88
+ created_at=agent.created_at,
89
+ )
90
+
91
+ bundle = work_items.topic_work_items_bundle_for_agent(db, agent)
92
+ todos = todo_service.get_todos(db, agent, bundle=bundle)
93
+ topic_progress = topic_progress_service.list_topic_progress_for_agent(
94
+ db, agent, bundle=bundle
95
+ )
96
+
97
+ notif_items, notif_total = notification_service.list_for_agent(
98
+ db,
99
+ agent,
100
+ unread_only=True,
101
+ category=notification_category,
102
+ limit=notification_limit,
103
+ offset=0,
104
+ )
105
+ unread_count = notification_service.count_unread(
106
+ db,
107
+ agent,
108
+ category=notification_category,
109
+ )
110
+ notifications = NotificationListRead(
111
+ items=[NotificationRead.model_validate(n) for n in notif_items],
112
+ total=notif_total,
113
+ unread_count=unread_count,
114
+ )
115
+
116
+ return AgentWorkRead(
117
+ agent=agent_read,
118
+ topic_progress=topic_progress,
119
+ todos=todos,
120
+ notifications=notifications,
121
+ )
122
+
123
+
124
+ def get_agent_work_summary(
125
+ db: Session,
126
+ agent: Agent,
127
+ *,
128
+ include_all_personas: bool = False,
129
+ topics_limit: int = 10,
130
+ experiments_limit: int = 5,
131
+ ) -> AgentWorkSummaryRead:
132
+ """Compact 6-bucket by_kind summary of the agent's work.
133
+
134
+ Buckets: mention, round_ack, pending_reply, explicit_only,
135
+ informational_only, action_items. Persona filtering drops host-only
136
+ buckets for non-host personas unless ``include_all_personas=True``.
137
+ Truncation caps how many topics / experiments contribute per bucket to
138
+ keep the summary card small.
139
+ """
140
+ project_key: str | None = None
141
+ if agent.project_id is not None:
142
+ project_key = svc.get_project(db, agent.project_id).project_key
143
+ agent_read = AgentRead(
144
+ id=agent.id,
145
+ name=agent.name,
146
+ role=agent.role,
147
+ project_id=agent.project_id,
148
+ project_key=project_key,
149
+ created_at=agent.created_at,
150
+ )
151
+
152
+ bundle = work_items.topic_work_items_bundle_for_agent(db, agent)
153
+ todos = todo_service.get_todos(db, agent, bundle=bundle)
154
+ items = bundle.items
155
+
156
+ bucket_items: dict[SummaryBucketKind, list[SummaryBucketItem]] = defaultdict(list)
157
+
158
+ # topic-derived work items (mention / round_ack / pending_reply)
159
+ for w in items:
160
+ if w.kind == "mention":
161
+ bucket_items["mention"].append(
162
+ _make_item(
163
+ kind="mention",
164
+ topic_id=w.topic_id,
165
+ topic_title=w.topic_title,
166
+ excerpt=w.excerpt,
167
+ updated_at=w.created_at,
168
+ )
169
+ )
170
+ elif w.kind == "round_ack":
171
+ bucket_items["round_ack"].append(
172
+ _make_item(
173
+ kind="round_ack",
174
+ topic_id=w.topic_id,
175
+ topic_title=w.topic_title,
176
+ excerpt=w.excerpt,
177
+ updated_at=w.created_at,
178
+ )
179
+ )
180
+ elif w.kind == "pending_topic_reply":
181
+ bucket_items["pending_reply"].append(
182
+ _make_item(
183
+ kind="pending_reply",
184
+ topic_id=w.topic_id,
185
+ topic_title=w.topic_title,
186
+ excerpt=w.excerpt,
187
+ updated_at=w.created_at,
188
+ )
189
+ )
190
+
191
+ # mentions that didn't come through topic work items (experiment-scope)
192
+ topic_mention_ids = {w.topic_id for w in items if w.kind == "mention"}
193
+ for m in todos.mentions:
194
+ if m.topic_id is None or m.topic_id in topic_mention_ids:
195
+ continue
196
+ bucket_items["mention"].append(
197
+ _make_item(
198
+ kind="mention",
199
+ topic_id=m.topic_id,
200
+ excerpt=m.excerpt,
201
+ updated_at=m.created_at,
202
+ )
203
+ )
204
+
205
+ # round_ack todo mirror
206
+ for r in todos.pending_round_acks:
207
+ bucket_items["round_ack"].append(
208
+ _make_item(
209
+ kind="round_ack",
210
+ topic_id=r.topic_id,
211
+ topic_title=r.topic_title,
212
+ excerpt=r.summary_excerpt,
213
+ updated_at=r.updated_at,
214
+ )
215
+ )
216
+
217
+ # pending_reply todo mirror
218
+ for reply in todos.pending_topic_replies:
219
+ bucket_items["pending_reply"].append(
220
+ _make_item(
221
+ kind="pending_reply",
222
+ topic_id=reply.topic_id,
223
+ topic_title=reply.topic_title,
224
+ excerpt=reply.excerpt,
225
+ updated_at=reply.created_at,
226
+ )
227
+ )
228
+
229
+ # explicit_only: host's lifecycle action items (visible only on host persona
230
+ # by default; persona filter applies below).
231
+ #
232
+ # f873c287 I1(f) partition boundary: ``informational_only`` experiments
233
+ # (phase_owner != host, actions=[], blocked_on is set) do NOT enter
234
+ # the obligation partition (``explicit_only``); they route to
235
+ # ``informational_only`` so the waker treats them as read-only noise.
236
+ #
237
+ # f873c287 I1(e): both bucket excerpts share the ``experiment:{phase}:
238
+ # {phase_owner}`` shape so the by_owner breakdown algorithm below can
239
+ # count them uniformly.
240
+ for e in todos.my_open_experiments:
241
+ owner_value = e.phase_owner.value
242
+ phase_value = e.phase.value
243
+ if getattr(e, "informational_only", False):
244
+ bucket_items["informational_only"].append(
245
+ _make_item(
246
+ kind="informational_only",
247
+ topic_id=None,
248
+ topic_title=e.title,
249
+ excerpt=(
250
+ f"experiment:{phase_value}:{owner_value}"
251
+ f":blocked={e.blocked_on}"
252
+ if e.blocked_on
253
+ else f"experiment:{phase_value}:{owner_value}:waiting"
254
+ ),
255
+ updated_at=e.updated_at,
256
+ )
257
+ )
258
+ continue
259
+ bucket_items["explicit_only"].append(
260
+ _make_item(
261
+ kind="explicit_only",
262
+ topic_id=None,
263
+ topic_title=e.title,
264
+ excerpt=f"experiment:{phase_value}:{owner_value}",
265
+ updated_at=e.updated_at,
266
+ )
267
+ )
268
+ for p in todos.pending_advance_rounds:
269
+ bucket_items["explicit_only"].append(
270
+ _make_item(
271
+ kind="explicit_only",
272
+ topic_id=p.topic_id,
273
+ topic_title=p.topic_title,
274
+ excerpt=f"advance_round:{p.discussion_round.value}",
275
+ updated_at=p.updated_at,
276
+ )
277
+ )
278
+ for pending in todos.pending_replies:
279
+ bucket_items["explicit_only"].append(
280
+ _make_item(
281
+ kind="explicit_only",
282
+ topic_id=pending.experiment_id,
283
+ topic_title=pending.experiment_title,
284
+ excerpt=f"pending_reply:{pending.status.value}",
285
+ updated_at=pending.updated_at,
286
+ )
287
+ )
288
+
289
+ # informational_only: read-only review lifecycle (host can dismiss; non-host sees as informational)
290
+ for e in todos.pending_result_reviews:
291
+ bucket_items["informational_only"].append(
292
+ _make_item(
293
+ kind="informational_only",
294
+ topic_id=None,
295
+ topic_title=e.title,
296
+ excerpt=f"result_review:{e.phase.value}",
297
+ updated_at=e.updated_at,
298
+ )
299
+ )
300
+ for informational in todos.experiment_review_informational:
301
+ bucket_items["informational_only"].append(
302
+ _make_item(
303
+ kind="informational_only",
304
+ topic_id=None,
305
+ topic_title=informational.experiment_title,
306
+ excerpt=informational.review_progress,
307
+ updated_at=informational.updated_at,
308
+ )
309
+ )
310
+
311
+ # action_items
312
+ for a in todos.action_items:
313
+ bucket_items["action_items"].append(
314
+ _make_item(
315
+ kind="action_items",
316
+ topic_id=a.topic_id,
317
+ topic_title=a.topic_title,
318
+ excerpt=a.title,
319
+ updated_at=a.updated_at,
320
+ )
321
+ )
322
+
323
+ # Apply truncation per bucket: keep top-N by updated_at desc.
324
+ truncated_by_bucket: dict[str, int] = {}
325
+ for kind, blist in bucket_items.items():
326
+ blist.sort(key=lambda it: it.updated_at or datetime.min, reverse=True)
327
+ cap = topics_limit if kind != "explicit_only" else experiments_limit
328
+ if len(blist) > cap:
329
+ truncated_by_bucket[kind] = len(blist) - cap
330
+ del blist[cap:]
331
+
332
+ # f873c287 I1(e): compute the per-phase_owner experiment counter BEFORE
333
+ # the persona filter so the host can see "3 are mine, 2 are waiting on
334
+ # reviewer" at a glance. The ``host`` key is dropped after the filter
335
+ # below because the underlying bucket is host_only.
336
+ #
337
+ # Counts BOTH explicit_only and informational_only items; both share
338
+ # the ``experiment:{phase}:{owner}[:...]`` excerpt format. We use
339
+ # maxsplit=3 so informational_only entries (which append ``:blocked=``
340
+ # or ``:waiting``) keep the owner as a clean segment.
341
+ experiments_needing_attention_by_owner: dict[str, int] = {}
342
+ for kind in ("explicit_only", "informational_only"):
343
+ for it in bucket_items.get(kind, []):
344
+ if not it.excerpt or not it.excerpt.startswith("experiment:"):
345
+ continue
346
+ parts = it.excerpt.split(":", maxsplit=3)
347
+ if len(parts) < 3:
348
+ continue
349
+ owner = parts[2].strip()
350
+ if not owner:
351
+ continue
352
+ experiments_needing_attention_by_owner[owner] = (
353
+ experiments_needing_attention_by_owner.get(owner, 0) + 1
354
+ )
355
+
356
+ # Apply persona filter: non-host loses host_only buckets unless --include-all-personas.
357
+ is_host = _is_host_persona(agent)
358
+ visibility_filter_applied = not (include_all_personas or is_host)
359
+ if visibility_filter_applied:
360
+ bucket_items = {
361
+ k: v for k, v in bucket_items.items() if _BUCKET_KIND_VISIBILITY[k] == "all"
362
+ }
363
+ # f873c287 I1(e): participant/reviewer personas don't see
364
+ # host-owned experiments in the breakdown either — the
365
+ # ``explicit_only`` bucket is host_only.
366
+ experiments_needing_attention_by_owner.pop("host", None)
367
+
368
+ buckets = [
369
+ _bucket(kind=kind, items=items_)
370
+ for kind, items_ in bucket_items.items()
371
+ ]
372
+
373
+ # Topics needing attention = unique topics that contributed to all / round_ack / pending_reply.
374
+ topics_with_action_items = {
375
+ it.topic_id
376
+ for kind, items_ in bucket_items.items()
377
+ if kind in {"mention", "round_ack", "pending_reply"}
378
+ for it in items_
379
+ if it.topic_id is not None
380
+ }
381
+ topics_needing_attention = len(topics_with_action_items)
382
+
383
+ experiments_needing_attention = sum(
384
+ experiments_needing_attention_by_owner.values()
385
+ )
386
+
387
+ topics_truncated = sum(
388
+ n for kind, n in truncated_by_bucket.items() if kind != "explicit_only"
389
+ )
390
+ experiments_truncated = sum(
391
+ n for kind, n in truncated_by_bucket.items() if kind == "explicit_only"
392
+ )
393
+
394
+ return AgentWorkSummaryRead(
395
+ agent=agent_read,
396
+ buckets=buckets,
397
+ topics_needing_attention=topics_needing_attention,
398
+ experiments_needing_attention=experiments_needing_attention,
399
+ experiments_needing_attention_by_owner=experiments_needing_attention_by_owner,
400
+ topics_truncated=topics_truncated,
401
+ experiments_truncated=experiments_truncated,
402
+ visibility_filter_applied=visibility_filter_applied,
403
+ topics_limit=topics_limit,
404
+ experiments_limit=experiments_limit,
405
+ )
@@ -0,0 +1,156 @@
1
+ """a764abf6 I1.(b) — archive metadata lint helper.
2
+
3
+ Surfaces consistency problems with the ``review.archived_at`` /
4
+ ``review.archived_reason`` metadata so drift between the auto-archive
5
+ cascade (I1(b)) and the default filter (I1(c)) becomes observable.
6
+
7
+ Two checks per plan (b):
8
+
9
+ * **检测 1 — archived review 一致性**: archived reviews must NOT appear
10
+ in the default ``list_reviews(include_archived=False)`` output.
11
+
12
+ - ``N=2_transition`` phase (``include_archived`` 默认 True 过渡期,
13
+ before release checklist flip): leaked archived review is
14
+ ``WARN`` only (existing prod tests rely on the default and we do
15
+ not want to fail mid-flight).
16
+ - ``N=2_post_flip`` phase (``include_archived`` 默认 False,
17
+ after release checklist flip): leaked archived review is
18
+ ``FAIL`` (the new contract is violated).
19
+
20
+ * **检测 2 — plan_version drift**: every archived review's
21
+ ``plan_version`` must be less than ``experiment.current_plan_version``.
22
+ Drift (archived review referencing a plan that is still canonical)
23
+ is always ``FAIL`` regardless of N=2 phase.
24
+
25
+ Output: list of :class:`ArchiveLintIssue` with ``code``,
26
+ ``severity`` (``WARN`` / ``FAIL``), ``message``, and references to the
27
+ ``review_id`` / ``plan_version`` involved.
28
+
29
+ The CLI / API wrapper passes ``n2_phase`` explicitly based on the
30
+ release checklist; this helper does not infer it from live prod data
31
+ (``list_reviews(include_archived=...)`` defaults are not introspectable
32
+ from the service layer without a deliberate API probe).
33
+ """
34
+
35
+ from __future__ import annotations
36
+
37
+ import uuid
38
+ from dataclasses import dataclass, field
39
+ from typing import Literal
40
+
41
+ from sqlalchemy import select
42
+ from sqlalchemy.orm import Session
43
+
44
+ from server.domain.models import Review
45
+ from server.services.project_service import get_experiment
46
+ from server.services.review_service import list_reviews # re-export for test monkey-patch
47
+
48
+ ArchiveLintCode = Literal[
49
+ "ARCHIVED_IN_DEFAULT_LIST",
50
+ "ARCHIVED_PLAN_VERSION_DRIFT",
51
+ ]
52
+
53
+ ArchiveLintSeverity = Literal["WARN", "FAIL"]
54
+
55
+ ArchiveLintN2Phase = Literal["transition", "post_flip"]
56
+
57
+
58
+ @dataclass(frozen=True)
59
+ class ArchiveLintIssue:
60
+ code: ArchiveLintCode
61
+ severity: ArchiveLintSeverity
62
+ message: str
63
+ review_id: uuid.UUID | None = None
64
+ plan_version: int | None = None
65
+
66
+
67
+ @dataclass(frozen=True)
68
+ class ArchiveLintResult:
69
+ issues: list[ArchiveLintIssue] = field(default_factory=list)
70
+ n2_phase: ArchiveLintN2Phase = "post_flip"
71
+ reviews_total: int = 0
72
+ reviews_archived: int = 0
73
+
74
+ @property
75
+ def has_failures(self) -> bool:
76
+ return any(issue.severity == "FAIL" for issue in self.issues)
77
+
78
+ @property
79
+ def has_warnings(self) -> bool:
80
+ return any(issue.severity == "WARN" for issue in self.issues)
81
+
82
+
83
+ def archive_lint(
84
+ db: Session,
85
+ experiment_id: uuid.UUID,
86
+ *,
87
+ n2_phase: ArchiveLintN2Phase = "post_flip",
88
+ ) -> ArchiveLintResult:
89
+ """Lint helper — returns all ``ArchiveLintIssue``s found.
90
+
91
+ Implementation steps:
92
+
93
+ 1. Pull all reviews (archived + non-archived).
94
+ 2. Pull ``list_reviews(include_archived=False)`` to model the default
95
+ filter exactly as the API exposes it.
96
+ 3. 检测 1 — diff the two sets: any review that is archived but
97
+ appears in the default list is an inconsistency. Severity depends
98
+ on the supplied ``n2_phase``.
99
+ 4. 检测 2 — for every archived review, assert
100
+ ``review.plan_version < experiment.current_plan_version``.
101
+ Drift ⇒ ``FAIL``.
102
+ """
103
+ experiment = get_experiment(db, experiment_id)
104
+ all_reviews = list(
105
+ db.scalars(select(Review).where(Review.experiment_id == experiment_id))
106
+ )
107
+ default_list = list_reviews(db, experiment_id, include_archived=False)
108
+ default_ids = {r.id for r in default_list}
109
+
110
+ issues: list[ArchiveLintIssue] = []
111
+ archived = [r for r in all_reviews if r.archived_at is not None]
112
+
113
+ # 检测 1: archived review leaked into default list.
114
+ severity_for_drift: ArchiveLintSeverity = (
115
+ "WARN" if n2_phase == "transition" else "FAIL"
116
+ )
117
+ for review in archived:
118
+ if review.id in default_ids:
119
+ issues.append(
120
+ ArchiveLintIssue(
121
+ code="ARCHIVED_IN_DEFAULT_LIST",
122
+ severity=severity_for_drift,
123
+ message=(
124
+ f"Review {review.id} is archived but appears in "
125
+ f"list_reviews(include_archived=False) default "
126
+ f"output (N=2 phase={n2_phase})"
127
+ ),
128
+ review_id=review.id,
129
+ plan_version=review.plan_version,
130
+ )
131
+ )
132
+
133
+ # 检测 2: archived review's plan_version must be < current_plan_version.
134
+ for review in archived:
135
+ if review.plan_version >= experiment.current_plan_version:
136
+ issues.append(
137
+ ArchiveLintIssue(
138
+ code="ARCHIVED_PLAN_VERSION_DRIFT",
139
+ severity="FAIL",
140
+ message=(
141
+ f"Archived review {review.id} references "
142
+ f"plan_version={review.plan_version} but "
143
+ f"experiment.current_plan_version="
144
+ f"{experiment.current_plan_version}"
145
+ ),
146
+ review_id=review.id,
147
+ plan_version=review.plan_version,
148
+ )
149
+ )
150
+
151
+ return ArchiveLintResult(
152
+ issues=issues,
153
+ n2_phase=n2_phase,
154
+ reviews_total=len(all_reviews),
155
+ reviews_archived=len(archived),
156
+ )