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
server/api/feedback.py ADDED
@@ -0,0 +1,75 @@
1
+ import uuid
2
+
3
+ from fastapi import APIRouter, Depends, Query, Response, status
4
+ from map_types.enums import FeedbackCategory, FeedbackStatus
5
+ from sqlalchemy.orm import Session
6
+
7
+ from server.api.deps import get_current_agent
8
+ from server.db.session import get_db
9
+ from server.domain.models import Agent
10
+ from server.domain.schemas import (
11
+ PlatformFeedbackCreate,
12
+ PlatformFeedbackRead,
13
+ PlatformFeedbackUpdate,
14
+ )
15
+ from server.services import permissions as perm
16
+ from server.services import platform_feedback_service as svc
17
+
18
+ feedback_router = APIRouter(prefix="/feedback", tags=["feedback"])
19
+
20
+
21
+ @feedback_router.post("", response_model=PlatformFeedbackRead, status_code=status.HTTP_201_CREATED)
22
+ def submit_feedback(
23
+ payload: PlatformFeedbackCreate,
24
+ db: Session = Depends(get_db),
25
+ agent: Agent = Depends(get_current_agent),
26
+ ) -> PlatformFeedbackRead:
27
+ """Any authenticated agent may submit feedback. No project boundary enforced."""
28
+ return svc.create_feedback(db, agent, payload)
29
+
30
+
31
+ @feedback_router.get("", response_model=list[PlatformFeedbackRead])
32
+ def list_feedback(
33
+ response: Response,
34
+ feedback_status: FeedbackStatus | None = Query(default=None, alias="status"),
35
+ category: FeedbackCategory | None = Query(default=None),
36
+ project_id: uuid.UUID | None = Query(default=None),
37
+ page: int = Query(default=1, ge=1),
38
+ page_size: int = Query(default=50, ge=1, le=200),
39
+ include_archived: bool = Query(default=False),
40
+ db: Session = Depends(get_db),
41
+ agent: Agent = Depends(get_current_agent),
42
+ ) -> list[PlatformFeedbackRead]:
43
+ perm.require_admin(agent)
44
+ items, total = svc.list_feedback(
45
+ db,
46
+ status=feedback_status,
47
+ category=category,
48
+ project_id=project_id,
49
+ page=page,
50
+ page_size=page_size,
51
+ include_archived=include_archived,
52
+ )
53
+ response.headers["X-Total-Count"] = str(total)
54
+ return items
55
+
56
+
57
+ @feedback_router.get("/{feedback_id}", response_model=PlatformFeedbackRead)
58
+ def get_feedback(
59
+ feedback_id: uuid.UUID,
60
+ db: Session = Depends(get_db),
61
+ agent: Agent = Depends(get_current_agent),
62
+ ) -> PlatformFeedbackRead:
63
+ perm.require_admin(agent)
64
+ return svc.get_feedback_read(db, feedback_id)
65
+
66
+
67
+ @feedback_router.patch("/{feedback_id}", response_model=PlatformFeedbackRead)
68
+ def update_feedback(
69
+ feedback_id: uuid.UUID,
70
+ payload: PlatformFeedbackUpdate,
71
+ db: Session = Depends(get_db),
72
+ agent: Agent = Depends(get_current_agent),
73
+ ) -> PlatformFeedbackRead:
74
+ perm.require_admin(agent)
75
+ return svc.update_feedback(db, feedback_id, payload)
@@ -0,0 +1,22 @@
1
+ import uuid
2
+
3
+ from fastapi import APIRouter, Depends
4
+ from sqlalchemy.orm import Session
5
+
6
+ from server.api.deps import get_current_agent
7
+ from server.db.session import get_db
8
+ from server.domain.models import Agent
9
+ from server.domain.schemas import NotificationRead
10
+ from server.services import notification_service
11
+
12
+ notifications_router = APIRouter(prefix="/notifications", tags=["notifications"])
13
+
14
+
15
+ @notifications_router.post("/{notification_id}/read", response_model=NotificationRead)
16
+ def mark_notification_read(
17
+ notification_id: uuid.UUID,
18
+ agent: Agent = Depends(get_current_agent),
19
+ db: Session = Depends(get_db),
20
+ ) -> NotificationRead:
21
+ notification = notification_service.mark_read(db, agent, notification_id)
22
+ return NotificationRead.model_validate(notification)
server/api/projects.py ADDED
@@ -0,0 +1,209 @@
1
+ import uuid
2
+
3
+ from fastapi import APIRouter, Depends, Query, status
4
+ from map_types.enums import TopicActionItemStatus
5
+ from sqlalchemy import or_, select
6
+ from sqlalchemy.orm import Session
7
+
8
+ from server.api.background_tasks import bind_background_tasks
9
+ from server.api.common import emit
10
+ from server.api.deps import get_current_agent
11
+ from server.db.session import get_db
12
+ from server.domain.models import Agent, AgentRole
13
+ from server.domain.schemas import (
14
+ AgentRead,
15
+ ProjectCreate,
16
+ ProjectRead,
17
+ ProjectStatusRead,
18
+ ProjectStatusRevise,
19
+ ProjectStatusVersionRead,
20
+ ProjectUpdate,
21
+ TopicActionItemRead,
22
+ TopicDecisionRead,
23
+ )
24
+ from server.services import permissions as perm
25
+ from server.services import project_service as svc
26
+ from server.services import project_status_service as status_doc_service
27
+
28
+ router = APIRouter(
29
+ prefix="/projects",
30
+ tags=["projects"],
31
+ dependencies=[Depends(bind_background_tasks)],
32
+ )
33
+
34
+
35
+ @router.post("", response_model=ProjectRead, status_code=status.HTTP_201_CREATED)
36
+ def create_project(
37
+ payload: ProjectCreate,
38
+ db: Session = Depends(get_db),
39
+ agent: Agent = Depends(get_current_agent),
40
+ ) -> ProjectRead:
41
+ perm.require_admin(agent)
42
+ project = svc.create_project(db, payload, author_agent_id=agent.id)
43
+ return ProjectRead.model_validate(project)
44
+
45
+
46
+ @router.get("", response_model=list[ProjectRead])
47
+ def list_projects(
48
+ include_archived: bool = Query(default=False),
49
+ db: Session = Depends(get_db),
50
+ agent: Agent = Depends(get_current_agent),
51
+ ) -> list[ProjectRead]:
52
+ if perm.is_admin(agent):
53
+ projects = svc.list_projects(db, include_archived=include_archived)
54
+ elif agent.project_id is None:
55
+ projects = []
56
+ else:
57
+ projects = svc.list_projects(
58
+ db, include_archived=include_archived, project_id=agent.project_id
59
+ )
60
+ return [ProjectRead.model_validate(p) for p in projects]
61
+
62
+
63
+ @router.get("/by-key/{project_key}", response_model=ProjectRead)
64
+ def get_project_by_key(
65
+ project_key: str,
66
+ db: Session = Depends(get_db),
67
+ agent: Agent = Depends(get_current_agent),
68
+ ) -> ProjectRead:
69
+ project = svc.get_project_by_key(db, project_key)
70
+ perm.ensure_project_access(agent, project.id)
71
+ return ProjectRead.model_validate(project)
72
+
73
+
74
+ @router.get("/{project_id}", response_model=ProjectRead)
75
+ def get_project(
76
+ project_id: uuid.UUID,
77
+ db: Session = Depends(get_db),
78
+ agent: Agent = Depends(get_current_agent),
79
+ ) -> ProjectRead:
80
+ perm.ensure_project_access(agent, project_id)
81
+ project = svc.get_project(db, project_id)
82
+ return ProjectRead.model_validate(project)
83
+
84
+
85
+ @router.get("/{project_id}/agents", response_model=list[AgentRead])
86
+ def list_project_agents(
87
+ project_id: uuid.UUID,
88
+ db: Session = Depends(get_db),
89
+ agent: Agent = Depends(get_current_agent),
90
+ ) -> list[AgentRead]:
91
+ perm.ensure_project_access(agent, project_id)
92
+ svc.get_project(db, project_id)
93
+ agents = list(
94
+ db.scalars(
95
+ select(Agent)
96
+ .where(or_(Agent.project_id == project_id, Agent.role == AgentRole.admin))
97
+ .order_by(Agent.name)
98
+ )
99
+ )
100
+ return [
101
+ AgentRead(
102
+ id=a.id,
103
+ name=a.name,
104
+ role=a.role,
105
+ project_id=a.project_id,
106
+ project_key=None,
107
+ created_at=a.created_at,
108
+ )
109
+ for a in agents
110
+ ]
111
+
112
+
113
+ @router.patch("/{project_id}", response_model=ProjectRead)
114
+ def update_project(
115
+ project_id: uuid.UUID,
116
+ payload: ProjectUpdate,
117
+ db: Session = Depends(get_db),
118
+ agent: Agent = Depends(get_current_agent),
119
+ ) -> ProjectRead:
120
+ perm.require_admin(agent)
121
+ project = svc.update_project(db, project_id, payload)
122
+ return ProjectRead.model_validate(project)
123
+
124
+
125
+ @router.get("/{project_id}/status", response_model=ProjectStatusRead)
126
+ def get_project_status(
127
+ project_id: uuid.UUID,
128
+ db: Session = Depends(get_db),
129
+ agent: Agent = Depends(get_current_agent),
130
+ ) -> ProjectStatusRead:
131
+ perm.ensure_project_access(agent, project_id)
132
+ return svc.get_project_status(db, project_id)
133
+
134
+
135
+ @router.get("/{project_id}/decisions", response_model=list[TopicDecisionRead])
136
+ def list_project_decisions(
137
+ project_id: uuid.UUID,
138
+ limit: int = Query(default=20, ge=1, le=100),
139
+ db: Session = Depends(get_db),
140
+ agent: Agent = Depends(get_current_agent),
141
+ ) -> list[TopicDecisionRead]:
142
+ perm.ensure_project_access(agent, project_id)
143
+ return svc.list_project_decisions(db, project_id, limit=limit)
144
+
145
+
146
+ @router.get("/{project_id}/action-items", response_model=list[TopicActionItemRead])
147
+ def list_project_action_items(
148
+ project_id: uuid.UUID,
149
+ owner_agent_id: uuid.UUID | None = Query(default=None),
150
+ item_status: TopicActionItemStatus | None = Query(default=None, alias="status"),
151
+ limit: int = Query(default=100, ge=1, le=200),
152
+ db: Session = Depends(get_db),
153
+ agent: Agent = Depends(get_current_agent),
154
+ ) -> list[TopicActionItemRead]:
155
+ perm.ensure_project_access(agent, project_id)
156
+ return svc.list_project_action_items(
157
+ db,
158
+ project_id,
159
+ owner_agent_id=owner_agent_id,
160
+ status=item_status,
161
+ limit=limit,
162
+ )
163
+
164
+
165
+ @router.post(
166
+ "/{project_id}/status/revisions",
167
+ response_model=ProjectStatusVersionRead,
168
+ status_code=status.HTTP_201_CREATED,
169
+ )
170
+ def revise_project_status(
171
+ project_id: uuid.UUID,
172
+ payload: ProjectStatusRevise,
173
+ db: Session = Depends(get_db),
174
+ agent: Agent = Depends(get_current_agent),
175
+ ) -> ProjectStatusVersionRead:
176
+ perm.ensure_can_revise_project_status(agent, project_id)
177
+ version = status_doc_service.revise_status(db, project_id, agent.id, payload)
178
+ emit(
179
+ db,
180
+ agent,
181
+ action="project_status.revised",
182
+ target_type="project_status_version",
183
+ target_id=version.id,
184
+ project_id=project_id,
185
+ summary=f"修订项目 Status v{version.version}",
186
+ )
187
+ return ProjectStatusVersionRead.model_validate(version)
188
+
189
+
190
+ @router.get("/{project_id}/status/versions", response_model=list[ProjectStatusVersionRead])
191
+ def list_project_status_versions(
192
+ project_id: uuid.UUID,
193
+ limit: int = Query(default=50, ge=1, le=200),
194
+ db: Session = Depends(get_db),
195
+ agent: Agent = Depends(get_current_agent),
196
+ ) -> list[ProjectStatusVersionRead]:
197
+ perm.ensure_project_access(agent, project_id)
198
+ return status_doc_service.list_status_versions(db, project_id, limit=limit)
199
+
200
+
201
+ @router.get("/{project_id}/status/versions/{version}", response_model=ProjectStatusVersionRead)
202
+ def get_project_status_version(
203
+ project_id: uuid.UUID,
204
+ version: int,
205
+ db: Session = Depends(get_db),
206
+ agent: Agent = Depends(get_current_agent),
207
+ ) -> ProjectStatusVersionRead:
208
+ perm.ensure_project_access(agent, project_id)
209
+ return status_doc_service.get_status_version(db, project_id, version)
server/api/router.py ADDED
@@ -0,0 +1,25 @@
1
+ """Aggregate API routers for backward compatibility."""
2
+
3
+ from server.api.action_items import action_items_router
4
+ from server.api.agents import agents_router
5
+ from server.api.audit import audit_router
6
+ from server.api.experiments import experiments_router
7
+ from server.api.feedback import feedback_router
8
+ from server.api.notifications import notifications_router
9
+ from server.api.projects import router
10
+ from server.api.status import status_router
11
+ from server.api.topics import topics_router
12
+ from server.api.webhooks import webhooks_router
13
+
14
+ __all__ = [
15
+ "action_items_router",
16
+ "notifications_router",
17
+ "router",
18
+ "agents_router",
19
+ "audit_router",
20
+ "experiments_router",
21
+ "feedback_router",
22
+ "status_router",
23
+ "topics_router",
24
+ "webhooks_router",
25
+ ]
server/api/status.py ADDED
@@ -0,0 +1,33 @@
1
+ import uuid
2
+
3
+ from fastapi import APIRouter, Depends, Query
4
+ from sqlalchemy.orm import Session
5
+
6
+ from server.api.deps import get_current_agent
7
+ from server.db.session import get_db
8
+ from server.domain.models import Agent
9
+ from server.domain.schemas import (
10
+ GlobalStatusRead,
11
+ )
12
+ from server.services import permissions as perm
13
+ from server.services import status_service
14
+ from server.services.errors import ForbiddenError
15
+
16
+ status_router = APIRouter(prefix="/status", tags=["status"])
17
+
18
+
19
+ @status_router.get("", response_model=GlobalStatusRead)
20
+ def get_global_status(
21
+ project_id: uuid.UUID | None = Query(default=None),
22
+ db: Session = Depends(get_db),
23
+ agent: Agent = Depends(get_current_agent),
24
+ ) -> GlobalStatusRead:
25
+ if project_id is None:
26
+ if not perm.is_admin(agent):
27
+ if agent.project_id is None:
28
+ raise ForbiddenError("Agent is not bound to a project")
29
+ project_id = agent.project_id
30
+ else:
31
+ return status_service.get_global_status(db, project_id=None)
32
+ perm.ensure_project_access(agent, project_id)
33
+ return status_service.get_global_status(db, project_id=project_id)
server/api/topics.py ADDED
@@ -0,0 +1,302 @@
1
+ import uuid
2
+
3
+ from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
4
+ from sqlalchemy.orm import Session
5
+
6
+ from server.api.background_tasks import bind_background_tasks
7
+ from server.api.common import emit
8
+ from server.api.deps import get_current_agent
9
+ from server.db.session import get_db
10
+ from server.domain.models import Agent, TopicStatus
11
+ from server.domain.schemas import (
12
+ TopicAdvanceRound,
13
+ TopicCommentCreate,
14
+ TopicCommentRead,
15
+ TopicCommentTreeNode,
16
+ TopicCreate,
17
+ TopicDecisionRead,
18
+ TopicRead,
19
+ TopicResolve,
20
+ TopicSummaryRead,
21
+ TopicUpdate,
22
+ )
23
+ from server.services import notification_service, topic_service
24
+ from server.services import permissions as perm
25
+ from server.services.errors import ForbiddenError
26
+
27
+ topics_router = APIRouter(tags=["topics"], dependencies=[Depends(bind_background_tasks)])
28
+
29
+
30
+ @topics_router.post(
31
+ "/projects/{project_id}/topics",
32
+ response_model=TopicSummaryRead,
33
+ status_code=status.HTTP_201_CREATED,
34
+ )
35
+ def create_topic(
36
+ project_id: uuid.UUID,
37
+ payload: TopicCreate,
38
+ db: Session = Depends(get_db),
39
+ agent: Agent = Depends(get_current_agent),
40
+ ) -> TopicSummaryRead:
41
+ resolved_project_id = perm.resolve_project_id_for_agent(agent, project_id)
42
+ perm.ensure_project_access(agent, resolved_project_id)
43
+ topic = topic_service.create_topic(db, resolved_project_id, agent.id, payload)
44
+ emit(
45
+ db,
46
+ agent,
47
+ action="topic.created",
48
+ target_type="topic",
49
+ target_id=topic.id,
50
+ project_id=resolved_project_id,
51
+ summary=f"创建话题「{topic.title}」",
52
+ event="topic.created",
53
+ event_payload={"id": str(topic.id), "title": topic.title},
54
+ )
55
+ return topic_service.topic_summary(db, topic)
56
+
57
+
58
+ @topics_router.get("/projects/{project_id}/topics", response_model=list[TopicSummaryRead])
59
+ def list_topics(
60
+ project_id: uuid.UUID,
61
+ response: Response,
62
+ topic_status: TopicStatus | None = Query(default=None, alias="status"),
63
+ creator_agent_id: uuid.UUID | None = Query(default=None),
64
+ q: str | None = Query(default=None),
65
+ page: int = Query(default=1, ge=1),
66
+ page_size: int = Query(default=100, ge=1, le=100),
67
+ include_archived: bool = Query(default=False),
68
+ db: Session = Depends(get_db),
69
+ agent: Agent = Depends(get_current_agent),
70
+ ) -> list[TopicSummaryRead]:
71
+ resolved_project_id = perm.resolve_project_id_for_agent(agent, project_id)
72
+ perm.ensure_project_access(agent, resolved_project_id)
73
+ topics, total = topic_service.list_topics(
74
+ db,
75
+ resolved_project_id,
76
+ status=topic_status,
77
+ creator_agent_id=creator_agent_id,
78
+ q=q,
79
+ page=page,
80
+ page_size=page_size,
81
+ include_archived=include_archived,
82
+ viewer_agent_id=agent.id,
83
+ )
84
+ response.headers["X-Total-Count"] = str(total)
85
+ return topics
86
+
87
+
88
+ @topics_router.get("/topics/{topic_id}", response_model=TopicRead)
89
+ def get_topic(
90
+ topic_id: uuid.UUID,
91
+ db: Session = Depends(get_db),
92
+ agent: Agent = Depends(get_current_agent),
93
+ ) -> TopicRead:
94
+ perm.ensure_topic_access(db, agent, topic_id)
95
+ return topic_service.get_topic_detail(db, topic_id)
96
+
97
+
98
+ @topics_router.patch("/topics/{topic_id}", response_model=TopicSummaryRead)
99
+ def update_topic(
100
+ topic_id: uuid.UUID,
101
+ payload: TopicUpdate,
102
+ db: Session = Depends(get_db),
103
+ agent: Agent = Depends(get_current_agent),
104
+ ) -> TopicSummaryRead:
105
+ # Archive/undo is a project-level operation: any project member may archive
106
+ # or restore a topic (docs/CLI.md archive spec — mirrors the experiment side
107
+ # which uses ``ensure_experiment_access``). Other field edits (title /
108
+ # description / pinned) remain host/admin-only.
109
+ changed_fields = payload.model_dump(exclude_unset=True)
110
+ if set(changed_fields) <= {"archived"}:
111
+ perm.ensure_topic_access(db, agent, topic_id)
112
+ else:
113
+ perm.ensure_topic_host_or_admin(db, agent, topic_id)
114
+ topic = topic_service.update_topic(db, topic_id, payload)
115
+ return topic_service.topic_summary(db, topic)
116
+
117
+
118
+ @topics_router.delete("/topics/{topic_id}", status_code=status.HTTP_204_NO_CONTENT)
119
+ def delete_topic(
120
+ topic_id: uuid.UUID,
121
+ db: Session = Depends(get_db),
122
+ agent: Agent = Depends(get_current_agent),
123
+ ) -> None:
124
+ perm.ensure_topic_host_or_admin(db, agent, topic_id)
125
+ topic_service.soft_delete_topic(db, topic_id)
126
+
127
+
128
+ @topics_router.post("/topics/{topic_id}/close", response_model=TopicSummaryRead)
129
+ def close_topic(
130
+ topic_id: uuid.UUID,
131
+ db: Session = Depends(get_db),
132
+ agent: Agent = Depends(get_current_agent),
133
+ ) -> TopicSummaryRead:
134
+ perm.ensure_topic_host_or_admin(db, agent, topic_id)
135
+ topic = topic_service.set_topic_status(db, topic_id, TopicStatus.closed)
136
+ # Phase 2 D2: kind-directed SSE so the waker can map to ``topic_lifecycle``.
137
+ notification_service.emit_kind(
138
+ db,
139
+ project_id=topic.project_id,
140
+ actor_id=agent.id,
141
+ personas=["host", "participant"],
142
+ event="topic.lifecycle.closed",
143
+ summary=f"话题已关闭「{topic.title}」",
144
+ target_type="topic",
145
+ target_id=topic.id,
146
+ payload={"topic_id": str(topic.id), "title": topic.title, "status": "closed"},
147
+ )
148
+ return topic_service.topic_summary(db, topic)
149
+
150
+
151
+ @topics_router.post("/topics/{topic_id}/dismiss", response_model=TopicSummaryRead)
152
+ def dismiss_my_topic(
153
+ topic_id: uuid.UUID,
154
+ db: Session = Depends(get_db),
155
+ agent: Agent = Depends(get_current_agent),
156
+ ) -> TopicSummaryRead:
157
+ """Host-only: hide an open topic from the creator's /todos.
158
+
159
+ Auto re-surfaces when the topic gets new activity (e.g. new comments).
160
+ """
161
+ topic = topic_service.dismiss_topic(db, agent=agent, topic_id=topic_id)
162
+ if topic is None:
163
+ raise HTTPException(status_code=404, detail="Topic not found")
164
+ return topic_service.topic_summary(db, topic)
165
+
166
+
167
+ @topics_router.post("/topics/{topic_id}/reopen", response_model=TopicSummaryRead)
168
+ def reopen_topic(
169
+ topic_id: uuid.UUID,
170
+ db: Session = Depends(get_db),
171
+ agent: Agent = Depends(get_current_agent),
172
+ ) -> TopicSummaryRead:
173
+ perm.ensure_topic_host_or_admin(db, agent, topic_id)
174
+ topic = topic_service.set_topic_status(db, topic_id, TopicStatus.open)
175
+ # Phase 2 D2: kind-directed SSE for reopen so the waker can map to
176
+ # ``topic_lifecycle`` and resume the participant wake loop.
177
+ notification_service.emit_kind(
178
+ db,
179
+ project_id=topic.project_id,
180
+ actor_id=agent.id,
181
+ personas=["host", "participant"],
182
+ event="topic.lifecycle.reopened",
183
+ summary=f"话题已重开「{topic.title}」",
184
+ target_type="topic",
185
+ target_id=topic.id,
186
+ payload={"topic_id": str(topic.id), "title": topic.title, "status": "open"},
187
+ )
188
+ return topic_service.topic_summary(db, topic)
189
+
190
+
191
+ @topics_router.post("/topics/{topic_id}/advance-round", response_model=TopicSummaryRead)
192
+ def advance_topic_round(
193
+ topic_id: uuid.UUID,
194
+ payload: TopicAdvanceRound | None = None,
195
+ db: Session = Depends(get_db),
196
+ agent: Agent = Depends(get_current_agent),
197
+ ) -> TopicSummaryRead:
198
+ topic = perm.ensure_topic_access(db, agent, topic_id)
199
+ body = payload or TopicAdvanceRound()
200
+
201
+ if body.ack is not None:
202
+ topic = topic_service.record_participant_round_ack(db, topic_id, agent, body.ack)
203
+ return topic_service.topic_summary(db, topic)
204
+
205
+ if topic.creator_agent_id != agent.id and not perm.is_admin(agent):
206
+ raise ForbiddenError("Only the topic host or admin can advance the discussion round")
207
+ topic = topic_service.advance_topic_round(
208
+ db,
209
+ topic_id,
210
+ increment_summary=body.increment_summary,
211
+ acknowledged_by=body.acknowledged_by,
212
+ )
213
+ emit(
214
+ db,
215
+ agent,
216
+ action="topic.advance_round",
217
+ target_type="topic",
218
+ target_id=topic.id,
219
+ project_id=topic.project_id,
220
+ summary=f"推进话题轮次至 {topic.discussion_round.value}",
221
+ event="topic.advance_round",
222
+ event_payload={
223
+ "topic_id": str(topic.id),
224
+ "discussion_round": topic.discussion_round.value,
225
+ "round_summary_count": topic.round_summary_count,
226
+ },
227
+ )
228
+ return topic_service.topic_summary(db, topic)
229
+
230
+
231
+ @topics_router.post("/topics/{topic_id}/resolve", response_model=TopicDecisionRead)
232
+ def resolve_topic(
233
+ topic_id: uuid.UUID,
234
+ payload: TopicResolve,
235
+ db: Session = Depends(get_db),
236
+ agent: Agent = Depends(get_current_agent),
237
+ ) -> TopicDecisionRead:
238
+ topic = perm.ensure_topic_access(db, agent, topic_id)
239
+ if topic.creator_agent_id != agent.id and not perm.is_admin(agent):
240
+ raise ForbiddenError("Only the topic host or admin can resolve the topic")
241
+ decision = topic_service.resolve_topic(db, topic_id, agent, payload)
242
+ emit(
243
+ db,
244
+ agent,
245
+ action="topic.resolved",
246
+ target_type="topic",
247
+ target_id=topic.id,
248
+ project_id=topic.project_id,
249
+ summary=f"沉淀话题结论「{topic.title}」",
250
+ event="topic.resolved",
251
+ event_payload={"topic_id": str(topic.id), "decision_id": str(decision.id)},
252
+ )
253
+ return topic_service.topic_decision_read(db, decision)
254
+
255
+
256
+ @topics_router.post(
257
+ "/topics/{topic_id}/comments",
258
+ response_model=TopicCommentRead,
259
+ status_code=status.HTTP_201_CREATED,
260
+ )
261
+ def create_topic_comment(
262
+ topic_id: uuid.UUID,
263
+ payload: TopicCommentCreate,
264
+ db: Session = Depends(get_db),
265
+ agent: Agent = Depends(get_current_agent),
266
+ ) -> TopicCommentRead:
267
+ topic = perm.ensure_topic_access(db, agent, topic_id)
268
+ comment, unresolved = topic_service.create_topic_comment(db, topic_id, agent, payload)
269
+ event_payload = {"topic_id": str(topic_id), "comment_id": str(comment.id)}
270
+ emit(
271
+ db,
272
+ agent,
273
+ action="topic.comment.created",
274
+ target_type="topic_comment",
275
+ target_id=comment.id,
276
+ project_id=topic.project_id,
277
+ summary="话题新评论",
278
+ event="topic.comment.created",
279
+ event_payload=event_payload,
280
+ notify=False,
281
+ )
282
+ notification_service.notify_topic_comment_created(
283
+ db,
284
+ project_id=topic.project_id,
285
+ actor_id=agent.id,
286
+ creator_agent_id=topic.creator_agent_id,
287
+ target_id=comment.id,
288
+ payload=event_payload,
289
+ )
290
+ return topic_service.topic_comment_read(db, comment, unresolved_mentions=unresolved)
291
+
292
+
293
+ @topics_router.get("/topics/{topic_id}/comments")
294
+ def list_topic_comments(
295
+ topic_id: uuid.UUID,
296
+ tree: bool = Query(default=False),
297
+ limit: int = Query(default=100, ge=1, le=500),
298
+ db: Session = Depends(get_db),
299
+ agent: Agent = Depends(get_current_agent),
300
+ ) -> list[TopicCommentRead] | list[TopicCommentTreeNode]:
301
+ perm.ensure_topic_access(db, agent, topic_id)
302
+ return topic_service.list_topic_comments(db, topic_id, tree=tree, limit=limit)