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
server/api/agents.py
ADDED
|
@@ -0,0 +1,412 @@
|
|
|
1
|
+
import uuid
|
|
2
|
+
|
|
3
|
+
from fastapi import APIRouter, Depends, Header, HTTPException, Query, Response, status
|
|
4
|
+
from fastapi.responses import StreamingResponse
|
|
5
|
+
from map_types.enums import NotificationCategory
|
|
6
|
+
from sqlalchemy import select
|
|
7
|
+
from sqlalchemy.orm import Session
|
|
8
|
+
|
|
9
|
+
from server.api.deps import get_current_agent, get_optional_current_agent
|
|
10
|
+
from server.db.session import get_db
|
|
11
|
+
from server.domain.models import Agent, AgentRole, Experiment, Project
|
|
12
|
+
from server.domain.schemas import (
|
|
13
|
+
AgentCreate,
|
|
14
|
+
AgentCreateResponse,
|
|
15
|
+
AgentRead,
|
|
16
|
+
AgentWorkRead,
|
|
17
|
+
AgentWorkSummaryRead,
|
|
18
|
+
DismissAllMentionsResultRead,
|
|
19
|
+
DismissMentionResultRead,
|
|
20
|
+
EscalationTargetRead,
|
|
21
|
+
InboundEventCreate,
|
|
22
|
+
InboundEventRead,
|
|
23
|
+
InboundEventRecordResult,
|
|
24
|
+
NotificationListRead,
|
|
25
|
+
NotificationRead,
|
|
26
|
+
TodoRead,
|
|
27
|
+
TopicProgressListRead,
|
|
28
|
+
TopicReadCursorRead,
|
|
29
|
+
)
|
|
30
|
+
from server.services import (
|
|
31
|
+
agent_work_service,
|
|
32
|
+
inbound_event_service,
|
|
33
|
+
mention_service,
|
|
34
|
+
notification_service,
|
|
35
|
+
todo_service,
|
|
36
|
+
topic_service,
|
|
37
|
+
)
|
|
38
|
+
from server.services import auth as auth_service
|
|
39
|
+
from server.services import permissions as perm
|
|
40
|
+
from server.services import project_service as svc
|
|
41
|
+
from server.services.errors import BadRequestError, ConflictError
|
|
42
|
+
from server.services.escalation_resolver import (
|
|
43
|
+
escalation_label,
|
|
44
|
+
resolve_escalation_target_with_tier,
|
|
45
|
+
)
|
|
46
|
+
from server.services.notification_stream import notification_sse_response
|
|
47
|
+
|
|
48
|
+
agents_router = APIRouter(prefix="/agents", tags=["agents"])
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@agents_router.get("", response_model=list[AgentRead])
|
|
52
|
+
def list_agents(
|
|
53
|
+
role: AgentRole | None = Query(default=None),
|
|
54
|
+
project_id: uuid.UUID | None = Query(default=None),
|
|
55
|
+
db: Session = Depends(get_db),
|
|
56
|
+
agent: Agent = Depends(get_current_agent),
|
|
57
|
+
) -> list[AgentRead]:
|
|
58
|
+
"""List agents visible to the caller.
|
|
59
|
+
|
|
60
|
+
- Admins see every agent (with optional filters).
|
|
61
|
+
- Project-bound agents see admins and agents within their own project.
|
|
62
|
+
"""
|
|
63
|
+
stmt = select(Agent).order_by(Agent.created_at.asc())
|
|
64
|
+
if not perm.is_admin(agent):
|
|
65
|
+
stmt = stmt.where(
|
|
66
|
+
(Agent.role == AgentRole.admin) | (Agent.project_id == agent.project_id)
|
|
67
|
+
)
|
|
68
|
+
if role is not None:
|
|
69
|
+
stmt = stmt.where(Agent.role == role)
|
|
70
|
+
if project_id is not None:
|
|
71
|
+
stmt = stmt.where(Agent.project_id == project_id)
|
|
72
|
+
|
|
73
|
+
agents = list(db.scalars(stmt))
|
|
74
|
+
project_keys: dict[uuid.UUID, str] = {}
|
|
75
|
+
project_ids = {a.project_id for a in agents if a.project_id is not None}
|
|
76
|
+
if project_ids:
|
|
77
|
+
rows = db.scalars(select(Project).where(Project.id.in_(project_ids))).all()
|
|
78
|
+
project_keys = {row.id: row.project_key for row in rows}
|
|
79
|
+
|
|
80
|
+
return [
|
|
81
|
+
AgentRead(
|
|
82
|
+
id=a.id,
|
|
83
|
+
name=a.name,
|
|
84
|
+
role=a.role,
|
|
85
|
+
project_id=a.project_id,
|
|
86
|
+
project_key=project_keys.get(a.project_id) if a.project_id else None,
|
|
87
|
+
created_at=a.created_at,
|
|
88
|
+
)
|
|
89
|
+
for a in agents
|
|
90
|
+
]
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@agents_router.post("", response_model=AgentCreateResponse, status_code=status.HTTP_201_CREATED)
|
|
94
|
+
def register_agent(
|
|
95
|
+
payload: AgentCreate,
|
|
96
|
+
db: Session = Depends(get_db),
|
|
97
|
+
actor: Agent | None = Depends(get_optional_current_agent),
|
|
98
|
+
) -> AgentCreateResponse:
|
|
99
|
+
perm.ensure_can_register_agent(db, actor, payload.role)
|
|
100
|
+
|
|
101
|
+
existing = db.query(Agent).filter(Agent.name == payload.name).first()
|
|
102
|
+
if existing:
|
|
103
|
+
raise ConflictError("Agent name already exists")
|
|
104
|
+
|
|
105
|
+
resolved_project_id: uuid.UUID | None = None
|
|
106
|
+
if payload.role == AgentRole.agent:
|
|
107
|
+
if payload.project_id is not None:
|
|
108
|
+
resolved_project_id = svc.get_project(db, payload.project_id).id
|
|
109
|
+
elif payload.project_key is not None:
|
|
110
|
+
resolved_project_id = svc.get_project_by_key(db, payload.project_key).id
|
|
111
|
+
else:
|
|
112
|
+
raise BadRequestError(
|
|
113
|
+
"project_id or project_key is required for role=agent"
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
try:
|
|
117
|
+
agent, token = auth_service.create_agent(
|
|
118
|
+
db, payload.name, payload.role, project_id=resolved_project_id
|
|
119
|
+
)
|
|
120
|
+
except ValueError as exc:
|
|
121
|
+
raise BadRequestError(str(exc)) from exc
|
|
122
|
+
return AgentCreateResponse(
|
|
123
|
+
id=agent.id,
|
|
124
|
+
name=agent.name,
|
|
125
|
+
role=agent.role,
|
|
126
|
+
project_id=agent.project_id,
|
|
127
|
+
created_at=agent.created_at,
|
|
128
|
+
api_token=token,
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
@agents_router.get("/me", response_model=AgentRead)
|
|
133
|
+
def get_me(
|
|
134
|
+
agent: Agent = Depends(get_current_agent),
|
|
135
|
+
db: Session = Depends(get_db),
|
|
136
|
+
) -> AgentRead:
|
|
137
|
+
project_key: str | None = None
|
|
138
|
+
if agent.project_id is not None:
|
|
139
|
+
project = svc.get_project(db, agent.project_id)
|
|
140
|
+
project_key = project.project_key
|
|
141
|
+
return AgentRead(
|
|
142
|
+
id=agent.id,
|
|
143
|
+
name=agent.name,
|
|
144
|
+
role=agent.role,
|
|
145
|
+
project_id=agent.project_id,
|
|
146
|
+
project_key=project_key,
|
|
147
|
+
created_at=agent.created_at,
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
@agents_router.get("/me/escalation-target", response_model=EscalationTargetRead)
|
|
152
|
+
def get_my_escalation_target(
|
|
153
|
+
experiment_id: uuid.UUID | None = Query(default=None),
|
|
154
|
+
agent: Agent = Depends(get_current_agent),
|
|
155
|
+
db: Session = Depends(get_db),
|
|
156
|
+
) -> EscalationTargetRead:
|
|
157
|
+
"""Resolve the escalation contact for a STATE_MACHINE.* error (I1(c)).
|
|
158
|
+
|
|
159
|
+
The CLI calls this endpoint whenever it gets a ``MAPHTTPError`` with
|
|
160
|
+
a ``STATE_MACHINE_*`` ``error_code`` so it can append an
|
|
161
|
+
``Escalation: @<name>`` line to the error output. The server runs
|
|
162
|
+
the same 3-tier rule documented on ``experiments.escalation_target_agent_id``
|
|
163
|
+
(override → caller → same-role → admin) and returns the chosen agent
|
|
164
|
+
+ the tier that picked it.
|
|
165
|
+
|
|
166
|
+
When ``experiment_id`` is omitted, only the caller + role-based rules
|
|
167
|
+
apply (no override available).
|
|
168
|
+
"""
|
|
169
|
+
experiment = None
|
|
170
|
+
if experiment_id is not None:
|
|
171
|
+
experiment = db.get(Experiment, experiment_id)
|
|
172
|
+
# Tolerate missing experiment — fall through to the role-based path
|
|
173
|
+
# so the caller still gets a useful escalation contact.
|
|
174
|
+
target_id, tier = resolve_escalation_target_with_tier(
|
|
175
|
+
db,
|
|
176
|
+
experiment=experiment,
|
|
177
|
+
caller_agent_id=agent.id,
|
|
178
|
+
project_id=agent.project_id,
|
|
179
|
+
)
|
|
180
|
+
label = escalation_label(db, escalation_target_id=target_id)
|
|
181
|
+
return EscalationTargetRead(
|
|
182
|
+
experiment_id=experiment_id,
|
|
183
|
+
escalation_target_id=target_id,
|
|
184
|
+
escalation_label=label,
|
|
185
|
+
tier=tier,
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
@agents_router.get("/me/todos", response_model=TodoRead)
|
|
190
|
+
def get_my_todos(
|
|
191
|
+
include_all_partitions: bool = Query(default=False),
|
|
192
|
+
agent: Agent = Depends(get_current_agent),
|
|
193
|
+
db: Session = Depends(get_db),
|
|
194
|
+
) -> TodoRead:
|
|
195
|
+
return todo_service.get_todos(
|
|
196
|
+
db, agent, include_all_partitions=include_all_partitions
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
@agents_router.get("/me/work", response_model=AgentWorkRead)
|
|
201
|
+
def get_my_work(
|
|
202
|
+
notification_limit: int = Query(default=50, ge=1, le=200),
|
|
203
|
+
notification_category: NotificationCategory | str | None = Query(default="wakeable"),
|
|
204
|
+
agent: Agent = Depends(get_current_agent),
|
|
205
|
+
db: Session = Depends(get_db),
|
|
206
|
+
) -> AgentWorkRead:
|
|
207
|
+
"""Unified work snapshot: whoami + topic-progress + todos + unread notifications."""
|
|
208
|
+
normalized_category: NotificationCategory | None
|
|
209
|
+
if notification_category is None or notification_category == "all":
|
|
210
|
+
normalized_category = None
|
|
211
|
+
elif isinstance(notification_category, NotificationCategory):
|
|
212
|
+
normalized_category = notification_category
|
|
213
|
+
else:
|
|
214
|
+
try:
|
|
215
|
+
normalized_category = NotificationCategory(notification_category)
|
|
216
|
+
except ValueError as exc:
|
|
217
|
+
raise HTTPException(
|
|
218
|
+
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
|
219
|
+
detail="notification_category must be wakeable, digest, or all",
|
|
220
|
+
) from exc
|
|
221
|
+
return agent_work_service.get_agent_work(
|
|
222
|
+
db,
|
|
223
|
+
agent,
|
|
224
|
+
notification_limit=notification_limit,
|
|
225
|
+
notification_category=normalized_category,
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
@agents_router.get("/me/work/summary", response_model=AgentWorkSummaryRead)
|
|
230
|
+
def get_my_work_summary(
|
|
231
|
+
include_all_personas: bool = Query(default=False),
|
|
232
|
+
topics_limit: int = Query(default=10, ge=1, le=100),
|
|
233
|
+
experiments_limit: int = Query(default=5, ge=1, le=50),
|
|
234
|
+
agent: Agent = Depends(get_current_agent),
|
|
235
|
+
db: Session = Depends(get_db),
|
|
236
|
+
) -> AgentWorkSummaryRead:
|
|
237
|
+
"""6-bucket by_kind summary of the agent's work.
|
|
238
|
+
|
|
239
|
+
Drives the /work top summary card and ``map work --summary``. Defaults
|
|
240
|
+
filter host-only buckets for non-host personas; pass
|
|
241
|
+
``include_all_personas=true`` to opt in.
|
|
242
|
+
"""
|
|
243
|
+
return agent_work_service.get_agent_work_summary(
|
|
244
|
+
db,
|
|
245
|
+
agent,
|
|
246
|
+
include_all_personas=include_all_personas,
|
|
247
|
+
topics_limit=topics_limit,
|
|
248
|
+
experiments_limit=experiments_limit,
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
@agents_router.get("/me/topic-progress", response_model=TopicProgressListRead)
|
|
253
|
+
def get_my_topic_progress(
|
|
254
|
+
agent: Agent = Depends(get_current_agent),
|
|
255
|
+
db: Session = Depends(get_db),
|
|
256
|
+
) -> TopicProgressListRead:
|
|
257
|
+
from server.services import topic_progress_service
|
|
258
|
+
|
|
259
|
+
return topic_progress_service.list_topic_progress_for_agent(db, agent)
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
@agents_router.post(
|
|
263
|
+
"/me/topics/{topic_id}/read",
|
|
264
|
+
response_model=TopicReadCursorRead,
|
|
265
|
+
)
|
|
266
|
+
def mark_my_topic_read(
|
|
267
|
+
topic_id: uuid.UUID,
|
|
268
|
+
agent: Agent = Depends(get_current_agent),
|
|
269
|
+
db: Session = Depends(get_db),
|
|
270
|
+
) -> TopicReadCursorRead:
|
|
271
|
+
return topic_service.mark_topic_read(db, agent=agent, topic_id=topic_id)
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
@agents_router.post(
|
|
275
|
+
"/me/mentions/{mention_id}/dismiss",
|
|
276
|
+
response_model=DismissMentionResultRead,
|
|
277
|
+
)
|
|
278
|
+
def dismiss_my_mention(
|
|
279
|
+
mention_id: uuid.UUID,
|
|
280
|
+
agent: Agent = Depends(get_current_agent),
|
|
281
|
+
db: Session = Depends(get_db),
|
|
282
|
+
) -> DismissMentionResultRead:
|
|
283
|
+
mention = mention_service.dismiss_mention(db, agent=agent, mention_id=mention_id)
|
|
284
|
+
if mention is None:
|
|
285
|
+
raise HTTPException(status_code=404, detail="Mention not found")
|
|
286
|
+
assert mention.dismissed_at is not None # for type checker
|
|
287
|
+
return DismissMentionResultRead(id=mention.id, dismissed_at=mention.dismissed_at)
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
@agents_router.post(
|
|
291
|
+
"/me/mentions/dismiss-all",
|
|
292
|
+
response_model=DismissAllMentionsResultRead,
|
|
293
|
+
)
|
|
294
|
+
def dismiss_all_my_mentions(
|
|
295
|
+
agent: Agent = Depends(get_current_agent),
|
|
296
|
+
db: Session = Depends(get_db),
|
|
297
|
+
) -> DismissAllMentionsResultRead:
|
|
298
|
+
count = mention_service.dismiss_all_for_agent(db, agent)
|
|
299
|
+
return DismissAllMentionsResultRead(dismissed=count)
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
@agents_router.get("/me/notifications", response_model=NotificationListRead)
|
|
303
|
+
def list_my_notifications(
|
|
304
|
+
unread_only: bool = Query(default=False),
|
|
305
|
+
category: NotificationCategory | str | None = Query(default=None),
|
|
306
|
+
target_type: str | None = Query(default=None),
|
|
307
|
+
limit: int = Query(default=50, ge=1, le=200),
|
|
308
|
+
offset: int = Query(default=0, ge=0),
|
|
309
|
+
agent: Agent = Depends(get_current_agent),
|
|
310
|
+
db: Session = Depends(get_db),
|
|
311
|
+
) -> NotificationListRead:
|
|
312
|
+
normalized_category: NotificationCategory | None
|
|
313
|
+
if category is None or category == "all":
|
|
314
|
+
normalized_category = None
|
|
315
|
+
elif isinstance(category, NotificationCategory):
|
|
316
|
+
normalized_category = category
|
|
317
|
+
else:
|
|
318
|
+
try:
|
|
319
|
+
normalized_category = NotificationCategory(category)
|
|
320
|
+
except ValueError as exc:
|
|
321
|
+
raise HTTPException(
|
|
322
|
+
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
|
323
|
+
detail="category must be wakeable, digest, or all",
|
|
324
|
+
) from exc
|
|
325
|
+
items, total = notification_service.list_for_agent(
|
|
326
|
+
db,
|
|
327
|
+
agent,
|
|
328
|
+
unread_only=unread_only,
|
|
329
|
+
category=normalized_category,
|
|
330
|
+
target_type=target_type,
|
|
331
|
+
limit=limit,
|
|
332
|
+
offset=offset,
|
|
333
|
+
)
|
|
334
|
+
unread_count = notification_service.count_unread(
|
|
335
|
+
db,
|
|
336
|
+
agent,
|
|
337
|
+
category=normalized_category,
|
|
338
|
+
target_type=target_type,
|
|
339
|
+
)
|
|
340
|
+
return NotificationListRead(
|
|
341
|
+
items=[NotificationRead.model_validate(n) for n in items],
|
|
342
|
+
total=total,
|
|
343
|
+
unread_count=unread_count,
|
|
344
|
+
)
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
@agents_router.get("/me/notifications/stream")
|
|
348
|
+
def stream_my_notifications(
|
|
349
|
+
agent: Agent = Depends(get_current_agent),
|
|
350
|
+
last_event_id: str | None = Header(default=None, alias="Last-Event-ID"),
|
|
351
|
+
) -> StreamingResponse:
|
|
352
|
+
# Last-Event-ID 由浏览器 EventSource 在断线重连时自动发送,服务端从
|
|
353
|
+
# per-agent ring buffer 中 replay id > last_event_id 的事件。
|
|
354
|
+
return notification_sse_response(agent.id, last_event_id)
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
@agents_router.post("/me/notifications/read-all")
|
|
358
|
+
def mark_all_notifications_read(
|
|
359
|
+
agent: Agent = Depends(get_current_agent),
|
|
360
|
+
db: Session = Depends(get_db),
|
|
361
|
+
) -> dict[str, int]:
|
|
362
|
+
count = notification_service.mark_all_read(db, agent)
|
|
363
|
+
return {"marked": count}
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
@agents_router.post(
|
|
367
|
+
"/me/inbound-events",
|
|
368
|
+
response_model=InboundEventRecordResult,
|
|
369
|
+
)
|
|
370
|
+
def record_my_inbound_event(
|
|
371
|
+
payload: InboundEventCreate,
|
|
372
|
+
response: Response,
|
|
373
|
+
agent: Agent = Depends(get_current_agent),
|
|
374
|
+
db: Session = Depends(get_db),
|
|
375
|
+
) -> InboundEventRecordResult:
|
|
376
|
+
"""Record that the caller has seen ``payload.event_id`` and intends to act
|
|
377
|
+
on it. This is the D6 server-side primary dedup gate: ``UNIQUE(fingerprint)``
|
|
378
|
+
enforces A1 (replay rejection) and A2 (concurrent claim) across processes
|
|
379
|
+
and restarts. A replay returns 409 Conflict — waker treats that as
|
|
380
|
+
"already woken" and skips resume.
|
|
381
|
+
|
|
382
|
+
v0.9 (M30A/M31 I2): legacy v1 fingerprints (``inbound:<event_id>``) return
|
|
383
|
+
200 OK with ``status="rejected_v1"`` and the bumped ``rejection_count`` so
|
|
384
|
+
the audit row is preserved but the waker is told to skip resume. We do NOT
|
|
385
|
+
map this to 409 because v1 is a known-legacy shape (not a cross-process
|
|
386
|
+
race) and a 409 would invite retry loops.
|
|
387
|
+
"""
|
|
388
|
+
event, result_status = inbound_event_service.record_inbound_event(
|
|
389
|
+
db, agent, payload
|
|
390
|
+
)
|
|
391
|
+
if result_status == "duplicate":
|
|
392
|
+
raise HTTPException(
|
|
393
|
+
status_code=status.HTTP_409_CONFLICT,
|
|
394
|
+
detail=(
|
|
395
|
+
f"inbound_event with fingerprint '{payload.fingerprint}' already exists; "
|
|
396
|
+
"treating as already woken (D6 server gate)"
|
|
397
|
+
),
|
|
398
|
+
)
|
|
399
|
+
if result_status == "rejected_v1":
|
|
400
|
+
# Legacy fingerprint: row persisted (or upserted) with rejection_count
|
|
401
|
+
# bumped. Use 200 instead of 201 to signal "we accepted the record but
|
|
402
|
+
# you should NOT proceed with the resume".
|
|
403
|
+
response.status_code = status.HTTP_200_OK
|
|
404
|
+
return InboundEventRecordResult(
|
|
405
|
+
status="rejected_v1",
|
|
406
|
+
event=InboundEventRead.model_validate(event),
|
|
407
|
+
)
|
|
408
|
+
response.status_code = status.HTTP_201_CREATED
|
|
409
|
+
return InboundEventRecordResult(
|
|
410
|
+
status="recorded",
|
|
411
|
+
event=InboundEventRead.model_validate(event),
|
|
412
|
+
)
|
server/api/audit.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import uuid
|
|
2
|
+
|
|
3
|
+
from fastapi import APIRouter, Depends, Query, Response
|
|
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
|
+
AuditLogRead,
|
|
11
|
+
)
|
|
12
|
+
from server.services import audit_service
|
|
13
|
+
from server.services import permissions as perm
|
|
14
|
+
|
|
15
|
+
audit_router = APIRouter(tags=["audit"])
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@audit_router.get("/audit", response_model=list[AuditLogRead])
|
|
19
|
+
def list_audit_for_target(
|
|
20
|
+
target_type: str = Query(...),
|
|
21
|
+
target_id: uuid.UUID = Query(...),
|
|
22
|
+
limit: int = Query(default=50, ge=1, le=200),
|
|
23
|
+
db: Session = Depends(get_db),
|
|
24
|
+
agent: Agent = Depends(get_current_agent),
|
|
25
|
+
) -> list[AuditLogRead]:
|
|
26
|
+
perm.ensure_audit_target_access(db, agent, target_type, target_id)
|
|
27
|
+
return audit_service.query_by_target(db, target_type, target_id, limit=limit)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@audit_router.get("/admin/audit", response_model=list[AuditLogRead])
|
|
31
|
+
def list_audit_global(
|
|
32
|
+
response: Response,
|
|
33
|
+
page: int = Query(default=1, ge=1),
|
|
34
|
+
page_size: int = Query(default=50, ge=1, le=200),
|
|
35
|
+
# I1(g): CLI ``map audit list --kind <action> --experiment <id>`` filters.
|
|
36
|
+
# ``kind`` maps onto the audit row's ``action`` column (e.g.
|
|
37
|
+
# ``review_item.mutation``). ``experiment_id`` filters on
|
|
38
|
+
# ``payload_json.experiment_id`` for events that denormalise it (every
|
|
39
|
+
# review-item mutation does).
|
|
40
|
+
kind: str | None = Query(default=None, max_length=128),
|
|
41
|
+
experiment_id: uuid.UUID | None = Query(default=None),
|
|
42
|
+
db: Session = Depends(get_db),
|
|
43
|
+
agent: Agent = Depends(get_current_agent),
|
|
44
|
+
) -> list[AuditLogRead]:
|
|
45
|
+
perm.require_admin(agent)
|
|
46
|
+
items, total = audit_service.query_all(
|
|
47
|
+
db,
|
|
48
|
+
page=page,
|
|
49
|
+
page_size=page_size,
|
|
50
|
+
action=kind,
|
|
51
|
+
experiment_id=experiment_id,
|
|
52
|
+
)
|
|
53
|
+
response.headers["X-Total-Count"] = str(total)
|
|
54
|
+
return items
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from contextvars import ContextVar
|
|
4
|
+
|
|
5
|
+
from fastapi import BackgroundTasks
|
|
6
|
+
|
|
7
|
+
_current_background_tasks: ContextVar[BackgroundTasks | None] = ContextVar(
|
|
8
|
+
"current_background_tasks",
|
|
9
|
+
default=None,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def bind_background_tasks(background_tasks: BackgroundTasks) -> None:
|
|
14
|
+
_current_background_tasks.set(background_tasks)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def get_background_tasks() -> BackgroundTasks | None:
|
|
18
|
+
return _current_background_tasks.get()
|
server/api/common.py
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import uuid
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
from sqlalchemy.orm import Session
|
|
5
|
+
|
|
6
|
+
from server.api.background_tasks import get_background_tasks
|
|
7
|
+
from server.domain.models import Agent
|
|
8
|
+
from server.services import audit_service, notification_service, webhook_service
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def emit(
|
|
12
|
+
db: Session,
|
|
13
|
+
agent: Agent,
|
|
14
|
+
*,
|
|
15
|
+
action: str,
|
|
16
|
+
target_type: str,
|
|
17
|
+
target_id: uuid.UUID | None,
|
|
18
|
+
project_id: uuid.UUID | None,
|
|
19
|
+
summary: str | None = None,
|
|
20
|
+
event: str | None = None,
|
|
21
|
+
event_payload: dict[str, Any] | None = None,
|
|
22
|
+
notify: bool = True,
|
|
23
|
+
) -> None:
|
|
24
|
+
"""记录审计日志并扇出副作用(notification + webhook)。
|
|
25
|
+
|
|
26
|
+
副作用顺序:audit → notification → webhook。
|
|
27
|
+
audit 总是执行;notification 仅在 ``notify=True`` 时执行;
|
|
28
|
+
webhook 仅在 ``event`` 非空时执行,投递失败不影响主流程。
|
|
29
|
+
"""
|
|
30
|
+
_log_audit(
|
|
31
|
+
db,
|
|
32
|
+
agent=agent,
|
|
33
|
+
action=action,
|
|
34
|
+
target_type=target_type,
|
|
35
|
+
target_id=target_id,
|
|
36
|
+
project_id=project_id,
|
|
37
|
+
summary=summary,
|
|
38
|
+
)
|
|
39
|
+
if event is None:
|
|
40
|
+
return
|
|
41
|
+
payload = event_payload or {}
|
|
42
|
+
if notify:
|
|
43
|
+
_dispatch_notifications(
|
|
44
|
+
db,
|
|
45
|
+
project_id=project_id,
|
|
46
|
+
actor_id=agent.id,
|
|
47
|
+
event=event,
|
|
48
|
+
summary=summary or event,
|
|
49
|
+
target_type=target_type,
|
|
50
|
+
target_id=target_id,
|
|
51
|
+
payload=payload,
|
|
52
|
+
)
|
|
53
|
+
_dispatch_webhooks(db, event=event, payload=payload, project_id=project_id)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _log_audit(
|
|
57
|
+
db: Session,
|
|
58
|
+
*,
|
|
59
|
+
agent: Agent,
|
|
60
|
+
action: str,
|
|
61
|
+
target_type: str,
|
|
62
|
+
target_id: uuid.UUID | None,
|
|
63
|
+
project_id: uuid.UUID | None,
|
|
64
|
+
summary: str | None,
|
|
65
|
+
) -> None:
|
|
66
|
+
audit_service.log(
|
|
67
|
+
db,
|
|
68
|
+
agent_id=agent.id,
|
|
69
|
+
action=action,
|
|
70
|
+
target_type=target_type,
|
|
71
|
+
target_id=target_id,
|
|
72
|
+
project_id=project_id,
|
|
73
|
+
summary=summary,
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _dispatch_notifications(
|
|
78
|
+
db: Session,
|
|
79
|
+
*,
|
|
80
|
+
project_id: uuid.UUID | None,
|
|
81
|
+
actor_id: uuid.UUID,
|
|
82
|
+
event: str,
|
|
83
|
+
summary: str,
|
|
84
|
+
target_type: str,
|
|
85
|
+
target_id: uuid.UUID | None,
|
|
86
|
+
payload: dict[str, Any],
|
|
87
|
+
) -> None:
|
|
88
|
+
notification_service.enqueue_from_event(
|
|
89
|
+
db,
|
|
90
|
+
project_id=project_id,
|
|
91
|
+
actor_id=actor_id,
|
|
92
|
+
event=event,
|
|
93
|
+
summary=summary,
|
|
94
|
+
target_type=target_type,
|
|
95
|
+
target_id=target_id,
|
|
96
|
+
payload=payload,
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _dispatch_webhooks(
|
|
101
|
+
db: Session,
|
|
102
|
+
*,
|
|
103
|
+
event: str,
|
|
104
|
+
payload: dict[str, Any],
|
|
105
|
+
project_id: uuid.UUID | None,
|
|
106
|
+
) -> None:
|
|
107
|
+
delivery_ids = webhook_service.enqueue_event_deliveries(
|
|
108
|
+
db, event, payload, project_id
|
|
109
|
+
)
|
|
110
|
+
background_tasks = get_background_tasks()
|
|
111
|
+
if background_tasks is not None:
|
|
112
|
+
for delivery_id in delivery_ids:
|
|
113
|
+
background_tasks.add_task(webhook_service.deliver_delivery, delivery_id)
|
|
114
|
+
else:
|
|
115
|
+
webhook_service.deliver_deliveries(db, delivery_ids)
|
|
116
|
+
|
|
117
|
+
|
server/api/deps.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
from fastapi import Depends, HTTPException, Security, status
|
|
2
|
+
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
|
3
|
+
from sqlalchemy.orm import Session
|
|
4
|
+
|
|
5
|
+
from server.db.session import get_db
|
|
6
|
+
from server.domain.models import Agent
|
|
7
|
+
from server.services.auth import get_agent_by_token
|
|
8
|
+
|
|
9
|
+
bearer_scheme = HTTPBearer(auto_error=False)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def get_current_agent(
|
|
13
|
+
credentials: HTTPAuthorizationCredentials | None = Security(bearer_scheme),
|
|
14
|
+
db: Session = Depends(get_db),
|
|
15
|
+
) -> Agent:
|
|
16
|
+
if credentials is None or credentials.scheme.lower() != "bearer":
|
|
17
|
+
raise HTTPException(status.HTTP_401_UNAUTHORIZED, detail="Missing or invalid authorization")
|
|
18
|
+
agent = get_agent_by_token(db, credentials.credentials)
|
|
19
|
+
if agent is None:
|
|
20
|
+
raise HTTPException(status.HTTP_401_UNAUTHORIZED, detail="Invalid API token")
|
|
21
|
+
return agent
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def get_optional_current_agent(
|
|
25
|
+
credentials: HTTPAuthorizationCredentials | None = Security(bearer_scheme),
|
|
26
|
+
db: Session = Depends(get_db),
|
|
27
|
+
) -> Agent | None:
|
|
28
|
+
if credentials is None or credentials.scheme.lower() != "bearer":
|
|
29
|
+
return None
|
|
30
|
+
return get_agent_by_token(db, credentials.credentials)
|