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,457 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import uuid
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
|
|
6
|
+
from map_types.schemas import ActionItemStalePayload
|
|
7
|
+
from sqlalchemy import func, select
|
|
8
|
+
from sqlalchemy.orm import Session
|
|
9
|
+
|
|
10
|
+
from server.domain.models import AuditLog, ReviewItem, TopicActionItem
|
|
11
|
+
from server.domain.schemas import AuditLogRead
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def log_no_commit(
|
|
15
|
+
db: Session,
|
|
16
|
+
*,
|
|
17
|
+
action: str,
|
|
18
|
+
target_type: str,
|
|
19
|
+
agent_id: uuid.UUID | None = None,
|
|
20
|
+
project_id: uuid.UUID | None = None,
|
|
21
|
+
target_id: uuid.UUID | None = None,
|
|
22
|
+
summary: str | None = None,
|
|
23
|
+
payload: dict | None = None,
|
|
24
|
+
) -> AuditLog:
|
|
25
|
+
"""Public audit logger that flushes but does NOT commit.
|
|
26
|
+
|
|
27
|
+
cleanup experiment (f12a5638) Exp A: previously ``_log_no_commit``,
|
|
28
|
+
a private helper used by 13+ callers in ``topic_service``,
|
|
29
|
+
``review_service``, ``phase_service``, ``action_item_migration_service``,
|
|
30
|
+
and ``mention_service``. The leading underscore implied "internal"
|
|
31
|
+
but every caller in the codebase uses it directly — the public
|
|
32
|
+
contract is real. Renamed to drop the underscore so the API
|
|
33
|
+
matches its actual scope (callers may legitimately want to log
|
|
34
|
+
multiple audit rows inside a single transaction).
|
|
35
|
+
"""
|
|
36
|
+
entry = AuditLog(
|
|
37
|
+
agent_id=agent_id,
|
|
38
|
+
project_id=project_id,
|
|
39
|
+
action=action,
|
|
40
|
+
target_type=target_type,
|
|
41
|
+
target_id=target_id,
|
|
42
|
+
summary=summary,
|
|
43
|
+
payload_json=payload,
|
|
44
|
+
)
|
|
45
|
+
db.add(entry)
|
|
46
|
+
db.flush()
|
|
47
|
+
return entry
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
# cleanup Exp A: keep the legacy private alias so external callers
|
|
51
|
+
# (none in this repo, but downstream SDKs / test suites might) don't
|
|
52
|
+
# break. Mark deprecated; remove after one minor version.
|
|
53
|
+
_log_no_commit = log_no_commit
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def log(
|
|
57
|
+
db: Session,
|
|
58
|
+
*,
|
|
59
|
+
action: str,
|
|
60
|
+
target_type: str,
|
|
61
|
+
agent_id: uuid.UUID | None = None,
|
|
62
|
+
project_id: uuid.UUID | None = None,
|
|
63
|
+
target_id: uuid.UUID | None = None,
|
|
64
|
+
summary: str | None = None,
|
|
65
|
+
payload: dict | None = None,
|
|
66
|
+
) -> AuditLog:
|
|
67
|
+
entry = log_no_commit(
|
|
68
|
+
db,
|
|
69
|
+
action=action,
|
|
70
|
+
target_type=target_type,
|
|
71
|
+
agent_id=agent_id,
|
|
72
|
+
project_id=project_id,
|
|
73
|
+
target_id=target_id,
|
|
74
|
+
summary=summary,
|
|
75
|
+
payload=payload,
|
|
76
|
+
)
|
|
77
|
+
db.commit()
|
|
78
|
+
db.refresh(entry)
|
|
79
|
+
return entry
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def query_by_target(
|
|
83
|
+
db: Session,
|
|
84
|
+
target_type: str,
|
|
85
|
+
target_id: uuid.UUID,
|
|
86
|
+
*,
|
|
87
|
+
limit: int = 50,
|
|
88
|
+
) -> list[AuditLogRead]:
|
|
89
|
+
stmt = (
|
|
90
|
+
select(AuditLog)
|
|
91
|
+
.where(AuditLog.target_type == target_type, AuditLog.target_id == target_id)
|
|
92
|
+
.order_by(AuditLog.created_at.desc())
|
|
93
|
+
.limit(max(1, min(limit, 200)))
|
|
94
|
+
)
|
|
95
|
+
return [AuditLogRead.model_validate(row) for row in db.scalars(stmt)]
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def query_all(
|
|
99
|
+
db: Session,
|
|
100
|
+
*,
|
|
101
|
+
page: int = 1,
|
|
102
|
+
page_size: int = 50,
|
|
103
|
+
action: str | None = None,
|
|
104
|
+
experiment_id: uuid.UUID | None = None,
|
|
105
|
+
) -> tuple[list[AuditLogRead], int]:
|
|
106
|
+
"""Paginated admin audit query with optional filters.
|
|
107
|
+
|
|
108
|
+
``action`` is matched against ``AuditLog.action`` (the audit-log kind,
|
|
109
|
+
e.g. ``review_item.mutation``). ``experiment_id`` filters rows whose
|
|
110
|
+
``payload_json`` carries the matching experiment id — used by the CLI
|
|
111
|
+
``map audit list --experiment <id>`` flag so admins can scope the
|
|
112
|
+
timeline to a single experiment without paging the global log.
|
|
113
|
+
"""
|
|
114
|
+
stmt = select(AuditLog).order_by(AuditLog.created_at.desc())
|
|
115
|
+
if action is not None:
|
|
116
|
+
stmt = stmt.where(AuditLog.action == action)
|
|
117
|
+
# experiment_id is denormalised into payload_json by the per-event
|
|
118
|
+
# writers; filter at the DB level so paging stays efficient even when
|
|
119
|
+
# the global log grows large.
|
|
120
|
+
if experiment_id is not None:
|
|
121
|
+
experiment_id_str = str(experiment_id)
|
|
122
|
+
stmt = stmt.where(AuditLog.payload_json["experiment_id"].as_string() == experiment_id_str)
|
|
123
|
+
total = db.scalar(select(func.count()).select_from(stmt.subquery())) or 0
|
|
124
|
+
page = max(1, page)
|
|
125
|
+
page_size = max(1, min(page_size, 200))
|
|
126
|
+
items = list(db.scalars(stmt.offset((page - 1) * page_size).limit(page_size)))
|
|
127
|
+
return [AuditLogRead.model_validate(row) for row in items], total
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
# --- Action-item audit events (experiment B / waker escalation 三段式) -----
|
|
131
|
+
|
|
132
|
+
# Action types live as constants so callers and grep share one source of truth.
|
|
133
|
+
# Stale is a *diagnostic* signal, not a state transition: ``complete`` and
|
|
134
|
+
# ``cancelled`` flip ``status``; ``stale`` only annotates that the assignee has
|
|
135
|
+
# not responded across the three-stage escalation window. ``wake_sent`` marks
|
|
136
|
+
# each waker firing so reviewers can grep the escalation timeline.
|
|
137
|
+
ACTION_ITEM_STALE = "action_item.stale"
|
|
138
|
+
ACTION_ITEM_WAKE_SENT = "action_item.wake_sent"
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _log_action_item_stale_no_commit(
|
|
142
|
+
db: Session,
|
|
143
|
+
*,
|
|
144
|
+
item: TopicActionItem,
|
|
145
|
+
stale_after_attempt: int,
|
|
146
|
+
admin_notified: bool,
|
|
147
|
+
creator_audit_only: bool,
|
|
148
|
+
) -> AuditLog:
|
|
149
|
+
"""Insert the ``action_item.stale`` audit row without committing.
|
|
150
|
+
|
|
151
|
+
Used by ``action_item_service.mark_stale_no_commit`` which must keep the
|
|
152
|
+
status mutation, the ``stale_at`` timestamp, and the audit row in one
|
|
153
|
+
transaction (plan §3 §4 "wake_count 自增与 stale_at 的事务边界" risk).
|
|
154
|
+
|
|
155
|
+
Validation mirrors the public ``log_action_item_stale`` helper — any
|
|
156
|
+
rejection raises ``ValueError`` *before* the flush, so a partial insert
|
|
157
|
+
cannot leak.
|
|
158
|
+
"""
|
|
159
|
+
if item.owner_agent_id is None:
|
|
160
|
+
raise ValueError(
|
|
161
|
+
"action_item.stale requires owner_agent_id (assignee) — "
|
|
162
|
+
"waker only wakes the owner, so stale cannot fire for unassigned items"
|
|
163
|
+
)
|
|
164
|
+
if item.last_woken_at is None:
|
|
165
|
+
raise ValueError(
|
|
166
|
+
"action_item.stale requires item.last_woken_at — "
|
|
167
|
+
"stale fires only after at least one wake has happened"
|
|
168
|
+
)
|
|
169
|
+
if stale_after_attempt < 1:
|
|
170
|
+
raise ValueError(
|
|
171
|
+
f"stale_after_attempt must be >= 1 (got {stale_after_attempt})"
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
payload = ActionItemStalePayload(
|
|
175
|
+
action_item_id=item.id,
|
|
176
|
+
owner_agent_id=item.owner_agent_id,
|
|
177
|
+
topic_id=item.topic_id,
|
|
178
|
+
decision_id=item.decision_id,
|
|
179
|
+
linked_experiment_id=item.linked_experiment_id,
|
|
180
|
+
last_woken_at=item.last_woken_at,
|
|
181
|
+
wake_count=item.wake_count,
|
|
182
|
+
stale_after_attempt=stale_after_attempt,
|
|
183
|
+
admin_notified=admin_notified,
|
|
184
|
+
creator_audit_only=creator_audit_only,
|
|
185
|
+
)
|
|
186
|
+
return _log_no_commit(
|
|
187
|
+
db,
|
|
188
|
+
action=ACTION_ITEM_STALE,
|
|
189
|
+
target_type="topic_action_item",
|
|
190
|
+
agent_id=item.owner_agent_id,
|
|
191
|
+
project_id=item.project_id,
|
|
192
|
+
target_id=item.id,
|
|
193
|
+
summary=f"行动项「{item.title}」进入 stale 路径(第 {stale_after_attempt} 次唤醒后无响应)",
|
|
194
|
+
payload=payload.model_dump(mode="json"),
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def log_action_item_stale(
|
|
199
|
+
db: Session,
|
|
200
|
+
*,
|
|
201
|
+
item: TopicActionItem,
|
|
202
|
+
stale_after_attempt: int,
|
|
203
|
+
admin_notified: bool,
|
|
204
|
+
creator_audit_only: bool,
|
|
205
|
+
) -> AuditLog:
|
|
206
|
+
"""Write the ``action_item.stale`` audit event after the 4th unanswered wake.
|
|
207
|
+
|
|
208
|
+
The runtime-waker stops waking the assignee once ``stale_at`` is set on the
|
|
209
|
+
item; this event is the diagnostic signal that the open item has not
|
|
210
|
+
progressed through T+24h → T+72h → every 7d up to 4 times. See plan §1.
|
|
211
|
+
|
|
212
|
+
Payload is validated against ``ActionItemStalePayload`` before being
|
|
213
|
+
written so the audit log stays grep-friendly (B-11 acceptance: payload
|
|
214
|
+
carries ``wake_count`` so reviewers can confirm "走了 stale 路径" without
|
|
215
|
+
joining other tables).
|
|
216
|
+
|
|
217
|
+
Autocommit variant — prefer ``_log_action_item_stale_no_commit`` from
|
|
218
|
+
inside a larger service transaction (see ``action_item_service.mark_stale_no_commit``).
|
|
219
|
+
"""
|
|
220
|
+
entry = _log_action_item_stale_no_commit(
|
|
221
|
+
db,
|
|
222
|
+
item=item,
|
|
223
|
+
stale_after_attempt=stale_after_attempt,
|
|
224
|
+
admin_notified=admin_notified,
|
|
225
|
+
creator_audit_only=creator_audit_only,
|
|
226
|
+
)
|
|
227
|
+
db.commit()
|
|
228
|
+
db.refresh(entry)
|
|
229
|
+
return entry
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def log_action_item_wake_sent(
|
|
233
|
+
db: Session,
|
|
234
|
+
*,
|
|
235
|
+
item: TopicActionItem,
|
|
236
|
+
now: datetime,
|
|
237
|
+
) -> AuditLog:
|
|
238
|
+
"""Write the ``action_item.wake_sent`` audit row for one wake cycle.
|
|
239
|
+
|
|
240
|
+
Audit-only signal — the durable side-effect (``wake_count += 1``,
|
|
241
|
+
``last_woken_at = now``) is applied by ``action_item_service.mark_wake_sent``.
|
|
242
|
+
Centralising the audit shape here keeps the payload contract in one place.
|
|
243
|
+
|
|
244
|
+
The audit row also gets ``wake_count`` after the increment so reviewers can
|
|
245
|
+
reconstruct "the Nth wake" without joining the action_item table.
|
|
246
|
+
"""
|
|
247
|
+
return _log_no_commit(
|
|
248
|
+
db,
|
|
249
|
+
action=ACTION_ITEM_WAKE_SENT,
|
|
250
|
+
target_type="topic_action_item",
|
|
251
|
+
agent_id=item.owner_agent_id,
|
|
252
|
+
project_id=item.project_id,
|
|
253
|
+
target_id=item.id,
|
|
254
|
+
summary=f"行动项「{item.title}」第 {item.wake_count} 次唤醒",
|
|
255
|
+
payload={
|
|
256
|
+
"action_item_id": str(item.id),
|
|
257
|
+
"owner_agent_id": str(item.owner_agent_id) if item.owner_agent_id else None,
|
|
258
|
+
"topic_id": str(item.topic_id),
|
|
259
|
+
"wake_count": item.wake_count,
|
|
260
|
+
"last_woken_at": item.last_woken_at.isoformat() if item.last_woken_at else None,
|
|
261
|
+
},
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
# --- Review-item mutation audit events (experiment b95894db I1(e)) ---------
|
|
266
|
+
|
|
267
|
+
# I1(e): every write that touches a review item (``resolve-item`` / review
|
|
268
|
+
# add / review submit / ``reject-result``) emits a ``review_item.mutation``
|
|
269
|
+
# audit row so admins can reconstruct the per-item timeline via
|
|
270
|
+
# ``map audit list --kind review_item_mutation --experiment <id>``.
|
|
271
|
+
#
|
|
272
|
+
# ``list`` / ``show`` queries do not write audit rows — they are read-only.
|
|
273
|
+
# The audit row carries the 8-field schema defined in the plan in
|
|
274
|
+
# ``payload_json`` so the existing ``AuditLog`` table does not need a
|
|
275
|
+
# migration:
|
|
276
|
+
#
|
|
277
|
+
# timestamp (audit row created_at)
|
|
278
|
+
# actor_agent_id (audit row agent_id)
|
|
279
|
+
# experiment_id (review_item.review.experiment_id, denormalised for admin filtering)
|
|
280
|
+
# review_item_id (audit row target_id when target_type=="review_item";
|
|
281
|
+
# None for experiment-level mutations like reject-result)
|
|
282
|
+
# action ("resolve_item" / "add_item" / "submit_review" /
|
|
283
|
+
# "reject_result" — distinguishes the verb)
|
|
284
|
+
# before_state (legacy ReviewItemStatus.value, or None on add)
|
|
285
|
+
# after_state (new ReviewItemStatus.value after normalisation, or
|
|
286
|
+
# "experiment.rejected" for reject-result)
|
|
287
|
+
# reason (free-form caller-supplied reason — reject_result
|
|
288
|
+
# summary, or "host rebutted item" / etc.)
|
|
289
|
+
REVIEW_ITEM_MUTATION = "review_item.mutation"
|
|
290
|
+
_REVIEW_ITEM_MUTATION_TARGET_TYPE = "review_item"
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
# 0db51e10 I2(5e): cross-persona acceptance_status compare audit.
|
|
294
|
+
# 沿用现有 audit_logs 表 + metadata_json 字段,新增 action enum
|
|
295
|
+
# ``cross_persona_call``。6 字段写入 payload_json:
|
|
296
|
+
# caller_agent_id (调用者 agent)
|
|
297
|
+
# target_experiment_id (目标实验)
|
|
298
|
+
# visibility_diff (per-persona view diff dict)
|
|
299
|
+
# result_partition_count (返回的 persona 数)
|
|
300
|
+
# diff_size (differing 字段数)
|
|
301
|
+
# timestamp (audit row created_at 列已自带)
|
|
302
|
+
CROSS_PERSONA_CALL = "cross_persona_call"
|
|
303
|
+
_CROSS_PERSONA_CALL_TARGET_TYPE = "experiment"
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def log_cross_persona_call_no_commit(
|
|
307
|
+
db: Session,
|
|
308
|
+
*,
|
|
309
|
+
caller_agent_id: uuid.UUID,
|
|
310
|
+
target_experiment_id: uuid.UUID,
|
|
311
|
+
project_id: uuid.UUID,
|
|
312
|
+
visibility_diff: dict | None = None,
|
|
313
|
+
result_partition_count: int = 0,
|
|
314
|
+
diff_size: int = 0,
|
|
315
|
+
rejected: bool = False,
|
|
316
|
+
rejection_reason: str | None = None,
|
|
317
|
+
) -> AuditLog:
|
|
318
|
+
"""Write the ``cross_persona_call`` audit row without committing.
|
|
319
|
+
|
|
320
|
+
Caller owns the transaction (mirrors the review_item.mutation
|
|
321
|
+
pattern). The endpoint that wraps this helper commits.
|
|
322
|
+
|
|
323
|
+
Payload shape matches plan 5e contract: ``caller_agent_id`` /
|
|
324
|
+
``target_experiment_id`` / ``visibility_diff`` /
|
|
325
|
+
``result_partition_count`` / ``diff_size`` / ``timestamp``.
|
|
326
|
+
authz PR2 adds ``rejected`` (bool) and ``rejection_reason`` (str)
|
|
327
|
+
so R6 metrics can distinguish a legit cross-persona aggregation
|
|
328
|
+
from a permission-rejected attempt. ``timestamp`` is surfaced via
|
|
329
|
+
the audit row's ``created_at`` column (no need to duplicate it).
|
|
330
|
+
"""
|
|
331
|
+
payload = {
|
|
332
|
+
"caller_agent_id": str(caller_agent_id),
|
|
333
|
+
"target_experiment_id": str(target_experiment_id),
|
|
334
|
+
"visibility_diff": visibility_diff or {},
|
|
335
|
+
"result_partition_count": result_partition_count,
|
|
336
|
+
"diff_size": diff_size,
|
|
337
|
+
}
|
|
338
|
+
if rejected:
|
|
339
|
+
payload["rejected"] = True
|
|
340
|
+
if rejection_reason:
|
|
341
|
+
payload["rejection_reason"] = rejection_reason
|
|
342
|
+
return _log_no_commit(
|
|
343
|
+
db,
|
|
344
|
+
action=CROSS_PERSONA_CALL,
|
|
345
|
+
target_type=_CROSS_PERSONA_CALL_TARGET_TYPE,
|
|
346
|
+
agent_id=caller_agent_id,
|
|
347
|
+
project_id=project_id,
|
|
348
|
+
target_id=target_experiment_id,
|
|
349
|
+
payload=payload,
|
|
350
|
+
)
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def log_review_item_mutation_no_commit(
|
|
354
|
+
db: Session,
|
|
355
|
+
*,
|
|
356
|
+
item: ReviewItem | None,
|
|
357
|
+
experiment_id: uuid.UUID,
|
|
358
|
+
actor_id: uuid.UUID,
|
|
359
|
+
project_id: uuid.UUID,
|
|
360
|
+
action: str,
|
|
361
|
+
before_state: str | None,
|
|
362
|
+
after_state: str | None,
|
|
363
|
+
reason: str | None = None,
|
|
364
|
+
target_id: uuid.UUID | None = None,
|
|
365
|
+
) -> AuditLog:
|
|
366
|
+
"""Write the ``review_item.mutation`` audit row without committing.
|
|
367
|
+
|
|
368
|
+
Caller owns the transaction (mirrors the action_item audit pattern).
|
|
369
|
+
``item`` may be ``None`` for experiment-level mutations like
|
|
370
|
+
``reject-result`` — pass ``target_id=experiment_id`` explicitly.
|
|
371
|
+
"""
|
|
372
|
+
payload = {
|
|
373
|
+
"timestamp": datetime.utcnow().isoformat(),
|
|
374
|
+
"actor_agent_id": str(actor_id),
|
|
375
|
+
"experiment_id": str(experiment_id),
|
|
376
|
+
"review_item_id": str(item.id) if item is not None else None,
|
|
377
|
+
"action": action,
|
|
378
|
+
"before_state": before_state,
|
|
379
|
+
"after_state": after_state,
|
|
380
|
+
"reason": reason,
|
|
381
|
+
}
|
|
382
|
+
return _log_no_commit(
|
|
383
|
+
db,
|
|
384
|
+
action=REVIEW_ITEM_MUTATION,
|
|
385
|
+
target_type=_REVIEW_ITEM_MUTATION_TARGET_TYPE,
|
|
386
|
+
agent_id=actor_id,
|
|
387
|
+
project_id=project_id,
|
|
388
|
+
target_id=target_id if target_id is not None else (
|
|
389
|
+
item.id if item is not None else experiment_id
|
|
390
|
+
),
|
|
391
|
+
summary=(
|
|
392
|
+
f"review_item {action}: {before_state} -> {after_state}"
|
|
393
|
+
if item is not None
|
|
394
|
+
else f"review_item {action}: experiment {experiment_id}"
|
|
395
|
+
),
|
|
396
|
+
payload=payload,
|
|
397
|
+
)
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
# --- Log similarity force-skip audit events (b72d0542 I1.b(2)(e)(g)) ------
|
|
401
|
+
|
|
402
|
+
# I1.b(2)(e): when the soft similarity check fires AND the caller passes
|
|
403
|
+
# ``force_skip_similarity=True``, the warning is suppressed in the log
|
|
404
|
+
# create response and a ``log.force_skip`` audit row is written instead.
|
|
405
|
+
# Admin queries use ``map audit list --kind log.force_skip --experiment <id>``
|
|
406
|
+
# to inspect the timeline.
|
|
407
|
+
#
|
|
408
|
+
# Payload contract (5 fields, mirrors plan (e)):
|
|
409
|
+
# log_id (the new log that triggered the warning)
|
|
410
|
+
# ref_log_id (the prior log the score was computed against)
|
|
411
|
+
# similarity_score (cosine-equivalent, in [0.0, 1.0])
|
|
412
|
+
# threshold (warn threshold at the time of the skip)
|
|
413
|
+
# embedding_model (model id used for the score)
|
|
414
|
+
LOG_FORCE_SKIP = "log.force_skip"
|
|
415
|
+
_LOG_FORCE_SKIP_TARGET_TYPE = "experiment_log"
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
def log_force_skip_no_commit(
|
|
419
|
+
db: Session,
|
|
420
|
+
*,
|
|
421
|
+
log_id: uuid.UUID,
|
|
422
|
+
ref_log_id: uuid.UUID,
|
|
423
|
+
experiment_id: uuid.UUID,
|
|
424
|
+
project_id: uuid.UUID,
|
|
425
|
+
actor_id: uuid.UUID,
|
|
426
|
+
similarity_score: float,
|
|
427
|
+
threshold: float,
|
|
428
|
+
embedding_model: str,
|
|
429
|
+
) -> AuditLog:
|
|
430
|
+
"""Write the ``log.force_skip`` audit row without committing.
|
|
431
|
+
|
|
432
|
+
Caller owns the transaction (mirrors the cross_persona_call pattern).
|
|
433
|
+
The endpoint that wraps this helper commits so the log row and the
|
|
434
|
+
audit row land atomically — see
|
|
435
|
+
``server/api/experiments.py::create_log``.
|
|
436
|
+
"""
|
|
437
|
+
payload = {
|
|
438
|
+
"log_id": str(log_id),
|
|
439
|
+
"ref_log_id": str(ref_log_id),
|
|
440
|
+
"experiment_id": str(experiment_id),
|
|
441
|
+
"similarity_score": similarity_score,
|
|
442
|
+
"threshold": threshold,
|
|
443
|
+
"embedding_model": embedding_model,
|
|
444
|
+
}
|
|
445
|
+
return _log_no_commit(
|
|
446
|
+
db,
|
|
447
|
+
action=LOG_FORCE_SKIP,
|
|
448
|
+
target_type=_LOG_FORCE_SKIP_TARGET_TYPE,
|
|
449
|
+
agent_id=actor_id,
|
|
450
|
+
project_id=project_id,
|
|
451
|
+
target_id=log_id,
|
|
452
|
+
summary=(
|
|
453
|
+
f"log.force_skip: similarity {similarity_score:.2f} "
|
|
454
|
+
f">= threshold {threshold:.2f} (model={embedding_model})"
|
|
455
|
+
),
|
|
456
|
+
payload=payload,
|
|
457
|
+
)
|
server/services/auth.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import secrets
|
|
2
|
+
import uuid
|
|
3
|
+
|
|
4
|
+
import bcrypt
|
|
5
|
+
from sqlalchemy import select
|
|
6
|
+
from sqlalchemy.orm import Session
|
|
7
|
+
|
|
8
|
+
from server.domain.models import Agent, AgentRole
|
|
9
|
+
|
|
10
|
+
TOKEN_PREFIX_LEN = 8
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def hash_token(token: str) -> str:
|
|
14
|
+
return bcrypt.hashpw(token.encode(), bcrypt.gensalt()).decode()
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def verify_token(token: str, token_hash: str) -> bool:
|
|
18
|
+
return bcrypt.checkpw(token.encode(), token_hash.encode())
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def token_prefix(token: str) -> str:
|
|
22
|
+
return token[:TOKEN_PREFIX_LEN]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def create_agent(
|
|
26
|
+
db: Session,
|
|
27
|
+
name: str,
|
|
28
|
+
role: AgentRole = AgentRole.agent,
|
|
29
|
+
*,
|
|
30
|
+
project_id: uuid.UUID | None = None,
|
|
31
|
+
) -> tuple[Agent, str]:
|
|
32
|
+
if role == AgentRole.agent and project_id is None:
|
|
33
|
+
raise ValueError("project_id is required for role=agent")
|
|
34
|
+
if role == AgentRole.admin:
|
|
35
|
+
project_id = None
|
|
36
|
+
token = secrets.token_urlsafe(32)
|
|
37
|
+
agent = Agent(
|
|
38
|
+
name=name,
|
|
39
|
+
api_token_hash=hash_token(token),
|
|
40
|
+
api_token_prefix=token_prefix(token),
|
|
41
|
+
role=role,
|
|
42
|
+
project_id=project_id,
|
|
43
|
+
)
|
|
44
|
+
db.add(agent)
|
|
45
|
+
db.commit()
|
|
46
|
+
db.refresh(agent)
|
|
47
|
+
return agent, token
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def get_agent_by_token(db: Session, token: str) -> Agent | None:
|
|
51
|
+
if not token:
|
|
52
|
+
return None
|
|
53
|
+
|
|
54
|
+
if len(token) >= TOKEN_PREFIX_LEN:
|
|
55
|
+
prefix = token_prefix(token)
|
|
56
|
+
stmt = select(Agent).where(Agent.api_token_prefix == prefix)
|
|
57
|
+
for agent in db.scalars(stmt):
|
|
58
|
+
if verify_token(token, agent.api_token_hash):
|
|
59
|
+
return agent
|
|
60
|
+
|
|
61
|
+
# Legacy agents created before api_token_prefix migration (empty prefix).
|
|
62
|
+
legacy_stmt = select(Agent).where(Agent.api_token_prefix == "")
|
|
63
|
+
for agent in db.scalars(legacy_stmt):
|
|
64
|
+
if verify_token(token, agent.api_token_hash):
|
|
65
|
+
return agent
|
|
66
|
+
return None
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import uuid
|
|
2
|
+
|
|
3
|
+
from sqlalchemy import select
|
|
4
|
+
from sqlalchemy.orm import Session
|
|
5
|
+
|
|
6
|
+
from server.domain.models import Agent, Comment, CommentAnchorType, PlanVersion, Review, ReviewItem
|
|
7
|
+
from server.domain.schemas import CommentCreate, CommentRead, CommentTreeNode
|
|
8
|
+
from server.services.errors import NotFoundError
|
|
9
|
+
from server.services.project_service import get_experiment
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _validate_anchor(db: Session, experiment_id: uuid.UUID, anchor_type: CommentAnchorType, anchor_id: uuid.UUID) -> None:
|
|
13
|
+
if anchor_type == CommentAnchorType.plan:
|
|
14
|
+
plan = db.scalar(
|
|
15
|
+
select(PlanVersion).where(PlanVersion.id == anchor_id, PlanVersion.experiment_id == experiment_id)
|
|
16
|
+
)
|
|
17
|
+
if plan is None:
|
|
18
|
+
raise NotFoundError("Plan anchor not found")
|
|
19
|
+
elif anchor_type == CommentAnchorType.review:
|
|
20
|
+
review = db.scalar(
|
|
21
|
+
select(Review).where(Review.id == anchor_id, Review.experiment_id == experiment_id)
|
|
22
|
+
)
|
|
23
|
+
if review is None:
|
|
24
|
+
raise NotFoundError("Review anchor not found")
|
|
25
|
+
elif anchor_type == CommentAnchorType.review_item:
|
|
26
|
+
item = db.scalar(select(ReviewItem).where(ReviewItem.id == anchor_id))
|
|
27
|
+
if item is None or item.review.experiment_id != experiment_id:
|
|
28
|
+
raise NotFoundError("Review item anchor not found")
|
|
29
|
+
elif anchor_type == CommentAnchorType.comment:
|
|
30
|
+
parent = db.scalar(
|
|
31
|
+
select(Comment).where(Comment.id == anchor_id, Comment.experiment_id == experiment_id)
|
|
32
|
+
)
|
|
33
|
+
if parent is None:
|
|
34
|
+
raise NotFoundError("Comment anchor not found")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def create_comment(
|
|
38
|
+
db: Session,
|
|
39
|
+
experiment_id: uuid.UUID,
|
|
40
|
+
author: Agent,
|
|
41
|
+
payload: CommentCreate,
|
|
42
|
+
) -> tuple[Comment, list[str]]:
|
|
43
|
+
get_experiment(db, experiment_id)
|
|
44
|
+
_validate_anchor(db, experiment_id, payload.anchor_type, payload.anchor_id)
|
|
45
|
+
|
|
46
|
+
if payload.parent_id is not None:
|
|
47
|
+
parent = db.scalar(
|
|
48
|
+
select(Comment).where(Comment.id == payload.parent_id, Comment.experiment_id == experiment_id)
|
|
49
|
+
)
|
|
50
|
+
if parent is None:
|
|
51
|
+
raise NotFoundError("Parent comment not found")
|
|
52
|
+
|
|
53
|
+
comment = Comment(
|
|
54
|
+
experiment_id=experiment_id,
|
|
55
|
+
anchor_type=payload.anchor_type,
|
|
56
|
+
anchor_id=payload.anchor_id,
|
|
57
|
+
parent_comment_id=payload.parent_id,
|
|
58
|
+
author_agent_id=author.id,
|
|
59
|
+
body=payload.body,
|
|
60
|
+
)
|
|
61
|
+
db.add(comment)
|
|
62
|
+
db.flush()
|
|
63
|
+
db.refresh(comment)
|
|
64
|
+
|
|
65
|
+
from server.services import mention_service
|
|
66
|
+
|
|
67
|
+
experiment = get_experiment(db, experiment_id)
|
|
68
|
+
unresolved = mention_service.process_experiment_comment_mentions(
|
|
69
|
+
db,
|
|
70
|
+
comment=comment,
|
|
71
|
+
author=author,
|
|
72
|
+
project_id=experiment.project_id,
|
|
73
|
+
experiment_title=experiment.title,
|
|
74
|
+
commit=False,
|
|
75
|
+
)
|
|
76
|
+
mention_service.auto_dismiss_mentions_after_comment(
|
|
77
|
+
db,
|
|
78
|
+
new_comment_author=author,
|
|
79
|
+
experiment_id=experiment_id,
|
|
80
|
+
topic_id=None,
|
|
81
|
+
new_comment_id=comment.id,
|
|
82
|
+
commit=False,
|
|
83
|
+
)
|
|
84
|
+
db.commit()
|
|
85
|
+
return comment, unresolved
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def list_comments(
|
|
89
|
+
db: Session,
|
|
90
|
+
experiment_id: uuid.UUID,
|
|
91
|
+
*,
|
|
92
|
+
limit: int = 100,
|
|
93
|
+
) -> list[Comment]:
|
|
94
|
+
get_experiment(db, experiment_id)
|
|
95
|
+
stmt = (
|
|
96
|
+
select(Comment)
|
|
97
|
+
.where(Comment.experiment_id == experiment_id)
|
|
98
|
+
.order_by(Comment.created_at.asc())
|
|
99
|
+
.limit(max(1, min(limit, 500)))
|
|
100
|
+
)
|
|
101
|
+
return list(db.scalars(stmt))
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _agent_names_by_ids(db: Session, agent_ids: set[uuid.UUID]) -> dict[uuid.UUID, str]:
|
|
105
|
+
if not agent_ids:
|
|
106
|
+
return {}
|
|
107
|
+
return {
|
|
108
|
+
agent.id: agent.name
|
|
109
|
+
for agent in db.scalars(select(Agent).where(Agent.id.in_(agent_ids)))
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def comment_read(
|
|
114
|
+
db: Session, comment: Comment, *, unresolved_mentions: list[str] | None = None
|
|
115
|
+
) -> CommentRead:
|
|
116
|
+
author_names = _agent_names_by_ids(db, {comment.author_agent_id})
|
|
117
|
+
return CommentRead(
|
|
118
|
+
id=comment.id,
|
|
119
|
+
experiment_id=comment.experiment_id,
|
|
120
|
+
anchor_type=comment.anchor_type,
|
|
121
|
+
anchor_id=comment.anchor_id,
|
|
122
|
+
parent_comment_id=comment.parent_comment_id,
|
|
123
|
+
author_agent_id=comment.author_agent_id,
|
|
124
|
+
author_name=author_names.get(comment.author_agent_id),
|
|
125
|
+
body=comment.body,
|
|
126
|
+
created_at=comment.created_at,
|
|
127
|
+
unresolved_mentions=list(unresolved_mentions or ()),
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def comments_to_read(db: Session, comments: list[Comment]) -> list[CommentRead]:
|
|
132
|
+
author_names = _agent_names_by_ids(db, {comment.author_agent_id for comment in comments})
|
|
133
|
+
return [
|
|
134
|
+
CommentRead(
|
|
135
|
+
id=comment.id,
|
|
136
|
+
experiment_id=comment.experiment_id,
|
|
137
|
+
anchor_type=comment.anchor_type,
|
|
138
|
+
anchor_id=comment.anchor_id,
|
|
139
|
+
parent_comment_id=comment.parent_comment_id,
|
|
140
|
+
author_agent_id=comment.author_agent_id,
|
|
141
|
+
author_name=author_names.get(comment.author_agent_id),
|
|
142
|
+
body=comment.body,
|
|
143
|
+
created_at=comment.created_at,
|
|
144
|
+
)
|
|
145
|
+
for comment in comments
|
|
146
|
+
]
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def build_comment_tree(db: Session, comments: list[Comment]) -> list[CommentTreeNode]:
|
|
150
|
+
author_names = _agent_names_by_ids(db, {comment.author_agent_id for comment in comments})
|
|
151
|
+
nodes: dict[uuid.UUID, CommentTreeNode] = {}
|
|
152
|
+
for comment in comments:
|
|
153
|
+
nodes[comment.id] = CommentTreeNode(
|
|
154
|
+
id=comment.id,
|
|
155
|
+
experiment_id=comment.experiment_id,
|
|
156
|
+
anchor_type=comment.anchor_type,
|
|
157
|
+
anchor_id=comment.anchor_id,
|
|
158
|
+
parent_comment_id=comment.parent_comment_id,
|
|
159
|
+
author_agent_id=comment.author_agent_id,
|
|
160
|
+
author_name=author_names.get(comment.author_agent_id),
|
|
161
|
+
body=comment.body,
|
|
162
|
+
created_at=comment.created_at,
|
|
163
|
+
children=[],
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
roots: list[CommentTreeNode] = []
|
|
167
|
+
for comment in comments:
|
|
168
|
+
node = nodes[comment.id]
|
|
169
|
+
if comment.parent_comment_id and comment.parent_comment_id in nodes:
|
|
170
|
+
nodes[comment.parent_comment_id].children.append(node)
|
|
171
|
+
else:
|
|
172
|
+
roots.append(node)
|
|
173
|
+
return roots
|