multi-agent-platform 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- cli/__init__.py +0 -0
- cli/action_item_escalation.py +177 -0
- cli/agent_client.py +554 -0
- cli/bridge_state.py +43 -0
- cli/commands/__init__.py +13 -0
- cli/commands/action.py +142 -0
- cli/commands/agent.py +117 -0
- cli/commands/audit.py +68 -0
- cli/commands/docs.py +179 -0
- cli/commands/experiment.py +755 -0
- cli/commands/feedback.py +106 -0
- cli/commands/notification.py +213 -0
- cli/commands/persona.py +63 -0
- cli/commands/project.py +87 -0
- cli/commands/runtime.py +105 -0
- cli/commands/topic.py +361 -0
- cli/e2e_collab.py +602 -0
- cli/git_checkpoint.py +68 -0
- cli/host_worker_types.py +151 -0
- cli/main.py +1553 -0
- cli/map_command_client.py +497 -0
- cli/participant_worker.py +255 -0
- cli/reviewer_worker.py +263 -0
- cli/runtime/__init__.py +5 -0
- cli/runtime/run_lock.py +497 -0
- cli/runtime_chat.py +317 -0
- cli/session_wake_log.py +235 -0
- cli/simple_waker.py +950 -0
- cli/table_render.py +113 -0
- cli/wake_backend.py +236 -0
- cli/worker_cycle_log.py +36 -0
- map_client/__init__.py +37 -0
- map_client/bootstrap.py +193 -0
- map_client/client.py +1045 -0
- map_client/config.py +21 -0
- map_client/errors.py +283 -0
- map_client/exceptions.py +130 -0
- map_client/plan_evidence.py +159 -0
- map_client/project_config.py +153 -0
- map_client/result_template.py +167 -0
- map_client/testing.py +27 -0
- map_mcp/__init__.py +4 -0
- map_mcp/_utils.py +28 -0
- map_mcp/auth.py +34 -0
- map_mcp/config.py +50 -0
- map_mcp/context.py +39 -0
- map_mcp/main.py +75 -0
- map_mcp/server.py +573 -0
- map_mcp/session.py +79 -0
- map_sdk/__init__.py +29 -0
- map_sdk/evidence.py +68 -0
- map_types/__init__.py +203 -0
- map_types/enums.py +199 -0
- map_types/schemas.py +1351 -0
- multi_agent_platform-0.1.0.dist-info/METADATA +298 -0
- multi_agent_platform-0.1.0.dist-info/RECORD +144 -0
- multi_agent_platform-0.1.0.dist-info/WHEEL +5 -0
- multi_agent_platform-0.1.0.dist-info/entry_points.txt +6 -0
- multi_agent_platform-0.1.0.dist-info/licenses/LICENSE +21 -0
- multi_agent_platform-0.1.0.dist-info/top_level.txt +6 -0
- server/__init__.py +0 -0
- server/__version__.py +14 -0
- server/api/__init__.py +0 -0
- server/api/action_items.py +138 -0
- server/api/agents.py +412 -0
- server/api/audit.py +54 -0
- server/api/background_tasks.py +18 -0
- server/api/common.py +117 -0
- server/api/deps.py +30 -0
- server/api/experiments.py +858 -0
- server/api/feedback.py +75 -0
- server/api/notifications.py +22 -0
- server/api/projects.py +209 -0
- server/api/router.py +25 -0
- server/api/status.py +33 -0
- server/api/topics.py +302 -0
- server/api/webhooks.py +74 -0
- server/auth/__init__.py +8 -0
- server/auth/experiment_access.py +66 -0
- server/config.py +38 -0
- server/db/__init__.py +3 -0
- server/db/base.py +5 -0
- server/db/deadlock_retry.py +146 -0
- server/db/session.py +41 -0
- server/domain/__init__.py +3 -0
- server/domain/encrypted_types.py +63 -0
- server/domain/models.py +713 -0
- server/domain/schemas.py +3 -0
- server/domain/state_machine.py +79 -0
- server/domain/topic_ack_constants.py +9 -0
- server/main.py +148 -0
- server/scripts/__init__.py +0 -0
- server/scripts/migrate_notification_unique.py +231 -0
- server/scripts/purge_audit_pollution.py +116 -0
- server/services/__init__.py +0 -0
- server/services/_lookups.py +26 -0
- server/services/acceptance_service.py +90 -0
- server/services/action_item_migration_service.py +190 -0
- server/services/action_item_service.py +200 -0
- server/services/agent_work_service.py +405 -0
- server/services/archive_lint_service.py +156 -0
- server/services/audit_service.py +457 -0
- server/services/auth.py +66 -0
- server/services/comment_service.py +173 -0
- server/services/errors.py +65 -0
- server/services/escalation_resolver.py +248 -0
- server/services/evidence_service.py +88 -0
- server/services/experiment_capabilities_service.py +277 -0
- server/services/inbound_event_service.py +111 -0
- server/services/lock_service.py +273 -0
- server/services/log_service.py +202 -0
- server/services/mention_service.py +730 -0
- server/services/notification_service.py +939 -0
- server/services/notification_stream.py +138 -0
- server/services/permissions.py +147 -0
- server/services/persona_activity_service.py +108 -0
- server/services/phase_owner_resolver.py +95 -0
- server/services/phase_service.py +381 -0
- server/services/plan_marker_service.py +235 -0
- server/services/plan_service.py +186 -0
- server/services/platform_feedback_service.py +114 -0
- server/services/project_service.py +534 -0
- server/services/project_status_service.py +132 -0
- server/services/review_service.py +707 -0
- server/services/secret_encryption.py +97 -0
- server/services/similarity_service.py +119 -0
- server/services/sse_event_schemas.py +17 -0
- server/services/status_service.py +68 -0
- server/services/template_service.py +134 -0
- server/services/text_utils.py +19 -0
- server/services/thread_activity.py +180 -0
- server/services/todo_persona_filter.py +73 -0
- server/services/todo_service.py +604 -0
- server/services/topic_ack_service.py +312 -0
- server/services/topic_action_item_ops.py +538 -0
- server/services/topic_comment_kind.py +14 -0
- server/services/topic_comment_service.py +237 -0
- server/services/topic_helpers.py +32 -0
- server/services/topic_lifecycle_service.py +478 -0
- server/services/topic_progress_service.py +40 -0
- server/services/topic_resolve_service.py +234 -0
- server/services/topic_service.py +102 -0
- server/services/topic_work_item_service.py +570 -0
- server/services/webhook_service.py +273 -0
|
@@ -0,0 +1,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
|
+
)
|