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,538 @@
|
|
|
1
|
+
"""Topic action-item list / mutate / serialize helpers.
|
|
2
|
+
|
|
3
|
+
拆分自 ``topic_service.py`` 以缓解超大模块可读性问题。
|
|
4
|
+
本模块负责 action item 的读序列化与生命周期 mutation:
|
|
5
|
+
- ``_action_item_read`` / suggest-link / linked-phase batch helpers
|
|
6
|
+
- ``list_action_items``
|
|
7
|
+
- ``deliver`` / ``complete`` / ``cancel`` / ``link``
|
|
8
|
+
- ``mark_wake_sent_action_item`` / ``mark_stale_action_item``
|
|
9
|
+
|
|
10
|
+
``topic_service`` re-exports 公开与私有 API,保持现有 import 不破。
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import uuid
|
|
15
|
+
from datetime import UTC, datetime, timedelta
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
from map_types.enums import ActionItemCategory, ExperimentPhase, TopicActionItemStatus
|
|
19
|
+
from map_types.schemas import ActionItemCancel
|
|
20
|
+
from sqlalchemy import select
|
|
21
|
+
from sqlalchemy.orm import Session, joinedload
|
|
22
|
+
|
|
23
|
+
from server.domain.models import Agent, Experiment, Topic, TopicActionItem
|
|
24
|
+
from server.domain.schemas import TopicActionItemRead
|
|
25
|
+
from server.services import action_item_service, audit_service, notification_service
|
|
26
|
+
from server.services._lookups import get_project
|
|
27
|
+
from server.services.errors import ConflictError, ForbiddenError, NotFoundError
|
|
28
|
+
from server.services.permissions import is_admin
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _suggest_linked_experiment(db: Session, item: TopicActionItem) -> tuple[uuid.UUID | None, str | None]:
|
|
32
|
+
"""Find a recently-done experiment in the same project whose owner matches
|
|
33
|
+
the action item's owner and whose title is a substring match (case-insensitive,
|
|
34
|
+
space-insensitive) of the action item title. Returns ``(None, None)`` when
|
|
35
|
+
the item is not eligible (closed, already linked, no owner) or no match.
|
|
36
|
+
"""
|
|
37
|
+
if item.status != TopicActionItemStatus.open:
|
|
38
|
+
return None, None
|
|
39
|
+
if item.linked_experiment_id is not None:
|
|
40
|
+
return None, None
|
|
41
|
+
if item.owner_agent_id is None:
|
|
42
|
+
return None, None
|
|
43
|
+
|
|
44
|
+
cutoff = datetime.now(UTC) - timedelta(days=30)
|
|
45
|
+
item_norm = item.title.lower().replace(" ", "")
|
|
46
|
+
candidates = db.scalars(
|
|
47
|
+
select(Experiment)
|
|
48
|
+
.where(
|
|
49
|
+
Experiment.project_id == item.project_id,
|
|
50
|
+
Experiment.creator_agent_id == item.owner_agent_id,
|
|
51
|
+
Experiment.phase == ExperimentPhase.done,
|
|
52
|
+
Experiment.deleted_at.is_(None),
|
|
53
|
+
Experiment.archived_at.is_(None),
|
|
54
|
+
Experiment.updated_at >= cutoff,
|
|
55
|
+
)
|
|
56
|
+
.order_by(Experiment.updated_at.desc())
|
|
57
|
+
.limit(50)
|
|
58
|
+
).all()
|
|
59
|
+
for exp in candidates:
|
|
60
|
+
exp_norm = exp.title.lower().replace(" ", "")
|
|
61
|
+
if not exp_norm or not item_norm:
|
|
62
|
+
continue
|
|
63
|
+
if exp_norm in item_norm or item_norm in exp_norm:
|
|
64
|
+
return exp.id, exp.title
|
|
65
|
+
return None, None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _suggest_linked_experiments_batch(
|
|
69
|
+
db: Session, items: list[TopicActionItem]
|
|
70
|
+
) -> dict[uuid.UUID, tuple[uuid.UUID | None, str | None]]:
|
|
71
|
+
"""Batch counterpart of :func:`_suggest_linked_experiment`.
|
|
72
|
+
|
|
73
|
+
List / decision-read 路径会对多个 action_item 逐条调用 read,若每条都
|
|
74
|
+
单独查候选实验会产生 N+1(每条一次 SQL + 50 行内存匹配)。本函数按
|
|
75
|
+
``(project_id, owner_agent_id)`` 分桶,每桶只查一次候选实验,再在 Python
|
|
76
|
+
内做子串匹配。不合格(非 open / 已 link / 无 owner)的 item 直接映射到
|
|
77
|
+
``(None, None)``。
|
|
78
|
+
"""
|
|
79
|
+
result: dict[uuid.UUID, tuple[uuid.UUID | None, str | None]] = {}
|
|
80
|
+
eligible: list[TopicActionItem] = []
|
|
81
|
+
for item in items:
|
|
82
|
+
if (
|
|
83
|
+
item.status == TopicActionItemStatus.open
|
|
84
|
+
and item.linked_experiment_id is None
|
|
85
|
+
and item.owner_agent_id is not None
|
|
86
|
+
):
|
|
87
|
+
eligible.append(item)
|
|
88
|
+
else:
|
|
89
|
+
result[item.id] = (None, None)
|
|
90
|
+
if not eligible:
|
|
91
|
+
return result
|
|
92
|
+
|
|
93
|
+
cutoff = datetime.now(UTC) - timedelta(days=30)
|
|
94
|
+
groups: dict[tuple[uuid.UUID, uuid.UUID | None], list[TopicActionItem]] = {}
|
|
95
|
+
for item in eligible:
|
|
96
|
+
groups.setdefault((item.project_id, item.owner_agent_id), []).append(item)
|
|
97
|
+
|
|
98
|
+
for (project_id, owner_id), group_items in groups.items():
|
|
99
|
+
candidates = db.scalars(
|
|
100
|
+
select(Experiment)
|
|
101
|
+
.where(
|
|
102
|
+
Experiment.project_id == project_id,
|
|
103
|
+
Experiment.creator_agent_id == owner_id,
|
|
104
|
+
Experiment.phase == ExperimentPhase.done,
|
|
105
|
+
Experiment.deleted_at.is_(None),
|
|
106
|
+
Experiment.archived_at.is_(None),
|
|
107
|
+
Experiment.updated_at >= cutoff,
|
|
108
|
+
)
|
|
109
|
+
.order_by(Experiment.updated_at.desc())
|
|
110
|
+
.limit(50)
|
|
111
|
+
).all()
|
|
112
|
+
for item in group_items:
|
|
113
|
+
item_norm = item.title.lower().replace(" ", "")
|
|
114
|
+
match: tuple[uuid.UUID | None, str | None] = (None, None)
|
|
115
|
+
if item_norm:
|
|
116
|
+
for exp in candidates:
|
|
117
|
+
exp_norm = exp.title.lower().replace(" ", "")
|
|
118
|
+
if exp_norm and (exp_norm in item_norm or item_norm in exp_norm):
|
|
119
|
+
match = (exp.id, exp.title)
|
|
120
|
+
break
|
|
121
|
+
result[item.id] = match
|
|
122
|
+
return result
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _linked_experiment_phases_batch(
|
|
126
|
+
db: Session, items: list[TopicActionItem]
|
|
127
|
+
) -> dict[uuid.UUID, ExperimentPhase | None]:
|
|
128
|
+
"""Batch-fetch ``linked_experiment_phase`` for a list of action items.
|
|
129
|
+
|
|
130
|
+
Returns a mapping ``{item.id: phase | None}``. Items without a linked
|
|
131
|
+
experiment map to ``None`` without hitting the DB. Used by list / todos
|
|
132
|
+
read paths to avoid an N+1 when serializing many action items.
|
|
133
|
+
"""
|
|
134
|
+
result: dict[uuid.UUID, ExperimentPhase | None] = {}
|
|
135
|
+
linked_ids: list[uuid.UUID] = []
|
|
136
|
+
for item in items:
|
|
137
|
+
if item.linked_experiment_id is not None:
|
|
138
|
+
linked_ids.append(item.linked_experiment_id)
|
|
139
|
+
else:
|
|
140
|
+
result[item.id] = None
|
|
141
|
+
if not linked_ids:
|
|
142
|
+
return result
|
|
143
|
+
rows = db.execute(
|
|
144
|
+
select(Experiment.id, Experiment.phase).where(Experiment.id.in_(linked_ids))
|
|
145
|
+
).all()
|
|
146
|
+
phase_by_id: dict[uuid.UUID, ExperimentPhase] = {row[0]: row[1] for row in rows}
|
|
147
|
+
for item in items:
|
|
148
|
+
if item.linked_experiment_id is not None:
|
|
149
|
+
result[item.id] = phase_by_id.get(item.linked_experiment_id)
|
|
150
|
+
return result
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _action_item_read(
|
|
154
|
+
db: Session,
|
|
155
|
+
item: TopicActionItem,
|
|
156
|
+
*,
|
|
157
|
+
suggested: tuple[uuid.UUID | None, str | None] | None = None,
|
|
158
|
+
linked_experiment_phase: ExperimentPhase | None = None,
|
|
159
|
+
linked_experiment_phase_provided: bool = False,
|
|
160
|
+
) -> TopicActionItemRead:
|
|
161
|
+
owner_name = None
|
|
162
|
+
if item.owner_agent_id is not None:
|
|
163
|
+
owner = getattr(item, "owner", None)
|
|
164
|
+
if owner is None:
|
|
165
|
+
owner = db.get(Agent, item.owner_agent_id)
|
|
166
|
+
owner_name = owner.name if owner else None
|
|
167
|
+
# 列表 / 决策读路径已批量预取 suggested,传入时跳过逐条 SQL(消除 N+1)。
|
|
168
|
+
suggested_id, suggested_title = (
|
|
169
|
+
suggested if suggested is not None else _suggest_linked_experiment(db, item)
|
|
170
|
+
)
|
|
171
|
+
# 同上:批量预取 phase 时直接用;否则单条 fallback 查询。
|
|
172
|
+
if not linked_experiment_phase_provided:
|
|
173
|
+
if item.linked_experiment_id is not None:
|
|
174
|
+
exp = db.get(Experiment, item.linked_experiment_id)
|
|
175
|
+
linked_experiment_phase = exp.phase if exp else None
|
|
176
|
+
else:
|
|
177
|
+
linked_experiment_phase = None
|
|
178
|
+
return TopicActionItemRead(
|
|
179
|
+
id=item.id,
|
|
180
|
+
decision_id=item.decision_id,
|
|
181
|
+
project_id=item.project_id,
|
|
182
|
+
topic_id=item.topic_id,
|
|
183
|
+
title=item.title,
|
|
184
|
+
description=item.description,
|
|
185
|
+
owner_agent_id=item.owner_agent_id,
|
|
186
|
+
owner_name=owner_name,
|
|
187
|
+
status=item.status,
|
|
188
|
+
due_at=item.due_at,
|
|
189
|
+
linked_experiment_id=item.linked_experiment_id,
|
|
190
|
+
linked_experiment_phase=linked_experiment_phase,
|
|
191
|
+
category=ActionItemCategory(item.category) if item.category else None,
|
|
192
|
+
cancel_reason=item.cancel_reason,
|
|
193
|
+
suggested_linked_experiment_id=suggested_id,
|
|
194
|
+
suggested_linked_experiment_title=suggested_title,
|
|
195
|
+
wake_count=item.wake_count,
|
|
196
|
+
first_open_at=item.first_open_at,
|
|
197
|
+
last_woken_at=item.last_woken_at,
|
|
198
|
+
stale_at=item.stale_at,
|
|
199
|
+
created_at=item.created_at,
|
|
200
|
+
updated_at=item.updated_at,
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
def list_action_items(
|
|
204
|
+
db: Session,
|
|
205
|
+
project_id: uuid.UUID,
|
|
206
|
+
*,
|
|
207
|
+
owner_agent_id: uuid.UUID | None = None,
|
|
208
|
+
status: TopicActionItemStatus | None = None,
|
|
209
|
+
limit: int = 100,
|
|
210
|
+
) -> list[TopicActionItemRead]:
|
|
211
|
+
get_project(db, project_id)
|
|
212
|
+
stmt = (
|
|
213
|
+
select(TopicActionItem)
|
|
214
|
+
.where(TopicActionItem.project_id == project_id)
|
|
215
|
+
.options(joinedload(TopicActionItem.owner))
|
|
216
|
+
.order_by(TopicActionItem.updated_at.desc())
|
|
217
|
+
.limit(max(1, min(limit, 200)))
|
|
218
|
+
)
|
|
219
|
+
if owner_agent_id is not None:
|
|
220
|
+
stmt = stmt.where(TopicActionItem.owner_agent_id == owner_agent_id)
|
|
221
|
+
if status is not None:
|
|
222
|
+
stmt = stmt.where(TopicActionItem.status == status)
|
|
223
|
+
items = list(db.scalars(stmt))
|
|
224
|
+
suggested_map = _suggest_linked_experiments_batch(db, items)
|
|
225
|
+
phase_map = _linked_experiment_phases_batch(db, items)
|
|
226
|
+
return [
|
|
227
|
+
_action_item_read(
|
|
228
|
+
db,
|
|
229
|
+
item,
|
|
230
|
+
suggested=suggested_map.get(item.id),
|
|
231
|
+
linked_experiment_phase=phase_map.get(item.id),
|
|
232
|
+
linked_experiment_phase_provided=True,
|
|
233
|
+
)
|
|
234
|
+
for item in items
|
|
235
|
+
]
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _ensure_action_item_accessor(item: TopicActionItem, agent: Agent) -> None:
|
|
239
|
+
"""Caller must be the owner or an admin. Topic creator may be added later."""
|
|
240
|
+
if item.owner_agent_id is None:
|
|
241
|
+
# No owner assigned: admin-only is the safe default.
|
|
242
|
+
if not is_admin(agent):
|
|
243
|
+
raise ForbiddenError("Only admin can close an unassigned action item")
|
|
244
|
+
return
|
|
245
|
+
if item.owner_agent_id != agent.id and not is_admin(agent):
|
|
246
|
+
raise ForbiddenError("Only the owner or admin can complete/cancel this action item")
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _complete_action_item_no_commit(
|
|
250
|
+
db: Session,
|
|
251
|
+
item: TopicActionItem,
|
|
252
|
+
*,
|
|
253
|
+
triggered_by: str = "manual",
|
|
254
|
+
) -> dict[str, Any]:
|
|
255
|
+
"""Move an open action item to ``done`` within the caller's transaction.
|
|
256
|
+
|
|
257
|
+
Caller is responsible for:
|
|
258
|
+
- 404 lookup (db.get on TopicActionItem)
|
|
259
|
+
- Owner / admin access check (``_ensure_action_item_accessor``)
|
|
260
|
+
- Final ``db.commit()``
|
|
261
|
+
|
|
262
|
+
Returns a dict the caller can splice into a higher-level audit payload
|
|
263
|
+
(e.g. ``cascaded_action_items`` on ``experiment.completed``).
|
|
264
|
+
"""
|
|
265
|
+
if item.status != TopicActionItemStatus.open:
|
|
266
|
+
raise ConflictError(f"Action item already {item.status.value}")
|
|
267
|
+
prev_status = item.status
|
|
268
|
+
item.status = TopicActionItemStatus.done
|
|
269
|
+
audit_service.log_no_commit(
|
|
270
|
+
db,
|
|
271
|
+
action="action_item.completed",
|
|
272
|
+
target_type="topic_action_item",
|
|
273
|
+
target_id=item.id,
|
|
274
|
+
project_id=item.project_id,
|
|
275
|
+
summary=f"完成行动项「{item.title}」",
|
|
276
|
+
payload={
|
|
277
|
+
"action_item_id": str(item.id),
|
|
278
|
+
"prev_status": prev_status.value,
|
|
279
|
+
"new_status": item.status.value,
|
|
280
|
+
"triggered_by": triggered_by,
|
|
281
|
+
},
|
|
282
|
+
)
|
|
283
|
+
return {
|
|
284
|
+
"action_item_id": str(item.id),
|
|
285
|
+
"prev_status": prev_status.value,
|
|
286
|
+
"new_status": item.status.value,
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def _action_item_source_topic(db: Session, item: TopicActionItem) -> Topic:
|
|
291
|
+
topic = db.get(Topic, item.topic_id)
|
|
292
|
+
if topic is None or topic.deleted_at is not None:
|
|
293
|
+
raise NotFoundError("Action item source topic not found")
|
|
294
|
+
return topic
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def deliver_action_item_no_commit(
|
|
298
|
+
db: Session,
|
|
299
|
+
item: TopicActionItem,
|
|
300
|
+
*,
|
|
301
|
+
triggered_by: str = "deliver",
|
|
302
|
+
agent_id: uuid.UUID | None = None,
|
|
303
|
+
) -> dict[str, Any]:
|
|
304
|
+
"""Mark an open action item done when its source topic may be closed/archived."""
|
|
305
|
+
if item.status != TopicActionItemStatus.open:
|
|
306
|
+
raise ConflictError(f"Action item already {item.status.value}")
|
|
307
|
+
topic = _action_item_source_topic(db, item)
|
|
308
|
+
audit_service.log_no_commit(
|
|
309
|
+
db,
|
|
310
|
+
action="action_item.delivered",
|
|
311
|
+
target_type="topic_action_item",
|
|
312
|
+
target_id=item.id,
|
|
313
|
+
agent_id=agent_id,
|
|
314
|
+
project_id=item.project_id,
|
|
315
|
+
summary=f"交付行动项「{item.title}」",
|
|
316
|
+
payload={
|
|
317
|
+
"action_item_id": str(item.id),
|
|
318
|
+
"topic_id": str(topic.id),
|
|
319
|
+
"topic_status": topic.status.value,
|
|
320
|
+
"topic_archived": topic.archived_at is not None,
|
|
321
|
+
"triggered_by": triggered_by,
|
|
322
|
+
},
|
|
323
|
+
)
|
|
324
|
+
return _complete_action_item_no_commit(db, item, triggered_by=triggered_by)
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def deliver_action_item(
|
|
328
|
+
db: Session,
|
|
329
|
+
action_item_id: uuid.UUID,
|
|
330
|
+
agent: Agent,
|
|
331
|
+
) -> TopicActionItemRead:
|
|
332
|
+
item = db.get(TopicActionItem, action_item_id)
|
|
333
|
+
if item is None:
|
|
334
|
+
raise NotFoundError("Action item not found")
|
|
335
|
+
_ensure_action_item_accessor(item, agent)
|
|
336
|
+
deliver_action_item_no_commit(db, item, triggered_by="deliver", agent_id=agent.id)
|
|
337
|
+
db.commit()
|
|
338
|
+
db.refresh(item)
|
|
339
|
+
return _action_item_read(db, item)
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def complete_action_item(
|
|
343
|
+
db: Session,
|
|
344
|
+
action_item_id: uuid.UUID,
|
|
345
|
+
agent: Agent,
|
|
346
|
+
*,
|
|
347
|
+
triggered_by: str = "manual",
|
|
348
|
+
) -> TopicActionItemRead:
|
|
349
|
+
"""Move an open action item to ``done``. Idempotent against non-open statuses (409)."""
|
|
350
|
+
item = db.get(TopicActionItem, action_item_id)
|
|
351
|
+
if item is None:
|
|
352
|
+
raise NotFoundError("Action item not found")
|
|
353
|
+
_ensure_action_item_accessor(item, agent)
|
|
354
|
+
_complete_action_item_no_commit(db, item, triggered_by=triggered_by)
|
|
355
|
+
db.commit()
|
|
356
|
+
db.refresh(item)
|
|
357
|
+
return _action_item_read(db, item)
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def mark_wake_sent_action_item(
|
|
361
|
+
db: Session,
|
|
362
|
+
action_item_id: uuid.UUID,
|
|
363
|
+
agent: Agent,
|
|
364
|
+
) -> TopicActionItemRead:
|
|
365
|
+
"""Bump wake_count + stamp last_woken_at + audit row (experiment B, plan §3).
|
|
366
|
+
|
|
367
|
+
Access: owner or admin — same gate as ``complete`` / ``cancel`` so the
|
|
368
|
+
runtime-waker CLI (driven by an admin agent) can escalate forgotten
|
|
369
|
+
action_items whose owner is unresponsive. The transaction-internal
|
|
370
|
+
helper does the mutation; this wrapper adds the access check + commit
|
|
371
|
+
+ read-back serialization.
|
|
372
|
+
|
|
373
|
+
I5: also posts a wakeable notification to the assignee so the bump
|
|
374
|
+
shows up in the owner's todos (B-1 / B-2 acceptance — wake is visible
|
|
375
|
+
to the assignee independent of the audit log).
|
|
376
|
+
"""
|
|
377
|
+
item = db.get(TopicActionItem, action_item_id)
|
|
378
|
+
if item is None:
|
|
379
|
+
raise NotFoundError("Action item not found")
|
|
380
|
+
_ensure_action_item_accessor(item, agent)
|
|
381
|
+
try:
|
|
382
|
+
action_item_service.mark_wake_sent(db, action_item_id)
|
|
383
|
+
except ValueError as exc:
|
|
384
|
+
# The I3 helper raises ValueError on non-open / unassigned items —
|
|
385
|
+
# surface as ConflictError so the API returns 409 instead of 500.
|
|
386
|
+
raise ConflictError(str(exc)) from exc
|
|
387
|
+
# I5: notify the assignee. ``mark_wake_sent`` refreshes the identity-mapped
|
|
388
|
+
# item in place, so ``item`` here reflects the new wake_count + last_woken_at
|
|
389
|
+
# when we hand it to the notification helper.
|
|
390
|
+
notification_service.notify_owner_action_item_wake(db, action_item=item)
|
|
391
|
+
return _action_item_read(db, item)
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def mark_stale_action_item(
|
|
395
|
+
db: Session,
|
|
396
|
+
action_item_id: uuid.UUID,
|
|
397
|
+
agent: Agent,
|
|
398
|
+
) -> TopicActionItemRead:
|
|
399
|
+
"""Stamp stale_at + write the ``action_item.stale`` audit row (plan §3).
|
|
400
|
+
|
|
401
|
+
Access: admin only — the stale transition is a system escalation that
|
|
402
|
+
should not be triggerable by the assignee themselves, since the whole
|
|
403
|
+
point is that they have not responded to 4 wakes. ``admin_notified=True``
|
|
404
|
+
and ``creator_audit_only=True`` are passed per plan §3 §6 (3c admin 优先
|
|
405
|
+
+ 6 creator 不 wake).
|
|
406
|
+
|
|
407
|
+
I5: also posts a wakeable notification to every admin agent (excluding
|
|
408
|
+
the owner) — plan §3 #2 (3c admin 优先). The creator is intentionally
|
|
409
|
+
NOT in the recipient set (I6 audit-only policy): they can find the
|
|
410
|
+
stale event via ``action_items`` listing or the audit history.
|
|
411
|
+
"""
|
|
412
|
+
if not is_admin(agent):
|
|
413
|
+
raise ForbiddenError("Only admin can mark an action item stale")
|
|
414
|
+
item = db.get(TopicActionItem, action_item_id)
|
|
415
|
+
if item is None:
|
|
416
|
+
raise NotFoundError("Action item not found")
|
|
417
|
+
try:
|
|
418
|
+
action_item_service.mark_stale(db, action_item_id)
|
|
419
|
+
except ValueError as exc:
|
|
420
|
+
# The audit-service helper refuses stale without a prior wake, and
|
|
421
|
+
# refuses non-open items — both surface as ConflictError (409) so
|
|
422
|
+
# the waker CLI can distinguish "retry later" from "5xx bug".
|
|
423
|
+
raise ConflictError(str(exc)) from exc
|
|
424
|
+
# I5: fan out to admin agents. No-op when the project has no admin
|
|
425
|
+
# configured (per plan risk #3 this is a degraded state — the audit
|
|
426
|
+
# row is still written, but no notification reaches a human).
|
|
427
|
+
notification_service.notify_admin_action_item_stale(db, action_item=item)
|
|
428
|
+
return _action_item_read(db, item)
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
def cancel_action_item(
|
|
432
|
+
db: Session,
|
|
433
|
+
action_item_id: uuid.UUID,
|
|
434
|
+
agent: Agent,
|
|
435
|
+
payload: ActionItemCancel,
|
|
436
|
+
*,
|
|
437
|
+
triggered_by: str = "manual",
|
|
438
|
+
) -> TopicActionItemRead:
|
|
439
|
+
"""Move an open action item to ``cancelled``. Reason + category validated by schema."""
|
|
440
|
+
item = db.get(TopicActionItem, action_item_id)
|
|
441
|
+
if item is None:
|
|
442
|
+
raise NotFoundError("Action item not found")
|
|
443
|
+
_ensure_action_item_accessor(item, agent)
|
|
444
|
+
if item.status != TopicActionItemStatus.open:
|
|
445
|
+
raise ConflictError(f"Action item already {item.status.value}")
|
|
446
|
+
prev_status = item.status
|
|
447
|
+
item.status = TopicActionItemStatus.cancelled
|
|
448
|
+
item.cancel_reason = payload.reason
|
|
449
|
+
if payload.category is not None:
|
|
450
|
+
item.category = payload.category.value
|
|
451
|
+
# 状态变更与 audit 行写在同一事务内(log_no_commit 只 flush),单次 commit;
|
|
452
|
+
# audit 失败会连同状态变更一起回滚,避免「已取消但无审计」的脱钩。
|
|
453
|
+
audit_service.log_no_commit(
|
|
454
|
+
db,
|
|
455
|
+
action="action_item.cancelled",
|
|
456
|
+
target_type="topic_action_item",
|
|
457
|
+
target_id=item.id,
|
|
458
|
+
agent_id=agent.id,
|
|
459
|
+
project_id=item.project_id,
|
|
460
|
+
summary=f"取消行动项「{item.title}」",
|
|
461
|
+
payload={
|
|
462
|
+
"action_item_id": str(item.id),
|
|
463
|
+
"prev_status": prev_status.value,
|
|
464
|
+
"new_status": item.status.value,
|
|
465
|
+
"cancel_reason": payload.reason,
|
|
466
|
+
"category": (payload.category.value if payload.category else item.category),
|
|
467
|
+
"triggered_by": triggered_by,
|
|
468
|
+
},
|
|
469
|
+
)
|
|
470
|
+
db.commit()
|
|
471
|
+
db.refresh(item)
|
|
472
|
+
return _action_item_read(db, item)
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
def link_action_item(
|
|
476
|
+
db: Session,
|
|
477
|
+
action_item_id: uuid.UUID,
|
|
478
|
+
experiment_id: uuid.UUID,
|
|
479
|
+
agent: Agent,
|
|
480
|
+
) -> TopicActionItemRead:
|
|
481
|
+
"""Attach an experiment to an open action item (``linked_experiment_id``).
|
|
482
|
+
|
|
483
|
+
Idempotency: re-linking the same experiment is a no-op (returns the item).
|
|
484
|
+
Relinking to a *different* experiment is rejected with 409 — to change link
|
|
485
|
+
target, first unlink via service-internal flow (not currently exposed).
|
|
486
|
+
|
|
487
|
+
Permissions: owner or admin (same as ``complete`` / ``cancel``).
|
|
488
|
+
"""
|
|
489
|
+
from server.services.project_service import get_experiment
|
|
490
|
+
|
|
491
|
+
item = db.get(TopicActionItem, action_item_id)
|
|
492
|
+
if item is None:
|
|
493
|
+
raise NotFoundError("Action item not found")
|
|
494
|
+
_ensure_action_item_accessor(item, agent)
|
|
495
|
+
if item.status != TopicActionItemStatus.open:
|
|
496
|
+
raise ConflictError(f"Cannot link a {item.status.value} action item")
|
|
497
|
+
|
|
498
|
+
experiment = get_experiment(db, experiment_id)
|
|
499
|
+
if experiment.project_id != item.project_id:
|
|
500
|
+
raise ConflictError("Action item and experiment belong to different projects")
|
|
501
|
+
allowed_phases = {
|
|
502
|
+
ExperimentPhase.running,
|
|
503
|
+
ExperimentPhase.result_review,
|
|
504
|
+
ExperimentPhase.done,
|
|
505
|
+
}
|
|
506
|
+
if experiment.phase not in allowed_phases:
|
|
507
|
+
raise ConflictError(
|
|
508
|
+
f"Cannot link experiment in phase {experiment.phase.value}; "
|
|
509
|
+
"must be running, result_review, or done"
|
|
510
|
+
)
|
|
511
|
+
|
|
512
|
+
if item.linked_experiment_id == experiment_id:
|
|
513
|
+
return _action_item_read(db, item)
|
|
514
|
+
if item.linked_experiment_id is not None:
|
|
515
|
+
raise ConflictError(
|
|
516
|
+
"Action item is already linked to a different experiment; "
|
|
517
|
+
"unlink first (not yet supported via CLI)"
|
|
518
|
+
)
|
|
519
|
+
|
|
520
|
+
item.linked_experiment_id = experiment_id
|
|
521
|
+
db.commit()
|
|
522
|
+
db.refresh(item)
|
|
523
|
+
audit_service.log(
|
|
524
|
+
db,
|
|
525
|
+
action="action_item.linked",
|
|
526
|
+
target_type="topic_action_item",
|
|
527
|
+
target_id=item.id,
|
|
528
|
+
agent_id=agent.id,
|
|
529
|
+
project_id=item.project_id,
|
|
530
|
+
summary=f"关联行动项「{item.title}」到实验「{experiment.title}」",
|
|
531
|
+
payload={
|
|
532
|
+
"action_item_id": str(item.id),
|
|
533
|
+
"experiment_id": str(experiment_id),
|
|
534
|
+
"triggered_by": "manual",
|
|
535
|
+
},
|
|
536
|
+
)
|
|
537
|
+
return _action_item_read(db, item)
|
|
538
|
+
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""Topic comment kind classification (user vs system/informational)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from map_types.enums import TopicCommentKind
|
|
6
|
+
|
|
7
|
+
from server.services import topic_ack_service
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def resolve_topic_comment_kind(body: str) -> TopicCommentKind:
|
|
11
|
+
"""Classify comment body; ack markers map to ``system`` for unread_change filtering."""
|
|
12
|
+
if topic_ack_service._ack_kind(body) is not None:
|
|
13
|
+
return TopicCommentKind.system
|
|
14
|
+
return TopicCommentKind.user
|