multi-agent-platform 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (144) hide show
  1. cli/__init__.py +0 -0
  2. cli/action_item_escalation.py +177 -0
  3. cli/agent_client.py +554 -0
  4. cli/bridge_state.py +43 -0
  5. cli/commands/__init__.py +13 -0
  6. cli/commands/action.py +142 -0
  7. cli/commands/agent.py +117 -0
  8. cli/commands/audit.py +68 -0
  9. cli/commands/docs.py +179 -0
  10. cli/commands/experiment.py +755 -0
  11. cli/commands/feedback.py +106 -0
  12. cli/commands/notification.py +213 -0
  13. cli/commands/persona.py +63 -0
  14. cli/commands/project.py +87 -0
  15. cli/commands/runtime.py +105 -0
  16. cli/commands/topic.py +361 -0
  17. cli/e2e_collab.py +602 -0
  18. cli/git_checkpoint.py +68 -0
  19. cli/host_worker_types.py +151 -0
  20. cli/main.py +1553 -0
  21. cli/map_command_client.py +497 -0
  22. cli/participant_worker.py +255 -0
  23. cli/reviewer_worker.py +263 -0
  24. cli/runtime/__init__.py +5 -0
  25. cli/runtime/run_lock.py +497 -0
  26. cli/runtime_chat.py +317 -0
  27. cli/session_wake_log.py +235 -0
  28. cli/simple_waker.py +950 -0
  29. cli/table_render.py +113 -0
  30. cli/wake_backend.py +236 -0
  31. cli/worker_cycle_log.py +36 -0
  32. map_client/__init__.py +37 -0
  33. map_client/bootstrap.py +193 -0
  34. map_client/client.py +1045 -0
  35. map_client/config.py +21 -0
  36. map_client/errors.py +283 -0
  37. map_client/exceptions.py +130 -0
  38. map_client/plan_evidence.py +159 -0
  39. map_client/project_config.py +153 -0
  40. map_client/result_template.py +167 -0
  41. map_client/testing.py +27 -0
  42. map_mcp/__init__.py +4 -0
  43. map_mcp/_utils.py +28 -0
  44. map_mcp/auth.py +34 -0
  45. map_mcp/config.py +50 -0
  46. map_mcp/context.py +39 -0
  47. map_mcp/main.py +75 -0
  48. map_mcp/server.py +573 -0
  49. map_mcp/session.py +79 -0
  50. map_sdk/__init__.py +29 -0
  51. map_sdk/evidence.py +68 -0
  52. map_types/__init__.py +203 -0
  53. map_types/enums.py +199 -0
  54. map_types/schemas.py +1351 -0
  55. multi_agent_platform-0.1.0.dist-info/METADATA +298 -0
  56. multi_agent_platform-0.1.0.dist-info/RECORD +144 -0
  57. multi_agent_platform-0.1.0.dist-info/WHEEL +5 -0
  58. multi_agent_platform-0.1.0.dist-info/entry_points.txt +6 -0
  59. multi_agent_platform-0.1.0.dist-info/licenses/LICENSE +21 -0
  60. multi_agent_platform-0.1.0.dist-info/top_level.txt +6 -0
  61. server/__init__.py +0 -0
  62. server/__version__.py +14 -0
  63. server/api/__init__.py +0 -0
  64. server/api/action_items.py +138 -0
  65. server/api/agents.py +412 -0
  66. server/api/audit.py +54 -0
  67. server/api/background_tasks.py +18 -0
  68. server/api/common.py +117 -0
  69. server/api/deps.py +30 -0
  70. server/api/experiments.py +858 -0
  71. server/api/feedback.py +75 -0
  72. server/api/notifications.py +22 -0
  73. server/api/projects.py +209 -0
  74. server/api/router.py +25 -0
  75. server/api/status.py +33 -0
  76. server/api/topics.py +302 -0
  77. server/api/webhooks.py +74 -0
  78. server/auth/__init__.py +8 -0
  79. server/auth/experiment_access.py +66 -0
  80. server/config.py +38 -0
  81. server/db/__init__.py +3 -0
  82. server/db/base.py +5 -0
  83. server/db/deadlock_retry.py +146 -0
  84. server/db/session.py +41 -0
  85. server/domain/__init__.py +3 -0
  86. server/domain/encrypted_types.py +63 -0
  87. server/domain/models.py +713 -0
  88. server/domain/schemas.py +3 -0
  89. server/domain/state_machine.py +79 -0
  90. server/domain/topic_ack_constants.py +9 -0
  91. server/main.py +148 -0
  92. server/scripts/__init__.py +0 -0
  93. server/scripts/migrate_notification_unique.py +231 -0
  94. server/scripts/purge_audit_pollution.py +116 -0
  95. server/services/__init__.py +0 -0
  96. server/services/_lookups.py +26 -0
  97. server/services/acceptance_service.py +90 -0
  98. server/services/action_item_migration_service.py +190 -0
  99. server/services/action_item_service.py +200 -0
  100. server/services/agent_work_service.py +405 -0
  101. server/services/archive_lint_service.py +156 -0
  102. server/services/audit_service.py +457 -0
  103. server/services/auth.py +66 -0
  104. server/services/comment_service.py +173 -0
  105. server/services/errors.py +65 -0
  106. server/services/escalation_resolver.py +248 -0
  107. server/services/evidence_service.py +88 -0
  108. server/services/experiment_capabilities_service.py +277 -0
  109. server/services/inbound_event_service.py +111 -0
  110. server/services/lock_service.py +273 -0
  111. server/services/log_service.py +202 -0
  112. server/services/mention_service.py +730 -0
  113. server/services/notification_service.py +939 -0
  114. server/services/notification_stream.py +138 -0
  115. server/services/permissions.py +147 -0
  116. server/services/persona_activity_service.py +108 -0
  117. server/services/phase_owner_resolver.py +95 -0
  118. server/services/phase_service.py +381 -0
  119. server/services/plan_marker_service.py +235 -0
  120. server/services/plan_service.py +186 -0
  121. server/services/platform_feedback_service.py +114 -0
  122. server/services/project_service.py +534 -0
  123. server/services/project_status_service.py +132 -0
  124. server/services/review_service.py +707 -0
  125. server/services/secret_encryption.py +97 -0
  126. server/services/similarity_service.py +119 -0
  127. server/services/sse_event_schemas.py +17 -0
  128. server/services/status_service.py +68 -0
  129. server/services/template_service.py +134 -0
  130. server/services/text_utils.py +19 -0
  131. server/services/thread_activity.py +180 -0
  132. server/services/todo_persona_filter.py +73 -0
  133. server/services/todo_service.py +604 -0
  134. server/services/topic_ack_service.py +312 -0
  135. server/services/topic_action_item_ops.py +538 -0
  136. server/services/topic_comment_kind.py +14 -0
  137. server/services/topic_comment_service.py +237 -0
  138. server/services/topic_helpers.py +32 -0
  139. server/services/topic_lifecycle_service.py +478 -0
  140. server/services/topic_progress_service.py +40 -0
  141. server/services/topic_resolve_service.py +234 -0
  142. server/services/topic_service.py +102 -0
  143. server/services/topic_work_item_service.py +570 -0
  144. server/services/webhook_service.py +273 -0
@@ -0,0 +1,3 @@
1
+ """Backward-compatible re-exports; prefer `map_types` in SDK/MCP code."""
2
+
3
+ from map_types.schemas import * # noqa: F403
@@ -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