nexus-coding-agent 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 (131) hide show
  1. nexus/__init__.py +4 -0
  2. nexus/application/__init__.py +2 -0
  3. nexus/application/approval_service.py +130 -0
  4. nexus/application/diff_service.py +177 -0
  5. nexus/application/event_publisher.py +180 -0
  6. nexus/application/execution_context.py +29 -0
  7. nexus/application/execution_ledger.py +126 -0
  8. nexus/application/model_call_context.py +29 -0
  9. nexus/application/observed_model_gateway.py +194 -0
  10. nexus/application/plan_approval_service.py +250 -0
  11. nexus/application/planning.py +668 -0
  12. nexus/application/runtime.py +624 -0
  13. nexus/application/session_service.py +411 -0
  14. nexus/application/structured_output.py +96 -0
  15. nexus/application/telemetry.py +396 -0
  16. nexus/application/telemetry_subscriber.py +216 -0
  17. nexus/application/tool_runtime.py +496 -0
  18. nexus/application/tracing_dispatcher.py +283 -0
  19. nexus/application/validation.py +293 -0
  20. nexus/config/__init__.py +17 -0
  21. nexus/config/loader.py +253 -0
  22. nexus/config/models.py +310 -0
  23. nexus/context/__init__.py +6 -0
  24. nexus/context/budget.py +24 -0
  25. nexus/context/builder.py +92 -0
  26. nexus/context/chunking.py +65 -0
  27. nexus/context/explorer.py +246 -0
  28. nexus/context/integration.py +63 -0
  29. nexus/context/manager.py +328 -0
  30. nexus/context/retrieval.py +261 -0
  31. nexus/domain/__init__.py +2 -0
  32. nexus/domain/agent_decision.py +113 -0
  33. nexus/domain/agent_state.py +49 -0
  34. nexus/domain/approvals.py +64 -0
  35. nexus/domain/context.py +108 -0
  36. nexus/domain/exploration.py +86 -0
  37. nexus/domain/mcp.py +51 -0
  38. nexus/domain/model.py +173 -0
  39. nexus/domain/observability.py +326 -0
  40. nexus/domain/persistence.py +122 -0
  41. nexus/domain/planning.py +329 -0
  42. nexus/domain/ports/__init__.py +61 -0
  43. nexus/domain/ports/agent_decision.py +9 -0
  44. nexus/domain/ports/approval_unit_of_work.py +27 -0
  45. nexus/domain/ports/checkpoint_provider.py +11 -0
  46. nexus/domain/ports/context.py +71 -0
  47. nexus/domain/ports/events.py +21 -0
  48. nexus/domain/ports/graph_runtime.py +30 -0
  49. nexus/domain/ports/mcp.py +23 -0
  50. nexus/domain/ports/model_gateway.py +21 -0
  51. nexus/domain/ports/model_input_budget.py +15 -0
  52. nexus/domain/ports/planning.py +62 -0
  53. nexus/domain/ports/repositories.py +54 -0
  54. nexus/domain/ports/repository_context.py +18 -0
  55. nexus/domain/ports/session_unit_of_work.py +33 -0
  56. nexus/domain/ports/skills.py +41 -0
  57. nexus/domain/ports/tooling.py +39 -0
  58. nexus/domain/ports/tracer.py +13 -0
  59. nexus/domain/ports/validation.py +32 -0
  60. nexus/domain/runtime_events.py +445 -0
  61. nexus/domain/skills.py +129 -0
  62. nexus/domain/tooling.py +163 -0
  63. nexus/domain/validation.py +161 -0
  64. nexus/errors/__init__.py +82 -0
  65. nexus/evaluation/__init__.py +7 -0
  66. nexus/evaluation/assertions.py +4 -0
  67. nexus/evaluation/evaluator.py +263 -0
  68. nexus/evaluation/fixtures.py +36 -0
  69. nexus/evaluation/harness.py +606 -0
  70. nexus/evaluation/loader.py +415 -0
  71. nexus/evaluation/metrics.py +27 -0
  72. nexus/evaluation/models.py +362 -0
  73. nexus/evaluation/report.py +43 -0
  74. nexus/evaluation/runner.py +480 -0
  75. nexus/infrastructure/__init__.py +2 -0
  76. nexus/infrastructure/asyncio_compat.py +13 -0
  77. nexus/infrastructure/bootstrap/__init__.py +17 -0
  78. nexus/infrastructure/bootstrap/composition.py +663 -0
  79. nexus/infrastructure/checkpoint/__init__.py +5 -0
  80. nexus/infrastructure/checkpoint/postgres.py +62 -0
  81. nexus/infrastructure/database.py +39 -0
  82. nexus/infrastructure/embedding.py +61 -0
  83. nexus/infrastructure/graph/__init__.py +6 -0
  84. nexus/infrastructure/graph/day4_runtime.py +966 -0
  85. nexus/infrastructure/graph/langgraph_runtime.py +172 -0
  86. nexus/infrastructure/mcp/__init__.py +5 -0
  87. nexus/infrastructure/mcp/manager.py +380 -0
  88. nexus/infrastructure/model_gateway/__init__.py +6 -0
  89. nexus/infrastructure/model_gateway/openai_compatible.py +345 -0
  90. nexus/infrastructure/observability/__init__.py +7 -0
  91. nexus/infrastructure/observability/console.py +31 -0
  92. nexus/infrastructure/observability/langsmith.py +236 -0
  93. nexus/infrastructure/observability/structured_logger.py +70 -0
  94. nexus/infrastructure/persistence/__init__.py +13 -0
  95. nexus/infrastructure/persistence/approval_unit_of_work.py +74 -0
  96. nexus/infrastructure/persistence/models.py +146 -0
  97. nexus/infrastructure/persistence/repositories.py +326 -0
  98. nexus/infrastructure/persistence/unit_of_work.py +82 -0
  99. nexus/infrastructure/repository_files.py +117 -0
  100. nexus/infrastructure/sandbox/__init__.py +5 -0
  101. nexus/infrastructure/sandbox/local_process.py +353 -0
  102. nexus/infrastructure/semantic.py +276 -0
  103. nexus/infrastructure/semantic_models.py +56 -0
  104. nexus/interfaces/__init__.py +2 -0
  105. nexus/interfaces/cli/__init__.py +2 -0
  106. nexus/interfaces/cli/__main__.py +6 -0
  107. nexus/interfaces/cli/app.py +266 -0
  108. nexus/interfaces/cli/renderer.py +118 -0
  109. nexus/security/__init__.py +14 -0
  110. nexus/security/approval_policies.py +73 -0
  111. nexus/security/command_policy.py +117 -0
  112. nexus/security/executables.py +107 -0
  113. nexus/security/git_commands.py +65 -0
  114. nexus/security/workspace.py +93 -0
  115. nexus/skills/__init__.py +7 -0
  116. nexus/skills/builtin/__init__.py +1 -0
  117. nexus/skills/builtin/debug-python/SKILL.md +26 -0
  118. nexus/skills/builtin/review-repository/SKILL.md +26 -0
  119. nexus/skills/builtin/write-tests/SKILL.md +26 -0
  120. nexus/skills/loader.py +439 -0
  121. nexus/skills/registry.py +146 -0
  122. nexus/skills/selector.py +152 -0
  123. nexus/tools/__init__.py +6 -0
  124. nexus/tools/editing.py +312 -0
  125. nexus/tools/mcp.py +160 -0
  126. nexus/tools/native.py +654 -0
  127. nexus/tools/registry.py +27 -0
  128. nexus_coding_agent-0.1.0.dist-info/METADATA +142 -0
  129. nexus_coding_agent-0.1.0.dist-info/RECORD +131 -0
  130. nexus_coding_agent-0.1.0.dist-info/WHEEL +4 -0
  131. nexus_coding_agent-0.1.0.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,13 @@
