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,111 @@
1
+ """Service layer for the runtime-waker inbound-event access log.
2
+
3
+ This module backs the D6 server-side record endpoint
4
+ (``POST /agents/me/inbound-events``), which is the primary dedup gate for the
5
+ runtime-waker: every waker must call this endpoint *before* resuming a session
6
+ so the server's ``UNIQUE(fingerprint)`` constraint enforces A1 (replay rejection)
7
+ and A2 (concurrent dedup) across processes and restarts.
8
+
9
+ v0.9 (M30A/M31 I2): legacy v1 fingerprints (``inbound:<event_id>``) are
10
+ detected via :func:`notification_service.is_legacy_v1_fingerprint` and routed
11
+ to a separate code path that bumps ``InboundEvent.rejection_count`` instead of
12
+ returning the normal ``UNIQUE(fingerprint)`` 409 — the audit row is preserved
13
+ (matching plan D6 three-layer join) but the waker is told to skip resume.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from sqlalchemy import select
19
+ from sqlalchemy.exc import IntegrityError
20
+ from sqlalchemy.orm import Session
21
+
22
+ from server.domain.models import Agent, InboundEvent
23
+ from server.domain.schemas import InboundEventCreate
24
+ from server.services.notification_service import is_legacy_v1_fingerprint
25
+
26
+
27
+ def record_inbound_event(
28
+ db: Session, agent: Agent, payload: InboundEventCreate
29
+ ) -> tuple[InboundEvent, str]:
30
+ """Persist a waker's record of an inbound notification.
31
+
32
+ Returns one of:
33
+
34
+ - ``(event, "recorded")`` on first time (status code 201 at the API layer).
35
+ - ``(existing, "duplicate")`` when the ``UNIQUE(fingerprint)`` constraint
36
+ rejects the insert (replay / concurrent claim); API maps to 409 Conflict
37
+ (D6 / A1 gate).
38
+ - ``(event, "rejected_v1")`` when the fingerprint uses the legacy
39
+ ``inbound:<event_id>`` shape. The row is upserted with
40
+ ``rejection_count`` bumped, so the audit table still records the
41
+ sighting; the waker is told to skip resume (API returns 200 OK with the
42
+ current ``rejection_count`` so the caller can observe it).
43
+
44
+ The fetch-after-rollback is a best-effort lookup: in the rare race where
45
+ the conflicting row is deleted before we look it up we re-raise the
46
+ original IntegrityError so the caller still sees a 5xx rather than silently
47
+ lying.
48
+ """
49
+ if is_legacy_v1_fingerprint(payload.fingerprint):
50
+ return _record_or_reject_v1(db, agent, payload)
51
+
52
+ event = InboundEvent(
53
+ agent_id=agent.id,
54
+ event_id=payload.event_id,
55
+ event_type=payload.event_type,
56
+ source=payload.source,
57
+ fingerprint=payload.fingerprint,
58
+ payload=payload.payload,
59
+ )
60
+ db.add(event)
61
+ try:
62
+ db.commit()
63
+ except IntegrityError:
64
+ db.rollback()
65
+ existing = (
66
+ db.query(InboundEvent)
67
+ .filter(InboundEvent.fingerprint == payload.fingerprint)
68
+ .one_or_none()
69
+ )
70
+ if existing is None:
71
+ raise
72
+ return existing, "duplicate"
73
+ db.refresh(event)
74
+ return event, "recorded"
75
+
76
+
77
+ def _record_or_reject_v1(
78
+ db: Session, agent: Agent, payload: InboundEventCreate
79
+ ) -> tuple[InboundEvent, str]:
80
+ """Insert or upsert a legacy v1 fingerprint with ``rejection_count`` bumped.
81
+
82
+ v1 fingerprints are pre-v0.9 namespaced (``inbound:<event_id>``) and MUST
83
+ NOT trigger a session resume. We still persist an ``InboundEvent`` row so
84
+ the audit table records the sighting — every repeat of the same fingerprint
85
+ increments ``rejection_count`` so an operator can observe how often the
86
+ legacy path fires. The waker distinguishes ``"rejected_v1"`` from
87
+ ``"duplicate"`` and uses it to log a ``[wake:skip] v1 fingerprint rejected``
88
+ line rather than treating it as a cross-process race.
89
+ """
90
+ existing = db.scalar(
91
+ select(InboundEvent).where(InboundEvent.fingerprint == payload.fingerprint)
92
+ )
93
+ if existing is not None:
94
+ existing.rejection_count = (existing.rejection_count or 0) + 1
95
+ db.commit()
96
+ db.refresh(existing)
97
+ return existing, "rejected_v1"
98
+
99
+ event = InboundEvent(
100
+ agent_id=agent.id,
101
+ event_id=payload.event_id,
102
+ event_type=payload.event_type,
103
+ source=payload.source,
104
+ fingerprint=payload.fingerprint,
105
+ payload=payload.payload,
106
+ rejection_count=1,
107
+ )
108
+ db.add(event)
109
+ db.commit()
110
+ db.refresh(event)
111
+ return event, "rejected_v1"
@@ -0,0 +1,273 @@
1
+ """Per-project experiment execution lock service (CP-3 server side).
2
+
3
+ The lock is **soft**: a row-level lock stored on the ``experiments`` table
4
+ itself (``lock_holder_experiment_id`` + ``lock_acquired_at`` + ``lock_ttl_seconds``)
5
+ is the source of truth. Stale locks self-heal via ``lock_ttl_seconds``. The
6
+ CLI side (``cli/runtime/run_lock.py``) adds a best-effort ``fcntl.flock`` for
7
+ intra-host safety; the server side is authoritative.
8
+
9
+ Endpoints wired in :mod:`server.api.experiments`:
10
+
11
+ * ``POST /experiments/{id}/lock/acquire`` → :func:`acquire_experiment_lock`
12
+ * ``POST /experiments/{id}/lock/release`` → :func:`release_experiment_lock`
13
+ * ``POST /experiments/{id}/lock/force-release`` → :func:`force_release_experiment_lock`
14
+ * ``POST /experiments/{id}/lock/skip`` → :func:`record_experiment_lock_skip`
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import logging
20
+ import uuid
21
+ from dataclasses import dataclass
22
+ from datetime import UTC, datetime
23
+ from typing import Any
24
+
25
+ from sqlalchemy import select
26
+ from sqlalchemy.exc import IntegrityError
27
+ from sqlalchemy.orm import Session
28
+
29
+ from server.db.deadlock_retry import commit_with_retry
30
+ from server.domain.models import Agent, Experiment
31
+ from server.services.errors import ConflictError, ForbiddenError, NotFoundError
32
+
33
+ logger = logging.getLogger("map.experiment_lock")
34
+
35
+ DEFAULT_LOCK_TTL_SECONDS = 1800
36
+
37
+
38
+ @dataclass(frozen=True)
39
+ class LockResult:
40
+ """Compact representation of a successful lock operation.
41
+
42
+ Returned to the API caller and also persisted on the experiment row.
43
+ """
44
+
45
+ experiment_id: uuid.UUID
46
+ project_id: uuid.UUID
47
+ holder: uuid.UUID | None
48
+ acquired_at: datetime | None
49
+ ttl_seconds: int | None
50
+ next_attempt_at: datetime | None
51
+ skip_count: int
52
+
53
+
54
+ def _now() -> datetime:
55
+ return datetime.now(UTC)
56
+
57
+
58
+ def _is_expired(experiment: Experiment, *, now: datetime | None = None) -> bool:
59
+ if not experiment.lock_holder_experiment_id or not experiment.lock_acquired_at:
60
+ return True
61
+ reference = now or _now()
62
+ acquired = experiment.lock_acquired_at
63
+ if acquired.tzinfo is None:
64
+ acquired = acquired.replace(tzinfo=UTC)
65
+ ttl = experiment.lock_ttl_seconds or DEFAULT_LOCK_TTL_SECONDS
66
+ return reference >= acquired + __import__("datetime").timedelta(seconds=ttl)
67
+
68
+
69
+ def _to_result(experiment: Experiment) -> LockResult:
70
+ return LockResult(
71
+ experiment_id=experiment.id,
72
+ project_id=experiment.project_id,
73
+ holder=experiment.lock_holder_experiment_id,
74
+ acquired_at=experiment.lock_acquired_at,
75
+ ttl_seconds=experiment.lock_ttl_seconds,
76
+ next_attempt_at=experiment.next_attempt_at,
77
+ skip_count=int(experiment.lock_skip_count or 0),
78
+ )
79
+
80
+
81
+ def _get(db: Session, experiment_id: uuid.UUID) -> Experiment:
82
+ stmt = select(Experiment).where(Experiment.id == experiment_id)
83
+ experiment = db.scalar(stmt)
84
+ if experiment is None or experiment.deleted_at is not None:
85
+ raise NotFoundError(f"experiment {experiment_id} not found")
86
+ return experiment
87
+
88
+
89
+ def _ensure_can_modify_lock(actor: Agent, experiment: Experiment) -> None:
90
+ if experiment.creator_agent_id != actor.id and actor.role.value != "admin":
91
+ raise ForbiddenError("Only the creator or admin can manage the experiment lock")
92
+
93
+
94
+ def _find_project_holder(db: Session, project_id: uuid.UUID, *, exclude_id: uuid.UUID | None = None) -> Experiment | None:
95
+ """Find an experiment in ``project_id`` that currently holds the lock."""
96
+
97
+ stmt = (
98
+ select(Experiment)
99
+ .where(
100
+ Experiment.project_id == project_id,
101
+ Experiment.lock_holder_experiment_id.is_not(None),
102
+ Experiment.lock_acquired_at.is_not(None),
103
+ )
104
+ )
105
+ if exclude_id is not None:
106
+ stmt = stmt.where(Experiment.id != exclude_id)
107
+ return db.scalar(stmt)
108
+
109
+
110
+ def acquire_experiment_lock(
111
+ db: Session,
112
+ experiment_id: uuid.UUID,
113
+ actor: Agent,
114
+ *,
115
+ ttl_seconds: int = DEFAULT_LOCK_TTL_SECONDS,
116
+ ) -> LockResult:
117
+ """Acquire the per-project execution lock for ``experiment_id``.
118
+
119
+ Raises :class:`ConflictError` when another live experiment in the same
120
+ project holds the lock. A stale lock (TTL expired) is transparently
121
+ reclaimed.
122
+ """
123
+
124
+ experiment = _get(db, experiment_id)
125
+ _ensure_can_modify_lock(actor, experiment)
126
+
127
+ other = _find_project_holder(db, experiment.project_id, exclude_id=experiment_id)
128
+ if other is not None and other.lock_holder_experiment_id != experiment.id and not _is_expired(other):
129
+ raise ConflictError(
130
+ f"experiment {other.id} already holds the execution lock for project {experiment.project_id}"
131
+ )
132
+
133
+ experiment.lock_holder_experiment_id = experiment.id
134
+ experiment.lock_acquired_at = _now()
135
+ experiment.lock_ttl_seconds = int(ttl_seconds)
136
+ try:
137
+ commit_with_retry(db, op_label="acquire_experiment_lock")
138
+ except IntegrityError:
139
+ # Lost the race against another acquire that committed first; the
140
+ # ``uq_experiment_lock_holder_active`` partial unique index
141
+ # (PG-only; race experiment eca0f522 PR1) caught the conflict at
142
+ # commit time. Surface as ConflictError so callers see the same
143
+ # semantics as the in-process ``_find_project_holder`` guard.
144
+ db.rollback()
145
+ holder = _find_project_holder(db, experiment.project_id, exclude_id=experiment_id)
146
+ holder_id = holder.id if holder is not None else None
147
+ raise ConflictError(
148
+ f"experiment {holder_id} already holds the execution lock for project {experiment.project_id}"
149
+ ) from None
150
+ db.refresh(experiment)
151
+ logger.info(
152
+ "acquire_lock experiment=%s project=%s ttl=%s",
153
+ experiment.id,
154
+ experiment.project_id,
155
+ ttl_seconds,
156
+ )
157
+ return _to_result(experiment)
158
+
159
+
160
+ def release_experiment_lock(
161
+ db: Session,
162
+ experiment_id: uuid.UUID,
163
+ actor: Agent,
164
+ ) -> LockResult:
165
+ """Release the lock if ``experiment_id`` is the current holder."""
166
+
167
+ experiment = _get(db, experiment_id)
168
+ _ensure_can_modify_lock(actor, experiment)
169
+
170
+ if experiment.lock_holder_experiment_id == experiment.id:
171
+ experiment.lock_holder_experiment_id = None
172
+ experiment.lock_acquired_at = None
173
+ experiment.lock_ttl_seconds = None
174
+ commit_with_retry(db, op_label="release_experiment_lock")
175
+ db.refresh(experiment)
176
+ logger.info("release_lock experiment=%s", experiment.id)
177
+ else:
178
+ logger.info(
179
+ "release_lock noop experiment=%s current_holder=%s",
180
+ experiment.id,
181
+ experiment.lock_holder_experiment_id,
182
+ )
183
+ return _to_result(experiment)
184
+
185
+
186
+ def force_release_experiment_lock(
187
+ db: Session,
188
+ experiment_id: uuid.UUID,
189
+ actor: Agent,
190
+ *,
191
+ reason: str,
192
+ ) -> LockResult:
193
+ """Operator override: clear the lock regardless of holder.
194
+
195
+ Clears **all** lock holders on the project (only one should exist, but
196
+ defensive).
197
+ """
198
+
199
+ experiment = _get(db, experiment_id)
200
+ if actor.role.value != "admin" and experiment.creator_agent_id != actor.id:
201
+ raise ForbiddenError("Only the creator or admin can force-release a lock")
202
+
203
+ # Clear every holder on the project.
204
+ holders = (
205
+ db.scalars(
206
+ select(Experiment).where(
207
+ Experiment.project_id == experiment.project_id,
208
+ Experiment.lock_holder_experiment_id.is_not(None),
209
+ )
210
+ ).all()
211
+ )
212
+ previous_holders = [str(e.id) for e in holders if e.lock_holder_experiment_id is not None]
213
+ for holder in holders:
214
+ holder.lock_holder_experiment_id = None
215
+ holder.lock_acquired_at = None
216
+ holder.lock_ttl_seconds = None
217
+ commit_with_retry(db, op_label="force_release_experiment_lock")
218
+ logger.warning(
219
+ "force_release_lock actor=%s reason=%s project=%s previous_holders=%s",
220
+ actor.id,
221
+ reason,
222
+ experiment.project_id,
223
+ previous_holders,
224
+ )
225
+ db.refresh(experiment)
226
+ return _to_result(experiment)
227
+
228
+
229
+ def record_experiment_lock_skip(
230
+ db: Session,
231
+ experiment_id: uuid.UUID,
232
+ actor: Agent,
233
+ *,
234
+ next_attempt_at: datetime,
235
+ ) -> LockResult:
236
+ """Bump ``lock_skip_count`` and record ``next_attempt_at`` for closed-loop backoff."""
237
+
238
+ experiment = _get(db, experiment_id)
239
+ _ensure_can_modify_lock(actor, experiment)
240
+ experiment.lock_skip_count = int(experiment.lock_skip_count or 0) + 1
241
+ experiment.next_attempt_at = next_attempt_at
242
+ commit_with_retry(db, op_label="record_experiment_lock_skip")
243
+ db.refresh(experiment)
244
+ logger.info(
245
+ "lock_skip experiment=%s skip_count=%s next_attempt_at=%s",
246
+ experiment.id,
247
+ experiment.lock_skip_count,
248
+ next_attempt_at.isoformat(),
249
+ )
250
+ return _to_result(experiment)
251
+
252
+
253
+ def serialize_lock_state(experiment: Experiment) -> dict[str, Any]:
254
+ """Return a JSON-safe snapshot for inclusion in API responses / logs."""
255
+
256
+ return {
257
+ "lock_holder_experiment_id": str(experiment.lock_holder_experiment_id) if experiment.lock_holder_experiment_id else None,
258
+ "lock_acquired_at": experiment.lock_acquired_at.isoformat() if experiment.lock_acquired_at else None,
259
+ "lock_ttl_seconds": experiment.lock_ttl_seconds,
260
+ "next_attempt_at": experiment.next_attempt_at.isoformat() if experiment.next_attempt_at else None,
261
+ "lock_skip_count": int(experiment.lock_skip_count or 0),
262
+ }
263
+
264
+
265
+ __all__ = [
266
+ "DEFAULT_LOCK_TTL_SECONDS",
267
+ "LockResult",
268
+ "acquire_experiment_lock",
269
+ "force_release_experiment_lock",
270
+ "record_experiment_lock_skip",
271
+ "release_experiment_lock",
272
+ "serialize_lock_state",
273
+ ]
@@ -0,0 +1,202 @@
1
+ import uuid
2
+
3
+ from sqlalchemy import func, select
4
+ from sqlalchemy.orm import Session
5
+
6
+ from server.domain.models import (
7
+ Agent,
8
+ ExperimentLog,
9
+ ExperimentPhase,
10
+ PlanVersion,
11
+ )
12
+ from server.domain.schemas import ExperimentLogCreate
13
+ from server.services import audit_service
14
+ from server.services.errors import StateTransitionError
15
+ from server.services.evidence_service import (
16
+ EvidenceValidationResult,
17
+ validate_log_evidence,
18
+ )
19
+ from server.services.project_service import get_experiment
20
+ from server.services.similarity_service import (
21
+ SimilarityValidationResult,
22
+ validate_log_similarity,
23
+ )
24
+
25
+
26
+ def _validate_log_phase(phase: ExperimentPhase) -> None:
27
+ if phase not in (
28
+ ExperimentPhase.running,
29
+ ExperimentPhase.result_review,
30
+ ExperimentPhase.done,
31
+ ):
32
+ raise StateTransitionError(
33
+ "Logs can only be added when experiment is running, pending result review, or done"
34
+ )
35
+
36
+
37
+ def _load_current_plan_md(db: Session, experiment_id: uuid.UUID) -> str | None:
38
+ """Return ``current_plan.content_md`` for the experiment, or None when
39
+ the experiment has no committed plan version (current_plan_version == 0
40
+ or the row is missing).
41
+ """
42
+ experiment = get_experiment(db, experiment_id)
43
+ if experiment.current_plan_version <= 0:
44
+ return None
45
+ plan = db.scalar(
46
+ select(PlanVersion).where(
47
+ PlanVersion.experiment_id == experiment_id,
48
+ PlanVersion.version == experiment.current_plan_version,
49
+ )
50
+ )
51
+ return plan.content_md if plan is not None else None
52
+
53
+
54
+ def append_log(
55
+ db: Session,
56
+ experiment_id: uuid.UUID,
57
+ author: Agent,
58
+ payload: ExperimentLogCreate,
59
+ ) -> tuple[ExperimentLog, EvidenceValidationResult, SimilarityValidationResult, bool]:
60
+ experiment = get_experiment(db, experiment_id)
61
+ _validate_log_phase(experiment.phase)
62
+ next_index = db.scalar(
63
+ select(func.coalesce(func.max(ExperimentLog.log_index), 0) + 1).where(
64
+ ExperimentLog.experiment_id == experiment_id
65
+ )
66
+ )
67
+ log = ExperimentLog(
68
+ experiment_id=experiment_id,
69
+ author_agent_id=author.id,
70
+ summary=payload.summary,
71
+ content_md=payload.content_md,
72
+ metadata_json=payload.metadata,
73
+ log_index=next_index or 1,
74
+ )
75
+ plan_md = _load_current_plan_md(db, experiment_id)
76
+ validation = validate_log_evidence(plan_md=plan_md, metadata=payload.metadata)
77
+ # Compute similarity BEFORE flushing the new log so the comparison
78
+ # sees the prior log, not the log we're about to save (which would
79
+ # trivially score 1.0 against itself).
80
+ similarity = validate_log_similarity(
81
+ db,
82
+ experiment_id=experiment_id,
83
+ content_md=payload.content_md,
84
+ )
85
+ db.add(log)
86
+ db.flush()
87
+ force_skip_applied = False
88
+ if similarity.warnings and payload.force_skip_similarity:
89
+ warning = similarity.warnings[0]
90
+ audit_service.log_force_skip_no_commit(
91
+ db,
92
+ log_id=log.id,
93
+ ref_log_id=warning.ref_log_id,
94
+ experiment_id=experiment_id,
95
+ project_id=experiment.project_id,
96
+ actor_id=author.id,
97
+ similarity_score=warning.score,
98
+ threshold=warning.threshold,
99
+ embedding_model=warning.model,
100
+ )
101
+ force_skip_applied = True
102
+ return log, validation, similarity, force_skip_applied
103
+
104
+
105
+ def create_log(
106
+ db: Session,
107
+ experiment_id: uuid.UUID,
108
+ author: Agent,
109
+ payload: ExperimentLogCreate,
110
+ ) -> tuple[
111
+ ExperimentLog,
112
+ EvidenceValidationResult,
113
+ SimilarityValidationResult,
114
+ bool,
115
+ ]:
116
+ log, validation, similarity, force_skip_applied = append_log(
117
+ db, experiment_id, author, payload
118
+ )
119
+ db.commit()
120
+ db.refresh(log)
121
+ return log, validation, similarity, force_skip_applied
122
+
123
+
124
+ def list_logs(
125
+ db: Session,
126
+ experiment_id: uuid.UUID,
127
+ *,
128
+ limit: int = 50,
129
+ ) -> list[ExperimentLog]:
130
+ get_experiment(db, experiment_id)
131
+ stmt = (
132
+ select(ExperimentLog)
133
+ .where(ExperimentLog.experiment_id == experiment_id)
134
+ .order_by(ExperimentLog.log_index.asc())
135
+ .limit(max(1, min(limit, 200)))
136
+ )
137
+ return list(db.scalars(stmt))
138
+
139
+
140
+ def get_latest_log(db: Session, experiment_id: uuid.UUID) -> ExperimentLog | None:
141
+ stmt = (
142
+ select(ExperimentLog)
143
+ .where(ExperimentLog.experiment_id == experiment_id)
144
+ .order_by(ExperimentLog.log_index.desc())
145
+ .limit(1)
146
+ )
147
+ return db.scalar(stmt)
148
+
149
+
150
+ def log_counts_by_experiment(
151
+ db: Session, experiment_ids: list[uuid.UUID]
152
+ ) -> dict[uuid.UUID, int]:
153
+ """Per-experiment ExperimentLog row counts in a single GROUP BY query.
154
+
155
+ Returns a dict keyed by experiment_id. Experiments with no logs map
156
+ to 0 (caller can safely ``counts[eid]`` without ``.get``). Empty
157
+ input → empty dict without hitting the database.
158
+ """
159
+ if not experiment_ids:
160
+ return {}
161
+ stmt = (
162
+ select(ExperimentLog.experiment_id, func.count())
163
+ .where(ExperimentLog.experiment_id.in_(experiment_ids))
164
+ .group_by(ExperimentLog.experiment_id)
165
+ )
166
+ found = {eid: int(count) for eid, count in db.execute(stmt).all()}
167
+ # Fill in zeros for experiments that have no logs so the caller can
168
+ # index by experiment_id without a defensive ``.get``/``default``.
169
+ return {eid: found.get(eid, 0) for eid in experiment_ids}
170
+
171
+
172
+ def latest_log_by_experiment(
173
+ db: Session, experiment_ids: list[uuid.UUID]
174
+ ) -> dict[uuid.UUID, ExperimentLog]:
175
+ """Latest ExperimentLog per experiment in a single window/join query.
176
+
177
+ Returns a dict keyed by experiment_id. Experiments with no logs are
178
+ absent from the dict (``latest.get(eid) is None``). Empty input →
179
+ empty dict without hitting the database.
180
+ """
181
+ if not experiment_ids:
182
+ return {}
183
+ # Max + self-join pattern. (experiment_id, log_index) is not UNIQUE
184
+ # in the schema, but ``append_log`` always allocates via max+1, so
185
+ # in practice the max row is unique. GROUP BY gives one row per
186
+ # experiment with the top log_index; the join back returns the
187
+ # full ExperimentLog rows.
188
+ max_idx_subq = (
189
+ select(
190
+ ExperimentLog.experiment_id.label("eid"),
191
+ func.max(ExperimentLog.log_index).label("max_idx"),
192
+ )
193
+ .where(ExperimentLog.experiment_id.in_(experiment_ids))
194
+ .group_by(ExperimentLog.experiment_id)
195
+ .subquery()
196
+ )
197
+ stmt = select(ExperimentLog).join(
198
+ max_idx_subq,
199
+ (ExperimentLog.experiment_id == max_idx_subq.c.eid)
200
+ & (ExperimentLog.log_index == max_idx_subq.c.max_idx),
201
+ )
202
+ return {log.experiment_id: log for log in db.scalars(stmt)}