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/domain/schemas.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
|
|
3
|
+
from server.domain.models import ExperimentPhase, ReviewItem, ReviewItemStatus
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class StateMachineError(Exception):
|
|
7
|
+
pass
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
TERMINAL_PHASES = frozenset({ExperimentPhase.done, ExperimentPhase.cancelled})
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class ReviewItemTransitionContext:
|
|
15
|
+
is_creator: bool
|
|
16
|
+
is_reviewer: bool
|
|
17
|
+
is_admin: bool
|
|
18
|
+
via_plan_revision: bool = False
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def validate_phase_transition(current: ExperimentPhase, target: ExperimentPhase) -> None:
|
|
22
|
+
allowed: dict[ExperimentPhase, set[ExperimentPhase]] = {
|
|
23
|
+
ExperimentPhase.draft: {ExperimentPhase.review, ExperimentPhase.cancelled},
|
|
24
|
+
ExperimentPhase.review: {
|
|
25
|
+
ExperimentPhase.approved,
|
|
26
|
+
ExperimentPhase.draft,
|
|
27
|
+
ExperimentPhase.cancelled,
|
|
28
|
+
},
|
|
29
|
+
ExperimentPhase.approved: {ExperimentPhase.running, ExperimentPhase.cancelled},
|
|
30
|
+
ExperimentPhase.running: {ExperimentPhase.result_review, ExperimentPhase.cancelled},
|
|
31
|
+
ExperimentPhase.result_review: {
|
|
32
|
+
ExperimentPhase.done,
|
|
33
|
+
ExperimentPhase.running,
|
|
34
|
+
ExperimentPhase.cancelled,
|
|
35
|
+
},
|
|
36
|
+
ExperimentPhase.done: set(),
|
|
37
|
+
ExperimentPhase.cancelled: set(),
|
|
38
|
+
}
|
|
39
|
+
if current in TERMINAL_PHASES:
|
|
40
|
+
raise StateMachineError(f"Cannot transition from terminal phase '{current.value}'")
|
|
41
|
+
if target not in allowed.get(current, set()):
|
|
42
|
+
raise StateMachineError(f"Invalid phase transition: {current.value} -> {target.value}")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def validate_review_item_transition(
|
|
46
|
+
current: ReviewItemStatus,
|
|
47
|
+
target: ReviewItemStatus,
|
|
48
|
+
ctx: ReviewItemTransitionContext,
|
|
49
|
+
) -> None:
|
|
50
|
+
transitions: dict[tuple[ReviewItemStatus, ReviewItemStatus], callable] = {
|
|
51
|
+
(ReviewItemStatus.open, ReviewItemStatus.addressed): lambda c: c.via_plan_revision,
|
|
52
|
+
(ReviewItemStatus.open, ReviewItemStatus.rebutted): lambda c: c.is_creator,
|
|
53
|
+
(ReviewItemStatus.open, ReviewItemStatus.withdrawn): lambda c: c.is_reviewer,
|
|
54
|
+
(ReviewItemStatus.open, ReviewItemStatus.escalated): lambda c: c.is_reviewer or c.is_admin,
|
|
55
|
+
(ReviewItemStatus.addressed, ReviewItemStatus.resolved): lambda c: c.is_reviewer,
|
|
56
|
+
(ReviewItemStatus.addressed, ReviewItemStatus.open): lambda c: c.is_reviewer,
|
|
57
|
+
(ReviewItemStatus.rebutted, ReviewItemStatus.resolved): lambda c: c.is_reviewer,
|
|
58
|
+
(ReviewItemStatus.rebutted, ReviewItemStatus.open): lambda c: c.is_reviewer,
|
|
59
|
+
(ReviewItemStatus.escalated, ReviewItemStatus.resolved): lambda c: c.is_reviewer or c.is_admin,
|
|
60
|
+
}
|
|
61
|
+
key = (current, target)
|
|
62
|
+
if key not in transitions:
|
|
63
|
+
raise StateMachineError(f"Invalid review item transition: {current.value} -> {target.value}")
|
|
64
|
+
if not transitions[key](ctx):
|
|
65
|
+
raise StateMachineError("Actor is not allowed to perform this review item transition")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def count_open_unreasonable(unreasonable_items: list[ReviewItem]) -> int:
|
|
69
|
+
return sum(
|
|
70
|
+
1
|
|
71
|
+
for item in unreasonable_items
|
|
72
|
+
if item.status
|
|
73
|
+
in (
|
|
74
|
+
ReviewItemStatus.open,
|
|
75
|
+
ReviewItemStatus.addressed,
|
|
76
|
+
ReviewItemStatus.rebutted,
|
|
77
|
+
ReviewItemStatus.escalated,
|
|
78
|
+
)
|
|
79
|
+
)
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""Constants for topic advance-round participant acknowledgement."""
|
|
2
|
+
|
|
3
|
+
from datetime import timedelta
|
|
4
|
+
|
|
5
|
+
ADVANCE_ROUND_ACK_TIMEOUT = timedelta(hours=24)
|
|
6
|
+
|
|
7
|
+
ACK_ACCEPT_MARKER = "map:ack=accept"
|
|
8
|
+
ACK_REJECT_MARKER = "map:ack=reject"
|
|
9
|
+
ACK_DISMISS_MARKER = "map:ack=dismiss"
|
server/main.py
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
from collections.abc import AsyncIterator
|
|
2
|
+
from contextlib import asynccontextmanager
|
|
3
|
+
|
|
4
|
+
import uvicorn
|
|
5
|
+
from fastapi import FastAPI, Request, status
|
|
6
|
+
from fastapi.middleware.cors import CORSMiddleware
|
|
7
|
+
from fastapi.responses import JSONResponse
|
|
8
|
+
|
|
9
|
+
from server.__version__ import __version__
|
|
10
|
+
from server.api.router import (
|
|
11
|
+
action_items_router,
|
|
12
|
+
agents_router,
|
|
13
|
+
audit_router,
|
|
14
|
+
experiments_router,
|
|
15
|
+
feedback_router,
|
|
16
|
+
notifications_router,
|
|
17
|
+
status_router,
|
|
18
|
+
topics_router,
|
|
19
|
+
webhooks_router,
|
|
20
|
+
)
|
|
21
|
+
from server.api.router import (
|
|
22
|
+
router as projects_router,
|
|
23
|
+
)
|
|
24
|
+
from server.config import get_settings
|
|
25
|
+
from server.db.session import init_db
|
|
26
|
+
from server.services.errors import (
|
|
27
|
+
BadRequestError,
|
|
28
|
+
ConflictError,
|
|
29
|
+
ForbiddenError,
|
|
30
|
+
NotFoundError,
|
|
31
|
+
StateTransitionError,
|
|
32
|
+
UnauthorizedError,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def register_domain_exception_handlers(app: FastAPI) -> None:
|
|
37
|
+
"""集中把领域异常映射为 HTTP 响应。
|
|
38
|
+
|
|
39
|
+
取代过去在每个端点里手写的 ``try: ... except (...) as exc: raise http_error(exc)``
|
|
40
|
+
样板——端点现在只需让领域异常自然抛出,由本处理器统一翻译。
|
|
41
|
+
输出与原 ``http_error`` 完全一致:``{"detail": str(exc)}`` + 对应状态码。
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
mapping = {
|
|
45
|
+
NotFoundError: status.HTTP_404_NOT_FOUND,
|
|
46
|
+
UnauthorizedError: status.HTTP_401_UNAUTHORIZED,
|
|
47
|
+
ForbiddenError: status.HTTP_403_FORBIDDEN,
|
|
48
|
+
ConflictError: status.HTTP_409_CONFLICT,
|
|
49
|
+
BadRequestError: status.HTTP_400_BAD_REQUEST,
|
|
50
|
+
StateTransitionError: status.HTTP_422_UNPROCESSABLE_ENTITY,
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
def make_handler(code: int):
|
|
54
|
+
def handler(_request: Request, exc: Exception) -> JSONResponse:
|
|
55
|
+
content: dict[str, str] = {"detail": str(exc)}
|
|
56
|
+
reason = getattr(exc, "reason", None)
|
|
57
|
+
if isinstance(reason, str) and reason:
|
|
58
|
+
content["reason"] = reason
|
|
59
|
+
actor_id = getattr(exc, "actor_id", None)
|
|
60
|
+
if isinstance(actor_id, str) and actor_id:
|
|
61
|
+
content["actor_id"] = actor_id
|
|
62
|
+
experiment_id = getattr(exc, "experiment_id", None)
|
|
63
|
+
if isinstance(experiment_id, str) and experiment_id:
|
|
64
|
+
content["experiment_id"] = experiment_id
|
|
65
|
+
# State-machine errors may carry a stable subcode + remediation
|
|
66
|
+
# hint. Surface them so CLI / SDK callers can route on the
|
|
67
|
+
# failure mode instead of pattern-matching the human-readable
|
|
68
|
+
# ``detail`` string. Defaults are skipped so the response stays
|
|
69
|
+
# compact for ordinary state-machine refusals.
|
|
70
|
+
if isinstance(exc, StateTransitionError):
|
|
71
|
+
error_code = getattr(exc, "error_code", None)
|
|
72
|
+
if isinstance(error_code, str) and error_code:
|
|
73
|
+
content["error_code"] = error_code
|
|
74
|
+
hint = getattr(exc, "hint", None)
|
|
75
|
+
if isinstance(hint, str) and hint:
|
|
76
|
+
content["hint"] = hint
|
|
77
|
+
retryable = getattr(exc, "retryable", None)
|
|
78
|
+
if isinstance(retryable, bool):
|
|
79
|
+
content["retryable"] = retryable
|
|
80
|
+
return JSONResponse(status_code=code, content=content)
|
|
81
|
+
|
|
82
|
+
return handler
|
|
83
|
+
|
|
84
|
+
for exc_type, code in mapping.items():
|
|
85
|
+
app.add_exception_handler(exc_type, make_handler(code))
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def create_app(*, init_db_on_startup: bool = True) -> FastAPI:
|
|
89
|
+
"""Build the ASGI app.
|
|
90
|
+
|
|
91
|
+
Args:
|
|
92
|
+
init_db_on_startup: When ``True`` (default), the app runs
|
|
93
|
+
:func:`server.db.session.init_db` inside a FastAPI lifespan
|
|
94
|
+
handler so ``uvicorn server.main:app`` self-bootstraps the
|
|
95
|
+
schema. Tests pass ``False`` and inject a per-test session via
|
|
96
|
+
``app.dependency_overrides``.
|
|
97
|
+
"""
|
|
98
|
+
settings = get_settings()
|
|
99
|
+
|
|
100
|
+
@asynccontextmanager
|
|
101
|
+
async def lifespan(_app: FastAPI) -> AsyncIterator[None]:
|
|
102
|
+
if init_db_on_startup:
|
|
103
|
+
init_db()
|
|
104
|
+
yield
|
|
105
|
+
|
|
106
|
+
app = FastAPI(
|
|
107
|
+
title="Multi-Agent Platform",
|
|
108
|
+
version=__version__,
|
|
109
|
+
lifespan=lifespan,
|
|
110
|
+
)
|
|
111
|
+
app.add_middleware(
|
|
112
|
+
CORSMiddleware,
|
|
113
|
+
allow_origins=settings.cors_origin_list,
|
|
114
|
+
allow_credentials=True,
|
|
115
|
+
allow_methods=["*"],
|
|
116
|
+
allow_headers=["*"],
|
|
117
|
+
)
|
|
118
|
+
prefix = settings.api_prefix
|
|
119
|
+
app.include_router(projects_router, prefix=prefix)
|
|
120
|
+
app.include_router(experiments_router, prefix=prefix)
|
|
121
|
+
app.include_router(agents_router, prefix=prefix)
|
|
122
|
+
app.include_router(notifications_router, prefix=prefix)
|
|
123
|
+
app.include_router(status_router, prefix=prefix)
|
|
124
|
+
app.include_router(topics_router, prefix=prefix)
|
|
125
|
+
app.include_router(webhooks_router, prefix=prefix)
|
|
126
|
+
app.include_router(audit_router, prefix=prefix)
|
|
127
|
+
app.include_router(feedback_router, prefix=prefix)
|
|
128
|
+
app.include_router(action_items_router, prefix=prefix)
|
|
129
|
+
|
|
130
|
+
register_domain_exception_handlers(app)
|
|
131
|
+
|
|
132
|
+
@app.get("/health")
|
|
133
|
+
def health() -> dict[str, str]:
|
|
134
|
+
return {"status": "ok"}
|
|
135
|
+
|
|
136
|
+
return app
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
app = create_app()
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def run() -> None:
|
|
143
|
+
settings = get_settings()
|
|
144
|
+
uvicorn.run("server.main:app", host="0.0.0.0", port=settings.port, reload=settings.debug)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
if __name__ == "__main__":
|
|
148
|
+
run()
|
|
File without changes
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
"""Notification UNIQUE(recipient_agent_id, group_key) migration (race PR2).
|
|
2
|
+
|
|
3
|
+
Independent script — does NOT go through alembic. The plan's rationale:
|
|
4
|
+
``CREATE UNIQUE INDEX CONCURRENTLY`` cannot run inside a transaction, and
|
|
5
|
+
alembic migrations are always wrapped in a transaction. This script
|
|
6
|
+
therefore executes raw SQL against the live database.
|
|
7
|
+
|
|
8
|
+
Usage
|
|
9
|
+
-----
|
|
10
|
+
# dry-run: print plan, no DDL
|
|
11
|
+
python -m server.scripts.migrate_notification_unique --dry-run
|
|
12
|
+
|
|
13
|
+
# actually create the index
|
|
14
|
+
python -m server.scripts.migrate_notification_unique --execute
|
|
15
|
+
|
|
16
|
+
# idempotent: re-run safely (detects existing index)
|
|
17
|
+
|
|
18
|
+
PG-only
|
|
19
|
+
-------
|
|
20
|
+
SQLite / MySQL are intentionally skipped because:
|
|
21
|
+
|
|
22
|
+
* SQLite test/dev: the model's ``UniqueConstraint`` already enforces it
|
|
23
|
+
via ``create_all``; production SQLite is not in scope for race PR2.
|
|
24
|
+
* MySQL: race PR2 is PG-only (per the topic-level scope and per
|
|
25
|
+
``multi-waker 跨机器部署需 PG`` PRD note). MySQL would need a separate
|
|
26
|
+
``ALTER TABLE ... ADD UNIQUE`` script.
|
|
27
|
+
|
|
28
|
+
Pre-flight (data dedup)
|
|
29
|
+
-----------------------
|
|
30
|
+
The unique constraint assumes no duplicate ``(recipient, group_key)``
|
|
31
|
+
pairs already exist. The plan's current production state should be
|
|
32
|
+
clean (the application layer's select-then-insert was the only path),
|
|
33
|
+
but a belt-and-braces dedup runs first: for each duplicate cluster,
|
|
34
|
+
keep the row with the highest ``wake_version`` (or ``updated_at`` as
|
|
35
|
+
tiebreaker) and delete the rest. The dedup log is printed so an
|
|
36
|
+
operator can sanity-check the count.
|
|
37
|
+
|
|
38
|
+
Failure handling
|
|
39
|
+
----------------
|
|
40
|
+
* ``CREATE UNIQUE INDEX CONCURRENTLY`` may leave an INVALID index if
|
|
41
|
+
the second attempt fails or is interrupted. The script ends with a
|
|
42
|
+
defensive ``DROP INDEX CONCURRENTLY IF EXISTS invalid_idx`` so the
|
|
43
|
+
table is not left carrying a bloat index that the planner won't use.
|
|
44
|
+
* Re-runs are idempotent: existing-index detection skips the CREATE
|
|
45
|
+
and reports current row counts.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
from __future__ import annotations
|
|
49
|
+
|
|
50
|
+
import argparse
|
|
51
|
+
import logging
|
|
52
|
+
import sys
|
|
53
|
+
from collections.abc import Sequence
|
|
54
|
+
|
|
55
|
+
import sqlalchemy as sa
|
|
56
|
+
from sqlalchemy.engine import Connection, Engine
|
|
57
|
+
|
|
58
|
+
logger = logging.getLogger("map.scripts.migrate_notification_unique")
|
|
59
|
+
|
|
60
|
+
INDEX_NAME = "uq_notifications_recipient_group_key"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _is_pg(conn: Connection) -> bool:
|
|
64
|
+
return conn.dialect.name == "postgresql"
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _index_exists(conn: Connection, table: str, index: str) -> bool:
|
|
68
|
+
rows = conn.exec_driver_sql(
|
|
69
|
+
"SELECT 1 FROM pg_indexes WHERE schemaname = current_schema() "
|
|
70
|
+
"AND tablename = %s AND indexname = %s",
|
|
71
|
+
(table, index),
|
|
72
|
+
).first()
|
|
73
|
+
return rows is not None
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _count_duplicates(conn: Connection) -> int:
|
|
77
|
+
"""Return the number of duplicate (recipient_agent_id, group_key)
|
|
78
|
+
rows that the new unique index would reject. Zero is the target."""
|
|
79
|
+
rows = conn.exec_driver_sql(
|
|
80
|
+
sa.text(
|
|
81
|
+
"SELECT COUNT(*) FROM ("
|
|
82
|
+
" SELECT recipient_agent_id, group_key, COUNT(*) AS n "
|
|
83
|
+
" FROM notifications "
|
|
84
|
+
" WHERE group_key IS NOT NULL "
|
|
85
|
+
" GROUP BY recipient_agent_id, group_key HAVING COUNT(*) > 1"
|
|
86
|
+
") d"
|
|
87
|
+
)
|
|
88
|
+
).first()
|
|
89
|
+
return int(rows[0]) if rows else 0
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _deduplicate(conn: Connection) -> int:
|
|
93
|
+
"""Delete duplicate rows, keeping the most recently updated per
|
|
94
|
+
(recipient, group_key). Returns the number of rows deleted.
|
|
95
|
+
"""
|
|
96
|
+
# Use a CTE: pick the keeper row (highest wake_version, then
|
|
97
|
+
# most-recent updated_at, then most-recent created_at), delete the
|
|
98
|
+
# rest. ctid is PG-specific but this script is PG-only.
|
|
99
|
+
sql = sa.text(
|
|
100
|
+
"""
|
|
101
|
+
WITH ranked AS (
|
|
102
|
+
SELECT
|
|
103
|
+
ctid,
|
|
104
|
+
ROW_NUMBER() OVER (
|
|
105
|
+
PARTITION BY recipient_agent_id, group_key
|
|
106
|
+
ORDER BY wake_version DESC, updated_at DESC, created_at DESC
|
|
107
|
+
) AS rn
|
|
108
|
+
FROM notifications
|
|
109
|
+
WHERE group_key IS NOT NULL
|
|
110
|
+
),
|
|
111
|
+
deleted AS (
|
|
112
|
+
DELETE FROM notifications n
|
|
113
|
+
USING ranked r
|
|
114
|
+
WHERE n.ctid = r.ctid AND r.rn > 1
|
|
115
|
+
RETURNING n.id
|
|
116
|
+
)
|
|
117
|
+
SELECT COUNT(*) FROM deleted
|
|
118
|
+
"""
|
|
119
|
+
)
|
|
120
|
+
rows = conn.exec_driver_sql(sql).first()
|
|
121
|
+
return int(rows[0]) if rows else 0
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _create_index(conn: Connection) -> None:
|
|
125
|
+
# CONCURRENTLY cannot run in a transaction; the script is invoked
|
|
126
|
+
# outside any alembic revision for that reason.
|
|
127
|
+
conn.exec_driver_sql(
|
|
128
|
+
sa.text(
|
|
129
|
+
f"CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS {INDEX_NAME} "
|
|
130
|
+
f"ON notifications (recipient_agent_id, group_key) "
|
|
131
|
+
f"WHERE group_key IS NOT NULL"
|
|
132
|
+
)
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _drop_invalid_index(conn: Connection) -> bool:
|
|
137
|
+
"""If a prior failed run left an INVALID index, drop it.
|
|
138
|
+
|
|
139
|
+
Returns True if a stale invalid index was found and dropped.
|
|
140
|
+
"""
|
|
141
|
+
rows = conn.exec_driver_sql(
|
|
142
|
+
sa.text(
|
|
143
|
+
"SELECT indexname FROM pg_indexes "
|
|
144
|
+
"WHERE schemaname = current_schema() AND tablename = 'notifications' "
|
|
145
|
+
"AND indexname LIKE %s"
|
|
146
|
+
),
|
|
147
|
+
(f"%{INDEX_NAME}%",),
|
|
148
|
+
).all()
|
|
149
|
+
names = [r[0] for r in rows]
|
|
150
|
+
dropped_any = False
|
|
151
|
+
for name in names:
|
|
152
|
+
# pg_index.indisvalid is False for indexes whose build failed.
|
|
153
|
+
invalid = conn.exec_driver_sql(
|
|
154
|
+
sa.text("SELECT indisvalid FROM pg_index WHERE indexname = :n"),
|
|
155
|
+
{"n": name},
|
|
156
|
+
).first()
|
|
157
|
+
if invalid is not None and not invalid[0]:
|
|
158
|
+
conn.exec_driver_sql(
|
|
159
|
+
sa.text(f"DROP INDEX CONCURRENTLY IF EXISTS {sa.quoted_name(name, quote=True)}")
|
|
160
|
+
)
|
|
161
|
+
logger.warning("dropped invalid index notifications.%s", name)
|
|
162
|
+
dropped_any = True
|
|
163
|
+
return dropped_any
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def run(engine: Engine, *, dry_run: bool, execute: bool) -> int:
|
|
167
|
+
if not execute and not dry_run:
|
|
168
|
+
print("Nothing to do: pass --dry-run or --execute", file=sys.stderr)
|
|
169
|
+
return 2
|
|
170
|
+
|
|
171
|
+
with engine.begin() as conn:
|
|
172
|
+
if not _is_pg(conn):
|
|
173
|
+
print(
|
|
174
|
+
f"skip: dialect={conn.dialect.name!r} (PG-only script; "
|
|
175
|
+
f"SQLite/MySQL use the model's UniqueConstraint via create_all)",
|
|
176
|
+
file=sys.stderr,
|
|
177
|
+
)
|
|
178
|
+
return 0
|
|
179
|
+
|
|
180
|
+
if _index_exists(conn, "notifications", INDEX_NAME):
|
|
181
|
+
print(f"index notifications.{INDEX_NAME} already exists — nothing to do")
|
|
182
|
+
return 0
|
|
183
|
+
|
|
184
|
+
duplicates = _count_duplicates(conn)
|
|
185
|
+
if duplicates:
|
|
186
|
+
print(f"WARNING: {duplicates} duplicate (recipient, group_key) clusters exist")
|
|
187
|
+
if dry_run:
|
|
188
|
+
print("dry-run: would deduplicate then create index")
|
|
189
|
+
return 1
|
|
190
|
+
assert execute
|
|
191
|
+
deleted = _deduplicate(conn)
|
|
192
|
+
print(f"deduplicated {deleted} rows")
|
|
193
|
+
elif dry_run:
|
|
194
|
+
print("dry-run: no duplicates, would create index")
|
|
195
|
+
return 0
|
|
196
|
+
|
|
197
|
+
if execute:
|
|
198
|
+
_create_index(conn)
|
|
199
|
+
print(f"created index notifications.{INDEX_NAME}")
|
|
200
|
+
_drop_invalid_index(conn)
|
|
201
|
+
return 0
|
|
202
|
+
return 0
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
206
|
+
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
|
207
|
+
parser.add_argument("--dry-run", action="store_true", help="print plan only")
|
|
208
|
+
parser.add_argument("--execute", action="store_true", help="actually create the index")
|
|
209
|
+
parser.add_argument(
|
|
210
|
+
"--database-url",
|
|
211
|
+
default=None,
|
|
212
|
+
help="override MAP_DATABASE_URL (default: use the app's configured engine)",
|
|
213
|
+
)
|
|
214
|
+
args = parser.parse_args(argv)
|
|
215
|
+
|
|
216
|
+
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s: %(message)s")
|
|
217
|
+
|
|
218
|
+
if args.database_url:
|
|
219
|
+
engine = sa.create_engine(args.database_url)
|
|
220
|
+
else:
|
|
221
|
+
from server.config import get_settings
|
|
222
|
+
from server.db.session import engine as app_engine
|
|
223
|
+
|
|
224
|
+
get_settings() # validates env
|
|
225
|
+
engine = app_engine
|
|
226
|
+
|
|
227
|
+
return run(engine, dry_run=args.dry_run, execute=args.execute)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
if __name__ == "__main__": # pragma: no cover
|
|
231
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""purge_audit_pollution — clean cross_persona_call audit rows that pre-date the capability gate.
|
|
2
|
+
|
|
3
|
+
authz experiment (0e6926fa) PR2: before this PR, the
|
|
4
|
+
``POST /experiments/{id}/cross-persona-call`` endpoint only enforced
|
|
5
|
+
``ensure_experiment_access`` (project membership), which let any
|
|
6
|
+
project member — including ``participant`` / ``reviewer`` personas —
|
|
7
|
+
write audit rows. Those rows are now ``rejected=True`` + 403, but the
|
|
8
|
+
historical rows pre-date the gate and need to be removed from the
|
|
9
|
+
audit log so R6 metrics stop double-counting them.
|
|
10
|
+
|
|
11
|
+
Usage::
|
|
12
|
+
|
|
13
|
+
# dry-run (default): count rows that would be deleted
|
|
14
|
+
python -m server.scripts.purge_audit_pollution
|
|
15
|
+
|
|
16
|
+
# actually delete
|
|
17
|
+
python -m server.scripts.purge_audit_pollution --apply
|
|
18
|
+
|
|
19
|
+
# scope to one project
|
|
20
|
+
python -m server.scripts.purge_audit_pollution --project-id <uuid> --apply
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import argparse
|
|
26
|
+
import sys
|
|
27
|
+
from datetime import datetime
|
|
28
|
+
from uuid import UUID
|
|
29
|
+
|
|
30
|
+
from sqlalchemy import delete, select
|
|
31
|
+
|
|
32
|
+
from server.db.session import SessionLocal
|
|
33
|
+
from server.domain.models import Agent, AgentRole, AuditLog
|
|
34
|
+
from server.services.audit_service import CROSS_PERSONA_CALL
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _whitelist_agent_ids(db) -> set[UUID]:
|
|
38
|
+
"""Agents allowed to call cross_persona_call: admin (any project) +
|
|
39
|
+
host persona (same project as the audit row)."""
|
|
40
|
+
admin_ids = set(
|
|
41
|
+
db.scalars(select(Agent.id).where(Agent.role == AgentRole.admin)).all()
|
|
42
|
+
)
|
|
43
|
+
host_ids = set(
|
|
44
|
+
db.scalars(
|
|
45
|
+
select(Agent.id).where(Agent.name == "multi-agent-platform-host")
|
|
46
|
+
).all()
|
|
47
|
+
)
|
|
48
|
+
return admin_ids | host_ids
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _polluted_row_ids(db, project_id: UUID | None) -> tuple[list[UUID], int]:
|
|
52
|
+
"""Return (ids_to_purge, total_cross_persona_rows) for context."""
|
|
53
|
+
whitelist = _whitelist_agent_ids(db)
|
|
54
|
+
stmt = select(AuditLog.id, AuditLog.agent_id).where(
|
|
55
|
+
AuditLog.action == CROSS_PERSONA_CALL
|
|
56
|
+
)
|
|
57
|
+
if project_id is not None:
|
|
58
|
+
stmt = stmt.where(AuditLog.project_id == project_id)
|
|
59
|
+
polluted: list[UUID] = []
|
|
60
|
+
for row_id, agent_id in db.execute(stmt).all():
|
|
61
|
+
if agent_id not in whitelist:
|
|
62
|
+
polluted.append(row_id)
|
|
63
|
+
total = len(polluted)
|
|
64
|
+
return polluted, total
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def main(argv: list[str] | None = None) -> int:
|
|
68
|
+
parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
|
|
69
|
+
parser.add_argument(
|
|
70
|
+
"--apply",
|
|
71
|
+
action="store_true",
|
|
72
|
+
help="Actually delete rows. Default is dry-run (count only).",
|
|
73
|
+
)
|
|
74
|
+
parser.add_argument(
|
|
75
|
+
"--project-id",
|
|
76
|
+
type=UUID,
|
|
77
|
+
default=None,
|
|
78
|
+
help="Scope the purge to one project.",
|
|
79
|
+
)
|
|
80
|
+
args = parser.parse_args(argv)
|
|
81
|
+
|
|
82
|
+
db = SessionLocal()
|
|
83
|
+
try:
|
|
84
|
+
polluted, total = _polluted_row_ids(db, args.project_id)
|
|
85
|
+
ts = datetime.utcnow().isoformat(timespec="seconds")
|
|
86
|
+
if not args.apply:
|
|
87
|
+
print(
|
|
88
|
+
f"[dry-run {ts}] would delete {len(polluted)} polluted "
|
|
89
|
+
f"cross_persona_call audit rows (project_id={args.project_id or 'ALL'})"
|
|
90
|
+
)
|
|
91
|
+
return 0
|
|
92
|
+
if not polluted:
|
|
93
|
+
print(f"[apply {ts}] no polluted rows found")
|
|
94
|
+
return 0
|
|
95
|
+
# Delete in chunks to avoid huge single statements; SQLite
|
|
96
|
+
# parameter cap is 999, Postgres has none but the chunk keeps
|
|
97
|
+
# the transaction small.
|
|
98
|
+
CHUNK = 200
|
|
99
|
+
deleted = 0
|
|
100
|
+
for i in range(0, len(polluted), CHUNK):
|
|
101
|
+
chunk = polluted[i : i + CHUNK]
|
|
102
|
+
stmt = delete(AuditLog).where(AuditLog.id.in_(chunk))
|
|
103
|
+
result = db.execute(stmt)
|
|
104
|
+
deleted += result.rowcount or 0
|
|
105
|
+
db.commit()
|
|
106
|
+
print(
|
|
107
|
+
f"[apply {ts}] deleted {deleted} polluted cross_persona_call "
|
|
108
|
+
f"audit rows (project_id={args.project_id or 'ALL'})"
|
|
109
|
+
)
|
|
110
|
+
return 0
|
|
111
|
+
finally:
|
|
112
|
+
db.close()
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
if __name__ == "__main__":
|
|
116
|
+
sys.exit(main())
|
|
File without changes
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Project / experiment lookup helpers shared across services.
|
|
2
|
+
|
|
3
|
+
从 ``project_service`` 抽出 ``get_project`` 以打破 ``project_service ↔ topic_service``
|
|
4
|
+
循环 import:``topic_service`` 顶部需要 ``get_project`` 做存在性检查,而
|
|
5
|
+
``project_service`` 顶部又 import ``topic_service`` 做聚合 read——双向顶部 import
|
|
6
|
+
会导致 ``ImportError: cannot import name 'get_project' from partially initialized
|
|
7
|
+
module``。把纯查询 helper 下沉到本无 service 依赖的模块后,``topic_service`` 不再
|
|
8
|
+
需要在顶部 import ``project_service``。
|
|
9
|
+
|
|
10
|
+
``project_service`` 仍 re-export ``get_project``,保持现有
|
|
11
|
+
``from server.services.project_service import get_project`` 调用方不变。
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import uuid
|
|
15
|
+
|
|
16
|
+
from sqlalchemy.orm import Session
|
|
17
|
+
|
|
18
|
+
from server.domain.models import Project
|
|
19
|
+
from server.services.errors import NotFoundError
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def get_project(db: Session, project_id: uuid.UUID) -> Project:
|
|
23
|
+
project = db.get(Project, project_id)
|
|
24
|
+
if project is None:
|
|
25
|
+
raise NotFoundError("Project not found")
|
|
26
|
+
return project
|