1
+ """SQLAlchemy adapters for Nexus-owned business persistence through Day 3."""
2
+
3
+ from nexus.infrastructure.persistence.approval_unit_of_work import (
4
+ SqlAlchemyApprovalUnitOfWorkFactory,
5
+ )
6
+ from nexus.infrastructure.persistence.models import Base
7
+ from nexus.infrastructure.persistence.unit_of_work import SqlAlchemySessionUnitOfWorkFactory
8
+
9
+ __all__ = [
10
+ "Base",
11
+ "SqlAlchemyApprovalUnitOfWorkFactory",
12
+ "SqlAlchemySessionUnitOfWorkFactory",
13
+ ]
@@ -0,0 +1,74 @@
1
+ """Independent async SQLAlchemy transaction boundary for Day 3 approvals."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from types import TracebackType
6
+
7
+ from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
8
+
9
+ from nexus.domain.ports.approval_unit_of_work import ApprovalUnitOfWork
10
+ from nexus.domain.ports.repositories import ApprovalRepository, RunRepository, SessionRepository
11
+ from nexus.infrastructure.persistence.repositories import (
12
+ SqlAlchemyApprovalRepository,
13
+ SqlAlchemyRunRepository,
14
+ SqlAlchemySessionRepository,
15
+ )
16
+
17
+
18
+ class SqlAlchemyApprovalUnitOfWork:
19
+ approvals: ApprovalRepository
20
+ runs: RunRepository
21
+ sessions: SessionRepository
22
+
23
+ def __init__(self, session_factory: async_sessionmaker[AsyncSession]) -> None:
24
+ self._session_factory = session_factory
25
+ self._session: AsyncSession | None = None
26
+
27
+ async def __aenter__(self) -> SqlAlchemyApprovalUnitOfWork:
28
+ if self._session is not None:
29
+ raise RuntimeError("ApprovalUnitOfWork instances cannot be reused.")
30
+ session = self._session_factory()
31
+ try:
32
+ await session.begin()
33
+ except Exception:
34
+ await session.close()
35
+ raise
36
+ self._session = session
37
+ self.approvals = SqlAlchemyApprovalRepository(session)
38
+ self.runs = SqlAlchemyRunRepository(session)
39
+ self.sessions = SqlAlchemySessionRepository(session)
40
+ return self
41
+
42
+ async def __aexit__(
43
+ self,
44
+ exc_type: type[BaseException] | None,
45
+ exc_value: BaseException | None,
46
+ traceback: TracebackType | None,
47
+ ) -> bool | None:
48
+ session = self._require_session()
49
+ try:
50
+ if exc_type is None:
51
+ try:
52
+ await session.commit()
53
+ except Exception:
54
+ await session.rollback()
55
+ raise
56
+ else:
57
+ await session.rollback()
58
+ finally:
59
+ await session.close()
60
+ self._session = None
61
+ return None
62
+
63
+ def _require_session(self) -> AsyncSession:
64
+ if self._session is None:
65
+ raise RuntimeError("ApprovalUnitOfWork has not been entered.")
66
+ return self._session
67
+
68
+
69
+ class SqlAlchemyApprovalUnitOfWorkFactory:
70
+ def __init__(self, session_factory: async_sessionmaker[AsyncSession]) -> None:
71
+ self._session_factory = session_factory
72
+
73
+ def __call__(self) -> ApprovalUnitOfWork:
74
+ return SqlAlchemyApprovalUnitOfWork(self._session_factory)
@@ -0,0 +1,146 @@
1
+ """Private SQLAlchemy rows for the business schema through Day 3."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import datetime
6
+ from typing import Any
7
+
8
+ from sqlalchemy import (
9
+ CheckConstraint,
10
+ DateTime,
11
+ ForeignKey,
12
+ Index,
13
+ Integer,
14
+ String,
15
+ Text,
16
+ UniqueConstraint,
17
+ )
18
+ from sqlalchemy.dialects.postgresql import JSONB, UUID
19
+ from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
20
+
21
+
22
+ class Base(DeclarativeBase):
23
+ pass
24
+
25
+
26
+ class RepositoryRow(Base):
27
+ __tablename__ = "repositories"
28
+
29
+ id: Mapped[str] = mapped_column(UUID(as_uuid=False), primary_key=True)
30
+ canonical_path: Mapped[str] = mapped_column(Text, nullable=False, unique=True)
31
+ metadata_payload: Mapped[dict[str, Any]] = mapped_column(
32
+ "metadata", JSONB, nullable=False, default=dict
33
+ )
34
+ configuration_reference: Mapped[str | None] = mapped_column(Text, nullable=True)
35
+ created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
36
+
37
+
38
+ class SessionRow(Base):
39
+ __tablename__ = "sessions"
40
+ __table_args__ = (
41
+ Index("ix_sessions_repository_last_active", "repository_id", "last_active_at"),
42
+ )
43
+
44
+ session_id: Mapped[str] = mapped_column(UUID(as_uuid=False), primary_key=True)
45
+ repository_id: Mapped[str] = mapped_column(
46
+ UUID(as_uuid=False),
47
+ ForeignKey("repositories.id", ondelete="RESTRICT"),
48
+ nullable=False,
49
+ )
50
+ created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
51
+ last_active_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
52
+ configuration_reference: Mapped[str | None] = mapped_column(Text, nullable=True)
53
+
54
+
55
+ class RunRow(Base):
56
+ __tablename__ = "runs"
57
+ __table_args__ = (
58
+ CheckConstraint(
59
+ "status IN ('RUNNING', 'INTERRUPTED', 'COMPLETED', 'FAILED')",
60
+ name="ck_runs_status_day2",
61
+ ),
62
+ Index("ix_runs_session_status_started", "session_id", "status", "started_at"),
63
+ )
64
+
65
+ run_id: Mapped[str] = mapped_column(UUID(as_uuid=False), primary_key=True)
66
+ session_id: Mapped[str] = mapped_column(
67
+ UUID(as_uuid=False),
68
+ ForeignKey("sessions.session_id", ondelete="RESTRICT"),
69
+ nullable=False,
70
+ )
71
+ task: Mapped[str] = mapped_column(Text, nullable=False)
72
+ status: Mapped[str] = mapped_column(String(32), nullable=False)
73
+ model_metadata: Mapped[dict[str, Any] | None] = mapped_column(JSONB, nullable=True)
74
+ started_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
75
+ finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
76
+ token_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
77
+ tool_call_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
78
+ changed_file_refs: Mapped[list[str] | None] = mapped_column(JSONB, nullable=True)
79
+ final_outcome: Mapped[dict[str, Any] | None] = mapped_column(JSONB, nullable=True)
80
+ graph_thread_id: Mapped[str] = mapped_column(Text, nullable=False, unique=True)
81
+
82
+
83
+ class SessionTurnRow(Base):
84
+ __tablename__ = "session_turns"
85
+ __table_args__ = (
86
+ UniqueConstraint("session_id", "sequence", name="uq_session_turns_session_sequence"),
87
+ )
88
+
89
+ id: Mapped[str] = mapped_column(UUID(as_uuid=False), primary_key=True)
90
+ session_id: Mapped[str] = mapped_column(
91
+ UUID(as_uuid=False),
92
+ ForeignKey("sessions.session_id", ondelete="RESTRICT"),
93
+ nullable=False,
94
+ )
95
+ run_id: Mapped[str | None] = mapped_column(
96
+ UUID(as_uuid=False),
97
+ ForeignKey("runs.run_id", ondelete="RESTRICT"),
98
+ nullable=True,
99
+ )
100
+ sequence: Mapped[int] = mapped_column(Integer, nullable=False)
101
+ role: Mapped[str] = mapped_column(Text, nullable=False)
102
+ content: Mapped[str] = mapped_column(Text, nullable=False)
103
+ metadata_payload: Mapped[dict[str, Any] | None] = mapped_column(
104
+ "metadata", JSONB, nullable=True
105
+ )
106
+ created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
107
+
108
+
109
+ class ApprovalRow(Base):
110
+ __tablename__ = "approvals"
111
+ __table_args__ = (
112
+ CheckConstraint(
113
+ "risk_level IN ('SAFE', 'WRITE', 'DANGEROUS')",
114
+ name="ck_approvals_risk_level_day3",
115
+ ),
116
+ CheckConstraint(
117
+ "decision IN ('PENDING', 'APPROVED', 'DENIED')",
118
+ name="ck_approvals_decision_day3",
119
+ ),
120
+ CheckConstraint(
121
+ "(decision = 'PENDING' AND decided_at IS NULL) OR "
122
+ "(decision IN ('APPROVED', 'DENIED') AND decided_at IS NOT NULL)",
123
+ name="ck_approvals_decided_at_day3",
124
+ ),
125
+ Index("ix_approvals_run_created", "run_id", "created_at", "approval_id"),
126
+ )
127
+
128
+ approval_id: Mapped[str] = mapped_column(UUID(as_uuid=False), primary_key=True)
129
+ run_id: Mapped[str] = mapped_column(
130
+ UUID(as_uuid=False),
131
+ ForeignKey("runs.run_id", ondelete="RESTRICT"),
132
+ nullable=False,
133
+ )
134
+ session_id: Mapped[str] = mapped_column(
135
+ UUID(as_uuid=False),
136
+ ForeignKey("sessions.session_id", ondelete="RESTRICT"),
137
+ nullable=False,
138
+ )
139
+ operation: Mapped[str] = mapped_column(Text, nullable=False)
140
+ risk_level: Mapped[str] = mapped_column(String(16), nullable=False)
141
+ resource_or_command_summary: Mapped[str] = mapped_column(Text, nullable=False)
142
+ decision: Mapped[str] = mapped_column(String(16), nullable=False)
143
+ actor: Mapped[str] = mapped_column(Text, nullable=False)
144
+ reason: Mapped[str | None] = mapped_column(Text, nullable=True)
145
+ created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
146
+ decided_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
@@ -0,0 +1,326 @@
1
+ """Async SQLAlchemy implementations of repository ports through Day 3."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from sqlalchemy import func, select
8
+ from sqlalchemy.ext.asyncio import AsyncSession
9
+
10
+ from nexus.domain.approvals import ApprovalRequest
11
+ from nexus.domain.persistence import NexusSession, Repository, Run, RunStatus, SessionTurn
12
+ from nexus.domain.tooling import ApprovalDecision, RiskLevel
13
+ from nexus.infrastructure.persistence.models import (
14
+ ApprovalRow,
15
+ RepositoryRow,
16
+ RunRow,
17
+ SessionRow,
18
+ SessionTurnRow,
19
+ )
20
+
21
+
22
+ class SqlAlchemyApprovalRepository:
23
+ def __init__(self, session: AsyncSession) -> None:
24
+ self._session = session
25
+
26
+ async def add(self, approval: ApprovalRequest) -> None:
27
+ self._session.add(_approval_to_row(approval))
28
+ await self._session.flush()
29
+
30
+ async def get(self, approval_id: str) -> ApprovalRequest | None:
31
+ row = await self._session.get(ApprovalRow, approval_id)
32
+ return None if row is None else _approval_from_row(row)
33
+
34
+ async def list_by_run(self, run_id: str) -> list[ApprovalRequest]:
35
+ rows = (
36
+ await self._session.scalars(
37
+ select(ApprovalRow)
38
+ .where(ApprovalRow.run_id == run_id)
39
+ .order_by(ApprovalRow.created_at.asc(), ApprovalRow.approval_id.asc())
40
+ )
41
+ ).all()
42
+ return [_approval_from_row(row) for row in rows]
43
+
44
+ async def update(self, approval: ApprovalRequest) -> None:
45
+ row = await self._session.get(ApprovalRow, approval.approval_id)
46
+ if row is None:
47
+ raise LookupError(f"Approval {approval.approval_id} no longer exists.")
48
+ if row.decision != ApprovalDecision.PENDING.value:
49
+ raise ValueError("A terminal approval decision cannot be changed.")
50
+ if approval.decision is ApprovalDecision.PENDING:
51
+ raise ValueError("Approval update requires a terminal decision.")
52
+ if (
53
+ row.run_id != approval.run_id
54
+ or row.session_id != approval.session_id
55
+ or row.operation != approval.operation
56
+ or row.risk_level != approval.risk_level.value
57
+ or row.resource_or_command_summary != approval.resource_or_command_summary
58
+ or row.created_at != approval.created_at
59
+ ):
60
+ raise ValueError("Immutable approval fields cannot be changed.")
61
+ row.decision = approval.decision.value
62
+ row.actor = approval.actor
63
+ row.reason = approval.reason
64
+ row.decided_at = approval.decided_at
65
+ await self._session.flush()
66
+
67
+
68
+ class SqlAlchemyRepositoryRepository:
69
+ def __init__(self, session: AsyncSession) -> None:
70
+ self._session = session
71
+
72
+ async def add(self, repository: Repository) -> None:
73
+ self._session.add(_repository_to_row(repository))
74
+ await self._session.flush()
75
+
76
+ async def get(self, repository_id: str) -> Repository | None:
77
+ row = await self._session.get(RepositoryRow, repository_id)
78
+ return None if row is None else _repository_from_row(row)
79
+
80
+ async def get_by_canonical_path(self, canonical_path: str) -> Repository | None:
81
+ row = await self._session.scalar(
82
+ select(RepositoryRow).where(RepositoryRow.canonical_path == canonical_path)
83
+ )
84
+ return None if row is None else _repository_from_row(row)
85
+
86
+
87
+ class SqlAlchemySessionRepository:
88
+ def __init__(self, session: AsyncSession) -> None:
89
+ self._session = session
90
+
91
+ async def add(self, session: NexusSession) -> None:
92
+ self._session.add(_session_to_row(session))
93
+ await self._session.flush()
94
+
95
+ async def get(self, session_id: str) -> NexusSession | None:
96
+ row = await self._session.get(SessionRow, session_id)
97
+ return None if row is None else _session_from_row(row)
98
+
99
+ async def list_by_repository(self, repository_id: str) -> list[NexusSession]:
100
+ rows = (
101
+ await self._session.scalars(
102
+ select(SessionRow)
103
+ .where(SessionRow.repository_id == repository_id)
104
+ .order_by(SessionRow.last_active_at.desc(), SessionRow.session_id.asc())
105
+ )
106
+ ).all()
107
+ return [_session_from_row(row) for row in rows]
108
+
109
+ async def update(self, session: NexusSession) -> None:
110
+ row = await self._session.get(SessionRow, session.session_id)
111
+ if row is None:
112
+ raise LookupError(f"Session {session.session_id} no longer exists.")
113
+ row.repository_id = session.repository_id
114
+ row.created_at = session.created_at
115
+ row.last_active_at = session.last_active_at
116
+ row.configuration_reference = session.configuration_reference
117
+ await self._session.flush()
118
+
119
+
120
+ class SqlAlchemyRunRepository:
121
+ def __init__(self, session: AsyncSession) -> None:
122
+ self._session = session
123
+
124
+ async def add(self, run: Run) -> None:
125
+ self._session.add(_run_to_row(run))
126
+ await self._session.flush()
127
+
128
+ async def get(self, run_id: str) -> Run | None:
129
+ row = await self._session.get(RunRow, run_id)
130
+ return None if row is None else _run_from_row(row)
131
+
132
+ async def get_latest_interrupted(self, session_id: str) -> Run | None:
133
+ row = await self._session.scalar(
134
+ select(RunRow)
135
+ .where(
136
+ RunRow.session_id == session_id,
137
+ RunRow.status == RunStatus.INTERRUPTED.value,
138
+ )
139
+ .order_by(RunRow.started_at.desc(), RunRow.run_id.desc())
140
+ .limit(1)
141
+ )
142
+ return None if row is None else _run_from_row(row)
143
+
144
+ async def has_interrupted(self, session_id: str) -> bool:
145
+ result = await self._session.scalar(
146
+ select(RunRow.run_id)
147
+ .where(
148
+ RunRow.session_id == session_id,
149
+ RunRow.status == RunStatus.INTERRUPTED.value,
150
+ )
151
+ .limit(1)
152
+ )
153
+ return result is not None
154
+
155
+ async def update(self, run: Run) -> None:
156
+ row = await self._session.get(RunRow, run.run_id)
157
+ if row is None:
158
+ raise LookupError(f"Run {run.run_id} no longer exists.")
159
+ _apply_run(row, run)
160
+ await self._session.flush()
161
+
162
+
163
+ class SqlAlchemySessionTurnRepository:
164
+ def __init__(self, session: AsyncSession) -> None:
165
+ self._session = session
166
+
167
+ async def add(self, turn: SessionTurn) -> None:
168
+ self._session.add(_turn_to_row(turn))
169
+ await self._session.flush()
170
+
171
+ async def list_by_session(self, session_id: str) -> list[SessionTurn]:
172
+ rows = (
173
+ await self._session.scalars(
174
+ select(SessionTurnRow)
175
+ .where(SessionTurnRow.session_id == session_id)
176
+ .order_by(SessionTurnRow.sequence.asc())
177
+ )
178
+ ).all()
179
+ return [_turn_from_row(row) for row in rows]
180
+
181
+ async def next_sequence(self, session_id: str) -> int:
182
+ current = await self._session.scalar(
183
+ select(func.max(SessionTurnRow.sequence)).where(
184
+ SessionTurnRow.session_id == session_id
185
+ )
186
+ )
187
+ return 1 if current is None else int(current) + 1
188
+
189
+
190
+ def _repository_to_row(entity: Repository) -> RepositoryRow:
191
+ return RepositoryRow(
192
+ id=entity.repository_id,
193
+ canonical_path=entity.canonical_path,
194
+ metadata_payload=entity.metadata,
195
+ configuration_reference=entity.configuration_reference,
196
+ created_at=entity.created_at,
197
+ )
198
+
199
+
200
+ def _repository_from_row(row: RepositoryRow) -> Repository:
201
+ return Repository(
202
+ repository_id=row.id,
203
+ canonical_path=row.canonical_path,
204
+ metadata=dict(row.metadata_payload),
205
+ configuration_reference=row.configuration_reference,
206
+ created_at=row.created_at,
207
+ )
208
+
209
+
210
+ def _session_to_row(entity: NexusSession) -> SessionRow:
211
+ return SessionRow(
212
+ session_id=entity.session_id,
213
+ repository_id=entity.repository_id,
214
+ created_at=entity.created_at,
215
+ last_active_at=entity.last_active_at,
216
+ configuration_reference=entity.configuration_reference,
217
+ )
218
+
219
+
220
+ def _session_from_row(row: SessionRow) -> NexusSession:
221
+ return NexusSession(
222
+ session_id=row.session_id,
223
+ repository_id=row.repository_id,
224
+ created_at=row.created_at,
225
+ last_active_at=row.last_active_at,
226
+ configuration_reference=row.configuration_reference,
227
+ )
228
+
229
+
230
+ def _run_to_row(entity: Run) -> RunRow:
231
+ row = RunRow(run_id=entity.run_id)
232
+ _apply_run(row, entity)
233
+ return row
234
+
235
+
236
+ def _apply_run(row: RunRow, entity: Run) -> None:
237
+ row.session_id = entity.session_id
238
+ row.task = entity.task
239
+ row.status = entity.status.value
240
+ row.model_metadata = entity.model_metadata
241
+ row.started_at = entity.started_at
242
+ row.finished_at = entity.finished_at
243
+ row.token_count = entity.token_count
244
+ row.tool_call_count = entity.tool_call_count
245
+ row.changed_file_refs = entity.changed_file_refs
246
+ row.final_outcome = entity.final_outcome
247
+ row.graph_thread_id = entity.graph_thread_id
248
+
249
+
250
+ def _run_from_row(row: RunRow) -> Run:
251
+ return Run(
252
+ run_id=row.run_id,
253
+ session_id=row.session_id,
254
+ task=row.task,
255
+ status=RunStatus(row.status),
256
+ model_metadata=_optional_dict(row.model_metadata),
257
+ started_at=row.started_at,
258
+ finished_at=row.finished_at,
259
+ token_count=row.token_count,
260
+ tool_call_count=row.tool_call_count,
261
+ changed_file_refs=None if row.changed_file_refs is None else list(row.changed_file_refs),
262
+ final_outcome=_optional_dict(row.final_outcome),
263
+ graph_thread_id=row.graph_thread_id,
264
+ )
265
+
266
+
267
+ def _turn_to_row(entity: SessionTurn) -> SessionTurnRow:
268
+ return SessionTurnRow(
269
+ id=entity.id,
270
+ session_id=entity.session_id,
271
+ run_id=entity.run_id,
272
+ sequence=entity.sequence,
273
+ role=entity.role,
274
+ content=entity.content,
275
+ metadata_payload=entity.metadata,
276
+ created_at=entity.created_at,
277
+ )
278
+
279
+
280
+ def _turn_from_row(row: SessionTurnRow) -> SessionTurn:
281
+ return SessionTurn(
282
+ id=row.id,
283
+ session_id=row.session_id,
284
+ run_id=row.run_id,
285
+ sequence=row.sequence,
286
+ role=row.role,
287
+ content=row.content,
288
+ metadata=_optional_dict(row.metadata_payload),
289
+ created_at=row.created_at,
290
+ )
291
+
292
+
293
+ def _optional_dict(value: dict[str, Any] | None) -> dict[str, object] | None:
294
+ return None if value is None else dict(value)
295
+
296
+
297
+ def _approval_to_row(entity: ApprovalRequest) -> ApprovalRow:
298
+ return ApprovalRow(
299
+ approval_id=entity.approval_id,
300
+ run_id=entity.run_id,
301
+ session_id=entity.session_id,
302
+ operation=entity.operation,
303
+ risk_level=entity.risk_level.value,
304
+ resource_or_command_summary=entity.resource_or_command_summary,
305
+ decision=entity.decision.value,
306
+ actor=entity.actor,
307
+ reason=entity.reason,
308
+ created_at=entity.created_at,
309
+ decided_at=entity.decided_at,
310
+ )
311
+
312
+
313
+ def _approval_from_row(row: ApprovalRow) -> ApprovalRequest:
314
+ return ApprovalRequest(
315
+ approval_id=row.approval_id,
316
+ run_id=row.run_id,
317
+ session_id=row.session_id,
318
+ operation=row.operation,
319
+ risk_level=RiskLevel(row.risk_level),
320
+ resource_or_command_summary=row.resource_or_command_summary,
321
+ decision=ApprovalDecision(row.decision),
322
+ actor=row.actor,
323
+ reason=row.reason,
324
+ created_at=row.created_at,
325
+ decided_at=row.decided_at,
326
+ )
@@ -0,0 +1,82 @@
1
+ """Concrete async SQLAlchemy transaction boundary for Day 2."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from types import TracebackType
6
+
7
+ from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
8
+
9
+ from nexus.domain.ports.repositories import (
10
+ RepositoryRepository,
11
+ RunRepository,
12
+ SessionRepository,
13
+ SessionTurnRepository,
14
+ )
15
+ from nexus.domain.ports.session_unit_of_work import SessionUnitOfWork
16
+ from nexus.infrastructure.persistence.repositories import (
17
+ SqlAlchemyRepositoryRepository,
18
+ SqlAlchemyRunRepository,
19
+ SqlAlchemySessionRepository,
20
+ SqlAlchemySessionTurnRepository,
21
+ )
22
+
23
+
24
+ class SqlAlchemySessionUnitOfWork:
25
+ repositories: RepositoryRepository
26
+ sessions: SessionRepository
27
+ runs: RunRepository
28
+ turns: SessionTurnRepository
29
+
30
+ def __init__(self, session_factory: async_sessionmaker[AsyncSession]) -> None:
31
+ self._session_factory = session_factory
32
+ self._session: AsyncSession | None = None
33
+
34
+ async def __aenter__(self) -> SqlAlchemySessionUnitOfWork:
35
+ if self._session is not None:
36
+ raise RuntimeError("SessionUnitOfWork instances cannot be reused.")
37
+ session = self._session_factory()
38
+ try:
39
+ await session.begin()
40
+ except Exception:
41
+ await session.close()
42
+ raise
43
+ self._session = session
44
+ self.repositories = SqlAlchemyRepositoryRepository(session)
45
+ self.sessions = SqlAlchemySessionRepository(session)
46
+ self.runs = SqlAlchemyRunRepository(session)
47
+ self.turns = SqlAlchemySessionTurnRepository(session)
48
+ return self
49
+
50
+ async def __aexit__(
51
+ self,
52
+ exc_type: type[BaseException] | None,
53
+ exc_value: BaseException | None,
54
+ traceback: TracebackType | None,
55
+ ) -> bool | None:
56
+ session = self._require_session()
57
+ try:
58
+ if exc_type is None:
59
+ try:
60
+ await session.commit()
61
+ except Exception:
62
+ await session.rollback()
63
+ raise
64
+ else:
65
+ await session.rollback()
66
+ finally:
67
+ await session.close()
68
+ self._session = None
69
+ return None
70
+
71
+ def _require_session(self) -> AsyncSession:
72
+ if self._session is None:
73
+ raise RuntimeError("SessionUnitOfWork has not been entered.")
74
+ return self._session
75
+
76
+
77
+ class SqlAlchemySessionUnitOfWorkFactory:
78
+ def __init__(self, session_factory: async_sessionmaker[AsyncSession]) -> None:
79
+ self._session_factory = session_factory
80
+
81
+ def __call__(self) -> SessionUnitOfWork:
82
+ return SqlAlchemySessionUnitOfWork(self._session_factory)