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,713 @@
1
+ import uuid
2
+ from datetime import datetime
3
+
4
+ from map_types.enums import (
5
+ AgentRole,
6
+ CommentAnchorType,
7
+ ExperimentPhase,
8
+ FeedbackCategory,
9
+ FeedbackStatus,
10
+ InboundEventSource,
11
+ MentionSourceType,
12
+ NotificationCategory,
13
+ NotificationFingerprintVersion,
14
+ PhaseOwner,
15
+ ResolutionReason,
16
+ ReviewArchivedReason,
17
+ ReviewItemKind,
18
+ ReviewItemStatus,
19
+ ReviewSubstituteKind,
20
+ TopicActionItemStatus,
21
+ TopicCommentKind,
22
+ TopicDiscussionRound,
23
+ TopicStatus,
24
+ )
25
+ from sqlalchemy import (
26
+ JSON,
27
+ Boolean,
28
+ DateTime,
29
+ Enum,
30
+ ForeignKey,
31
+ Index,
32
+ Integer,
33
+ String,
34
+ Text,
35
+ UniqueConstraint,
36
+ func,
37
+ text,
38
+ )
39
+ from sqlalchemy.orm import Mapped, mapped_column, relationship, validates
40
+
41
+ from server.db.base import Base
42
+ from server.domain.encrypted_types import EncryptedString
43
+
44
+
45
+ class Project(Base):
46
+ __tablename__ = "projects"
47
+
48
+ id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
49
+ project_key: Mapped[str] = mapped_column(String(64), nullable=False, unique=True, index=True)
50
+ name: Mapped[str] = mapped_column(String(255), nullable=False)
51
+ workspace_path: Mapped[str] = mapped_column(String(1024), nullable=False)
52
+ description: Mapped[str | None] = mapped_column(Text, nullable=True)
53
+ current_status_version: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
54
+ created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
55
+ archived_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
56
+
57
+ experiments: Mapped[list["Experiment"]] = relationship(back_populates="project")
58
+ status_versions: Mapped[list["ProjectStatusVersion"]] = relationship(
59
+ back_populates="project", order_by="ProjectStatusVersion.version"
60
+ )
61
+ topics: Mapped[list["Topic"]] = relationship(back_populates="project")
62
+ topic_decisions: Mapped[list["TopicDecision"]] = relationship(back_populates="project")
63
+
64
+
65
+ class ProjectStatusVersion(Base):
66
+ __tablename__ = "project_status_versions"
67
+ __table_args__ = (UniqueConstraint("project_id", "version", name="uq_project_status_version"),)
68
+
69
+ id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
70
+ project_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("projects.id"), nullable=False, index=True)
71
+ version: Mapped[int] = mapped_column(Integer, nullable=False)
72
+ content_md: Mapped[str] = mapped_column(Text, nullable=False)
73
+ author_agent_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("agents.id"), nullable=False)
74
+ change_note: Mapped[str | None] = mapped_column(String(1024), nullable=True)
75
+ created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
76
+
77
+ project: Mapped["Project"] = relationship(back_populates="status_versions")
78
+ author: Mapped["Agent"] = relationship()
79
+
80
+
81
+ class Agent(Base):
82
+ __tablename__ = "agents"
83
+
84
+ id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
85
+ name: Mapped[str] = mapped_column(String(255), nullable=False, unique=True)
86
+ api_token_hash: Mapped[str] = mapped_column(String(255), nullable=False)
87
+ api_token_prefix: Mapped[str] = mapped_column(String(8), nullable=False, default="", index=True)
88
+ role: Mapped[AgentRole] = mapped_column(Enum(AgentRole), default=AgentRole.agent, nullable=False)
89
+ project_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("projects.id"), nullable=True, index=True)
90
+ created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
91
+
92
+ project: Mapped["Project | None"] = relationship()
93
+
94
+ # ---- Capability model --------------------------------------------------
95
+ # authz experiment (0e6926fa) PR2: capability strings decouple
96
+ # ``system:*`` gating from the AgentRole enum so future capabilities
97
+ # don't churn the schema. Convention: ``<namespace>:<action>``.
98
+ # ``system:*`` is admin-grade (audit / cross-project). Persona-scoped
99
+ # capabilities (e.g. ``host:scan_stalled``) match the agent's name
100
+ # suffix (``multi-agent-platform-host`` → ``host:*``).
101
+ _ADMIN_CAPABILITY_PREFIX = "system:"
102
+ _PERSONA_CAPABILITIES: dict[str, frozenset[str]] = {
103
+ "host": frozenset(
104
+ {
105
+ "system:scan_stalled",
106
+ "system:audit_export",
107
+ "system:cross_persona_call",
108
+ }
109
+ ),
110
+ "reviewer": frozenset({"review:submit", "review:accept_result"}),
111
+ "participant": frozenset({"topic:comment"}),
112
+ }
113
+ _PERSONA_NAME_PREFIX = "multi-agent-platform-"
114
+
115
+ @property
116
+ def persona(self) -> str | None:
117
+ """Extract persona suffix from ``multi-agent-platform-<persona>``.
118
+
119
+ Returns ``None`` for admin-only agents or non-conforming names so
120
+ the capability table falls through to admin-only access.
121
+ """
122
+ if not self.name.startswith(self._PERSONA_NAME_PREFIX):
123
+ return None
124
+ suffix = self.name[len(self._PERSONA_NAME_PREFIX):]
125
+ return suffix if suffix in self._PERSONA_CAPABILITIES else None
126
+
127
+ def has_capability(self, capability: str) -> bool:
128
+ """Return True if this agent can perform ``capability``.
129
+
130
+ Order:
131
+ 1. ``role == admin`` → any ``system:*`` capability passes.
132
+ 2. Persona match → look up the persona's capability set.
133
+ 3. Otherwise → False.
134
+ """
135
+ if self.role == AgentRole.admin and capability.startswith(self._ADMIN_CAPABILITY_PREFIX):
136
+ return True
137
+ persona = self.persona
138
+ if persona is not None:
139
+ return capability in self._PERSONA_CAPABILITIES[persona]
140
+ return False
141
+ created_experiments: Mapped[list["Experiment"]] = relationship(
142
+ back_populates="creator", foreign_keys="Experiment.creator_agent_id"
143
+ )
144
+ escalation_target_experiments: Mapped[list["Experiment"]] = relationship(
145
+ back_populates="escalation_target", foreign_keys="Experiment.escalation_target_agent_id"
146
+ )
147
+ plan_versions: Mapped[list["PlanVersion"]] = relationship(back_populates="author")
148
+ reviews: Mapped[list["Review"]] = relationship(back_populates="reviewer")
149
+ comments: Mapped[list["Comment"]] = relationship(back_populates="author")
150
+ logs: Mapped[list["ExperimentLog"]] = relationship(back_populates="author")
151
+
152
+
153
+ _ACTIVE_TOPIC_EXPERIMENT_PHASES_SQL = "('draft','review','approved','running','result_review')"
154
+ _ACTIVE_TOPIC_EXPERIMENT_INDEX_WHERE = (
155
+ f"topic_id IS NOT NULL AND deleted_at IS NULL AND archived_at IS NULL "
156
+ f"AND phase IN {_ACTIVE_TOPIC_EXPERIMENT_PHASES_SQL}"
157
+ )
158
+
159
+
160
+ class Experiment(Base):
161
+ __tablename__ = "experiments"
162
+ __table_args__ = (
163
+ Index(
164
+ "uq_experiment_one_active_per_topic",
165
+ "topic_id",
166
+ unique=True,
167
+ sqlite_where=text(_ACTIVE_TOPIC_EXPERIMENT_INDEX_WHERE),
168
+ postgresql_where=text(_ACTIVE_TOPIC_EXPERIMENT_INDEX_WHERE),
169
+ ),
170
+ # race experiment (eca0f522) PR1: enforce "at most one active
171
+ # holder" at the DB layer so the soft lock acquire race becomes
172
+ # an IntegrityError instead of producing two live holders.
173
+ # Both PG and SQLite get the partial WHERE clause (mirrors the
174
+ # ``uq_experiment_one_active_per_topic`` style above); the
175
+ # alembic migration 036 also adds the PG-side partial index for
176
+ # databases that came up before the model change.
177
+ Index(
178
+ "uq_experiment_lock_holder_active",
179
+ "lock_holder_experiment_id",
180
+ unique=True,
181
+ sqlite_where=text("lock_holder_experiment_id IS NOT NULL"),
182
+ postgresql_where=text("lock_holder_experiment_id IS NOT NULL"),
183
+ ),
184
+ )
185
+
186
+ id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
187
+ project_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("projects.id"), nullable=False, index=True)
188
+ creator_agent_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("agents.id"), nullable=False)
189
+ title: Mapped[str] = mapped_column(String(512), nullable=False)
190
+ description: Mapped[str | None] = mapped_column(Text, nullable=True)
191
+ phase: Mapped[ExperimentPhase] = mapped_column(
192
+ Enum(ExperimentPhase), default=ExperimentPhase.draft, nullable=False, index=True
193
+ )
194
+ current_plan_version: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
195
+ created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
196
+ updated_at: Mapped[datetime] = mapped_column(
197
+ DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
198
+ )
199
+ deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
200
+ archived_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
201
+
202
+ topic_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("topics.id"), nullable=True, index=True)
203
+ project: Mapped["Project"] = relationship(back_populates="experiments")
204
+ creator: Mapped["Agent"] = relationship(back_populates="created_experiments", foreign_keys=[creator_agent_id])
205
+ topic: Mapped["Topic | None"] = relationship(back_populates="experiments")
206
+ plan_versions: Mapped[list["PlanVersion"]] = relationship(
207
+ back_populates="experiment", order_by="PlanVersion.version"
208
+ )
209
+ reviews: Mapped[list["Review"]] = relationship(back_populates="experiment", order_by="Review.created_at")
210
+ comments: Mapped[list["Comment"]] = relationship(back_populates="experiment", order_by="Comment.created_at")
211
+ logs: Mapped[list["ExperimentLog"]] = relationship(back_populates="experiment", order_by="ExperimentLog.created_at")
212
+
213
+ # --- phase_owner routing (experiment f873c287 I1(b)) -----------------
214
+ # Decision-owner role for the *current* phase. Drives:
215
+ # - ``informational_only`` auto-classification (I1(a))
216
+ # - "host blocked, waiting on {phase_owner}" UI copy (I1(d))
217
+ # - ``experiments_needing_attention`` persona filter (I1(e))
218
+ # Stored as a string column (16 chars) — kept aligned with the
219
+ # ``PhaseOwner`` enum via ``phase_owner_resolver``. Defaulted by app
220
+ # logic when ``Experiment.phase`` changes (see ``phase_service``).
221
+ phase_owner: Mapped[str] = mapped_column(
222
+ String(16), nullable=False, server_default="host", index=True
223
+ )
224
+
225
+ @validates("phase_owner")
226
+ def _validate_phase_owner(self, _key: str, value: object) -> str:
227
+ """ORM-side guard: ``phase_owner`` must be a valid ``PhaseOwner``.
228
+
229
+ Migration 035 deliberately stores ``phase_owner`` as a plain
230
+ ``String(16)`` (no DB-level CHECK / enum) so the resolver + enum
231
+ remain the single source of truth. The trade-off is that any
232
+ code path that bypasses ``phase_service._sync_phase_owner`` could
233
+ silently write a garbage string. This validator catches such
234
+ drift at the ORM boundary (BEFORE the SQL flush) and raises
235
+ ``ValueError`` so the bad value never reaches the DB.
236
+
237
+ Allowed values come from :class:`map_types.enums.PhaseOwner` —
238
+ the same enum the resolver / UI rely on.
239
+ """
240
+ if isinstance(value, PhaseOwner):
241
+ return value.value
242
+ if isinstance(value, str):
243
+ try:
244
+ return PhaseOwner(value).value
245
+ except ValueError:
246
+ pass
247
+ raise ValueError(
248
+ f"Experiment.phase_owner must be a PhaseOwner member "
249
+ f"(host / reviewer / participant / admin); got {value!r}"
250
+ )
251
+
252
+ # --- execution lock (per-project; CP-3) ---------------------------------
253
+ lock_holder_experiment_id: Mapped[uuid.UUID | None] = mapped_column(nullable=True, index=True)
254
+ lock_acquired_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
255
+ lock_ttl_seconds: Mapped[int | None] = mapped_column(Integer, nullable=True)
256
+ next_attempt_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
257
+ lock_skip_count: Mapped[int] = mapped_column(Integer, nullable=False, server_default="0")
258
+
259
+ # --- escalation routing (experiment 156172e9 I1(b)) ---------------------
260
+ # Optional override for STATE_MACHINE.* error escalation. When set, the
261
+ # CLI surfaces this agent as the recovery contact regardless of the
262
+ # caller's role. When NULL, the role-based fallback rule applies:
263
+ # current caller → same-role active agent → admin.
264
+ escalation_target_agent_id: Mapped[uuid.UUID | None] = mapped_column(
265
+ ForeignKey("agents.id"), nullable=True, index=True
266
+ )
267
+ escalation_target: Mapped["Agent | None"] = relationship(
268
+ back_populates="escalation_target_experiments",
269
+ foreign_keys=[escalation_target_agent_id],
270
+ )
271
+
272
+
273
+ class Topic(Base):
274
+ __tablename__ = "topics"
275
+
276
+ id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
277
+ project_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("projects.id"), nullable=False, index=True)
278
+ creator_agent_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("agents.id"), nullable=False)
279
+ title: Mapped[str] = mapped_column(String(512), nullable=False)
280
+ description: Mapped[str | None] = mapped_column(Text, nullable=True)
281
+ status: Mapped[TopicStatus] = mapped_column(Enum(TopicStatus), default=TopicStatus.open, nullable=False, index=True)
282
+ pinned: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False, index=True)
283
+ discussion_round: Mapped[TopicDiscussionRound] = mapped_column(
284
+ Enum(TopicDiscussionRound),
285
+ default=TopicDiscussionRound.round1,
286
+ nullable=False,
287
+ index=True,
288
+ )
289
+ round_summary_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
290
+ created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
291
+ updated_at: Mapped[datetime] = mapped_column(
292
+ DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
293
+ )
294
+ deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
295
+ archived_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
296
+
297
+ project: Mapped["Project"] = relationship(back_populates="topics")
298
+ creator: Mapped["Agent"] = relationship(foreign_keys=[creator_agent_id])
299
+ comments: Mapped[list["TopicComment"]] = relationship(
300
+ back_populates="topic", order_by="TopicComment.created_at"
301
+ )
302
+ experiments: Mapped[list["Experiment"]] = relationship(back_populates="topic")
303
+ decision: Mapped["TopicDecision | None"] = relationship(back_populates="topic", uselist=False)
304
+ dismissed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
305
+ dismissed_by_agent_id: Mapped[uuid.UUID | None] = mapped_column(
306
+ ForeignKey("agents.id"), nullable=True
307
+ )
308
+ advance_round_pending_since: Mapped[datetime | None] = mapped_column(
309
+ DateTime(timezone=True), nullable=True
310
+ )
311
+
312
+
313
+ class TopicComment(Base):
314
+ __tablename__ = "topic_comments"
315
+
316
+ id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
317
+ topic_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("topics.id"), nullable=False, index=True)
318
+ author_agent_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("agents.id"), nullable=False)
319
+ parent_comment_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("topic_comments.id"), nullable=True)
320
+ body: Mapped[str] = mapped_column(Text, nullable=False)
321
+ kind: Mapped[TopicCommentKind] = mapped_column(
322
+ Enum(TopicCommentKind), default=TopicCommentKind.user, nullable=False
323
+ )
324
+ comment_seq: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
325
+ created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
326
+
327
+ topic: Mapped["Topic"] = relationship(back_populates="comments")
328
+ author: Mapped["Agent"] = relationship()
329
+ parent: Mapped["TopicComment | None"] = relationship(remote_side="TopicComment.id")
330
+
331
+
332
+ class TopicReadCursor(Base):
333
+ __tablename__ = "topic_read_cursors"
334
+ __table_args__ = (UniqueConstraint("topic_id", "agent_id", name="uq_topic_read_cursor_topic_agent"),)
335
+
336
+ id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
337
+ topic_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("topics.id"), nullable=False, index=True)
338
+ agent_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("agents.id"), nullable=False, index=True)
339
+ last_read_comment_seq: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
340
+ updated_at: Mapped[datetime] = mapped_column(
341
+ DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
342
+ )
343
+
344
+
345
+ class TopicDecision(Base):
346
+ __tablename__ = "topic_decisions"
347
+ __table_args__ = (UniqueConstraint("topic_id", name="uq_topic_decision_topic_id"),)
348
+
349
+ id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
350
+ project_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("projects.id"), nullable=False, index=True)
351
+ topic_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("topics.id"), nullable=False, index=True)
352
+ author_agent_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("agents.id"), nullable=False)
353
+ decision: Mapped[str | None] = mapped_column(Text, nullable=True)
354
+ rationale: Mapped[str | None] = mapped_column(Text, nullable=True)
355
+ rejected_options: Mapped[str | None] = mapped_column(Text, nullable=True)
356
+ open_questions: Mapped[str | None] = mapped_column(Text, nullable=True)
357
+ no_decision_reason: Mapped[str | None] = mapped_column(Text, nullable=True)
358
+ created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
359
+ updated_at: Mapped[datetime] = mapped_column(
360
+ DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
361
+ )
362
+
363
+ project: Mapped["Project"] = relationship(back_populates="topic_decisions")
364
+ topic: Mapped["Topic"] = relationship(back_populates="decision")
365
+ author: Mapped["Agent"] = relationship()
366
+ action_items: Mapped[list["TopicActionItem"]] = relationship(
367
+ back_populates="decision_record",
368
+ order_by="TopicActionItem.created_at",
369
+ cascade="all, delete-orphan",
370
+ )
371
+
372
+
373
+ class TopicActionItem(Base):
374
+ __tablename__ = "topic_action_items"
375
+
376
+ id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
377
+ decision_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("topic_decisions.id"), nullable=False, index=True)
378
+ project_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("projects.id"), nullable=False, index=True)
379
+ topic_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("topics.id"), nullable=False, index=True)
380
+ title: Mapped[str] = mapped_column(String(512), nullable=False)
381
+ description: Mapped[str | None] = mapped_column(Text, nullable=True)
382
+ owner_agent_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("agents.id"), nullable=True, index=True)
383
+ status: Mapped[TopicActionItemStatus] = mapped_column(
384
+ Enum(TopicActionItemStatus), default=TopicActionItemStatus.open, nullable=False, index=True
385
+ )
386
+ due_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
387
+ linked_experiment_id: Mapped[uuid.UUID | None] = mapped_column(
388
+ ForeignKey("experiments.id"), nullable=True, index=True
389
+ )
390
+ category: Mapped[str | None] = mapped_column(String(32), nullable=True)
391
+ cancel_reason: Mapped[str | None] = mapped_column(Text, nullable=True)
392
+ wake_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default=text("0"))
393
+ last_woken_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
394
+ first_open_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
395
+ stale_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
396
+ created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
397
+ updated_at: Mapped[datetime] = mapped_column(
398
+ DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
399
+ )
400
+
401
+ decision_record: Mapped["TopicDecision"] = relationship(back_populates="action_items")
402
+ project: Mapped["Project"] = relationship()
403
+ topic: Mapped["Topic"] = relationship()
404
+ owner: Mapped["Agent | None"] = relationship(foreign_keys=[owner_agent_id])
405
+ linked_experiment: Mapped["Experiment | None"] = relationship(foreign_keys=[linked_experiment_id])
406
+
407
+
408
+ class PlanVersion(Base):
409
+ __tablename__ = "plan_versions"
410
+ __table_args__ = (UniqueConstraint("experiment_id", "version", name="uq_plan_experiment_version"),)
411
+
412
+ id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
413
+ experiment_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("experiments.id"), nullable=False, index=True)
414
+ version: Mapped[int] = mapped_column(Integer, nullable=False)
415
+ content_md: Mapped[str] = mapped_column(Text, nullable=False)
416
+ author_agent_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("agents.id"), nullable=False)
417
+ change_note: Mapped[str | None] = mapped_column(String(1024), nullable=True)
418
+ created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
419
+
420
+ experiment: Mapped["Experiment"] = relationship(back_populates="plan_versions")
421
+ author: Mapped["Agent"] = relationship(back_populates="plan_versions")
422
+
423
+
424
+ class Review(Base):
425
+ __tablename__ = "reviews"
426
+ __table_args__ = (
427
+ UniqueConstraint("experiment_id", "reviewer_agent_id", "plan_version", name="uq_review_per_version"),
428
+ )
429
+
430
+ id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
431
+ experiment_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("experiments.id"), nullable=False, index=True)
432
+ reviewer_agent_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("agents.id"), nullable=False)
433
+ plan_version: Mapped[int] = mapped_column(Integer, nullable=False)
434
+ substitute_kind: Mapped[ReviewSubstituteKind] = mapped_column(
435
+ Enum(ReviewSubstituteKind),
436
+ nullable=False,
437
+ default=ReviewSubstituteKind.none,
438
+ server_default="none",
439
+ )
440
+ created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
441
+ # Archive metadata (experiment 18f1d8f6 I1(a)). Both columns are NULL
442
+ # for active reviews; ``archived_at`` is set the moment a row becomes
443
+ # archived. ``archived_reason`` is also used as a historical backfill
444
+ # marker for rows that predate the archive feature (migration 034
445
+ # sets ``archived_reason='auto'`` with ``archived_at=NULL`` to keep
446
+ # the rows visible during the N=2 transition).
447
+ archived_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
448
+ archived_reason: Mapped[ReviewArchivedReason | None] = mapped_column(
449
+ Enum(ReviewArchivedReason),
450
+ nullable=True,
451
+ )
452
+
453
+ experiment: Mapped["Experiment"] = relationship(back_populates="reviews")
454
+ reviewer: Mapped["Agent"] = relationship(back_populates="reviews")
455
+ items: Mapped[list["ReviewItem"]] = relationship(back_populates="review", order_by="ReviewItem.created_at")
456
+
457
+
458
+ class ReviewItem(Base):
459
+ __tablename__ = "review_items"
460
+
461
+ id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
462
+ review_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("reviews.id"), nullable=False, index=True)
463
+ kind: Mapped[ReviewItemKind] = mapped_column(Enum(ReviewItemKind), nullable=False)
464
+ content: Mapped[str] = mapped_column(Text, nullable=False)
465
+ status: Mapped[ReviewItemStatus | None] = mapped_column(Enum(ReviewItemStatus), nullable=True)
466
+ last_resolution_reason: Mapped["ResolutionReason | None"] = mapped_column(
467
+ Enum(ResolutionReason), nullable=True
468
+ )
469
+ created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
470
+ updated_at: Mapped[datetime] = mapped_column(
471
+ DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
472
+ )
473
+
474
+ review: Mapped["Review"] = relationship(back_populates="items")
475
+
476
+
477
+ class Comment(Base):
478
+ __tablename__ = "comments"
479
+
480
+ id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
481
+ experiment_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("experiments.id"), nullable=False, index=True)
482
+ anchor_type: Mapped[CommentAnchorType] = mapped_column(Enum(CommentAnchorType), nullable=False)
483
+ anchor_id: Mapped[uuid.UUID] = mapped_column(nullable=False)
484
+ parent_comment_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("comments.id"), nullable=True)
485
+ author_agent_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("agents.id"), nullable=False)
486
+ body: Mapped[str] = mapped_column(Text, nullable=False)
487
+ created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
488
+
489
+ experiment: Mapped["Experiment"] = relationship(back_populates="comments")
490
+ author: Mapped["Agent"] = relationship(back_populates="comments")
491
+ parent: Mapped["Comment | None"] = relationship(remote_side="Comment.id")
492
+
493
+
494
+ class ExperimentLog(Base):
495
+ __tablename__ = "experiment_logs"
496
+
497
+ id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
498
+ experiment_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("experiments.id"), nullable=False, index=True)
499
+ author_agent_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("agents.id"), nullable=False)
500
+ summary: Mapped[str] = mapped_column(String(1024), nullable=False)
501
+ content_md: Mapped[str] = mapped_column(Text, nullable=False)
502
+ metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
503
+ log_index: Mapped[int] = mapped_column(Integer, nullable=False)
504
+ created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
505
+
506
+ experiment: Mapped["Experiment"] = relationship(back_populates="logs")
507
+ author: Mapped["Agent"] = relationship(back_populates="logs")
508
+
509
+
510
+ class Webhook(Base):
511
+ __tablename__ = "webhooks"
512
+
513
+ id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
514
+ project_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("projects.id"), nullable=True, index=True)
515
+ url: Mapped[str] = mapped_column(String(2048), nullable=False)
516
+ # c9281d86 PR3: store as Fernet ciphertext at rest. Application code
517
+ # still reads / writes plaintext via the TypeDecorator — no API or
518
+ # service-layer change required. Migration 037 backfills existing
519
+ # plaintext rows in-place.
520
+ secret: Mapped[str] = mapped_column(EncryptedString(1024), nullable=False)
521
+ events: Mapped[list] = mapped_column(JSON, nullable=False, default=list)
522
+ active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
523
+ created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
524
+ updated_at: Mapped[datetime] = mapped_column(
525
+ DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
526
+ )
527
+
528
+ deliveries: Mapped[list["WebhookDelivery"]] = relationship(back_populates="webhook")
529
+
530
+
531
+ class WebhookDelivery(Base):
532
+ __tablename__ = "webhook_deliveries"
533
+
534
+ id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
535
+ webhook_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("webhooks.id"), nullable=False, index=True)
536
+ event: Mapped[str] = mapped_column(String(128), nullable=False)
537
+ payload: Mapped[dict] = mapped_column(JSON, nullable=False)
538
+ status_code: Mapped[int | None] = mapped_column(Integer, nullable=True)
539
+ attempts: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
540
+ success: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
541
+ last_attempt_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
542
+ last_error: Mapped[str | None] = mapped_column(Text, nullable=True)
543
+ created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
544
+
545
+ webhook: Mapped["Webhook"] = relationship(back_populates="deliveries")
546
+
547
+
548
+ class AuditLog(Base):
549
+ __tablename__ = "audit_logs"
550
+
551
+ id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
552
+ agent_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("agents.id"), nullable=True, index=True)
553
+ project_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("projects.id"), nullable=True, index=True)
554
+ action: Mapped[str] = mapped_column(String(128), nullable=False, index=True)
555
+ target_type: Mapped[str] = mapped_column(String(64), nullable=False)
556
+ target_id: Mapped[uuid.UUID | None] = mapped_column(nullable=True)
557
+ summary: Mapped[str | None] = mapped_column(String(1024), nullable=True)
558
+ payload_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
559
+ created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
560
+
561
+ __table_args__ = (
562
+ # query_by_target: WHERE target_type=? AND target_id=? ORDER BY created_at DESC
563
+ # 复合索引左前缀覆盖现有 target_id 单列查询,避免重复索引。
564
+ Index("ix_audit_logs_target", "target_type", "target_id"),
565
+ # query_all: ORDER BY created_at DESC LIMIT/OFFSET 分页
566
+ Index("ix_audit_logs_created_at", "created_at"),
567
+ )
568
+
569
+
570
+ class Notification(Base):
571
+ __tablename__ = "notifications"
572
+ __table_args__ = (
573
+ # race experiment (eca0f522) PR2: enforce "at most one notification
574
+ # per (recipient, group_key)" at the DB layer so the upsert race
575
+ # becomes an ``IntegrityError``-or-``ON CONFLICT`` merge instead of
576
+ # producing duplicate rows when concurrent event sources share the
577
+ # same group_key. group_key is nullable — both PG and SQLite allow
578
+ # multiple NULLs in a unique constraint, so the constraint only
579
+ # fires for non-NULL group_keys (matches the existing partial-index
580
+ # convention used by ``uq_experiment_one_active_per_topic``).
581
+ UniqueConstraint("recipient_agent_id", "group_key", name="uq_notifications_recipient_group_key"),
582
+ )
583
+
584
+ id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
585
+ recipient_agent_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("agents.id"), nullable=False, index=True)
586
+ project_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("projects.id"), nullable=True, index=True)
587
+ event: Mapped[str] = mapped_column(String(128), nullable=False)
588
+ summary: Mapped[str] = mapped_column(String(1024), nullable=False)
589
+ target_type: Mapped[str] = mapped_column(String(64), nullable=False)
590
+ target_id: Mapped[uuid.UUID | None] = mapped_column(nullable=True)
591
+ payload_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
592
+ category: Mapped[NotificationCategory] = mapped_column(
593
+ Enum(NotificationCategory),
594
+ default=NotificationCategory.digest,
595
+ server_default=NotificationCategory.digest.value,
596
+ nullable=False,
597
+ index=True,
598
+ )
599
+ group_key: Mapped[str | None] = mapped_column(String(512), nullable=True, index=True)
600
+ wake_version: Mapped[int] = mapped_column(Integer, nullable=False, default=1, server_default=text("1"))
601
+ fingerprint_version: Mapped[NotificationFingerprintVersion] = mapped_column(
602
+ Enum(NotificationFingerprintVersion),
603
+ default=NotificationFingerprintVersion.v2,
604
+ server_default=NotificationFingerprintVersion.v2.value,
605
+ nullable=False,
606
+ index=True,
607
+ )
608
+ event_count: Mapped[int] = mapped_column(Integer, nullable=False, default=1, server_default=text("1"))
609
+ first_event_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
610
+ last_event_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
611
+ read_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
612
+ created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), index=True)
613
+ updated_at: Mapped[datetime] = mapped_column(
614
+ DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
615
+ )
616
+
617
+ recipient: Mapped["Agent"] = relationship()
618
+
619
+
620
+ class InboundEvent(Base):
621
+ """Waker-side ingest log. Decouples "server published a notification" from
622
+ "waker saw and (about to) act on it" with a stable ``fingerprint`` unique key.
623
+
624
+ Audit three-layer split:
625
+ - ``notification`` (event layer, server-side fan-out)
626
+ - ``inbound_event`` (this table, access layer — waker's idempotent ingest)
627
+ - ``runtime-waker-sessions/<id>.jsonl`` (execution layer)
628
+
629
+ ``event_id`` matches ``notification.id`` one-to-one (UUID) so reviewers can
630
+ join the three layers without persona-name string joins. ``fingerprint`` is
631
+ the dedup key and is the server-side primary gate for replay rejection
632
+ (Phase 1 ``UNIQUE(fingerprint)`` enforces A1).
633
+ """
634
+
635
+ __tablename__ = "inbound_events"
636
+ __table_args__ = (
637
+ UniqueConstraint("fingerprint", name="uq_inbound_events_fingerprint"),
638
+ Index(
639
+ "ix_inbound_events_agent_source_received",
640
+ "agent_id",
641
+ "source",
642
+ "received_at",
643
+ ),
644
+ )
645
+
646
+ id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
647
+ agent_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("agents.id"), nullable=False)
648
+ event_id: Mapped[uuid.UUID] = mapped_column(nullable=False)
649
+ event_type: Mapped[str] = mapped_column(String(64), nullable=False)
650
+ source: Mapped[InboundEventSource] = mapped_column(
651
+ Enum(InboundEventSource),
652
+ default=InboundEventSource.polling,
653
+ nullable=False,
654
+ )
655
+ fingerprint: Mapped[str] = mapped_column(String(128), nullable=False)
656
+ payload: Mapped[dict | None] = mapped_column(JSON, nullable=True)
657
+ received_at: Mapped[datetime] = mapped_column(
658
+ DateTime(timezone=True), server_default=func.now(), nullable=False
659
+ )
660
+ acked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
661
+ rejection_count: Mapped[int] = mapped_column(
662
+ Integer, nullable=False, default=0, server_default=text("0")
663
+ )
664
+
665
+ agent: Mapped["Agent"] = relationship()
666
+
667
+
668
+ class Mention(Base):
669
+ __tablename__ = "mentions"
670
+
671
+ id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
672
+ mentioned_agent_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("agents.id"), nullable=False, index=True)
673
+ author_agent_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("agents.id"), nullable=False)
674
+ source_type: Mapped[MentionSourceType] = mapped_column(Enum(MentionSourceType), nullable=False)
675
+ source_id: Mapped[uuid.UUID] = mapped_column(nullable=False)
676
+ project_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("projects.id"), nullable=False, index=True)
677
+ experiment_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("experiments.id"), nullable=True)
678
+ topic_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("topics.id"), nullable=True)
679
+ excerpt: Mapped[str] = mapped_column(String(512), nullable=False)
680
+ created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), index=True)
681
+ dismissed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
682
+
683
+ mentioned_agent: Mapped["Agent"] = relationship(foreign_keys=[mentioned_agent_id])
684
+ author: Mapped["Agent"] = relationship(foreign_keys=[author_agent_id])
685
+
686
+
687
+ class PlatformFeedback(Base):
688
+ """Platform-wide feedback inbox.
689
+
690
+ A global object (project_id is nullable) so any authenticated agent can
691
+ submit feedback about the MAP platform itself, regardless of which project
692
+ they are bound to. Only admins read/triage the inbox.
693
+ """
694
+
695
+ __tablename__ = "platform_feedback"
696
+
697
+ id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
698
+ author_agent_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("agents.id"), nullable=False, index=True)
699
+ project_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("projects.id"), nullable=True, index=True)
700
+ body: Mapped[str] = mapped_column(Text, nullable=False)
701
+ category: Mapped[FeedbackCategory | None] = mapped_column(Enum(FeedbackCategory), nullable=True)
702
+ status: Mapped[FeedbackStatus] = mapped_column(
703
+ Enum(FeedbackStatus), default=FeedbackStatus.new, nullable=False, index=True
704
+ )
705
+ metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
706
+ created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
707
+ updated_at: Mapped[datetime] = mapped_column(
708
+ DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
709
+ )
710
+ archived_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
711
+
712
+ author: Mapped["Agent"] = relationship()
713
+ project: Mapped["Project | None"] = relationship()