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.
- nexus/__init__.py +4 -0
- nexus/application/__init__.py +2 -0
- nexus/application/approval_service.py +130 -0
- nexus/application/diff_service.py +177 -0
- nexus/application/event_publisher.py +180 -0
- nexus/application/execution_context.py +29 -0
- nexus/application/execution_ledger.py +126 -0
- nexus/application/model_call_context.py +29 -0
- nexus/application/observed_model_gateway.py +194 -0
- nexus/application/plan_approval_service.py +250 -0
- nexus/application/planning.py +668 -0
- nexus/application/runtime.py +624 -0
- nexus/application/session_service.py +411 -0
- nexus/application/structured_output.py +96 -0
- nexus/application/telemetry.py +396 -0
- nexus/application/telemetry_subscriber.py +216 -0
- nexus/application/tool_runtime.py +496 -0
- nexus/application/tracing_dispatcher.py +283 -0
- nexus/application/validation.py +293 -0
- nexus/config/__init__.py +17 -0
- nexus/config/loader.py +253 -0
- nexus/config/models.py +310 -0
- nexus/context/__init__.py +6 -0
- nexus/context/budget.py +24 -0
- nexus/context/builder.py +92 -0
- nexus/context/chunking.py +65 -0
- nexus/context/explorer.py +246 -0
- nexus/context/integration.py +63 -0
- nexus/context/manager.py +328 -0
- nexus/context/retrieval.py +261 -0
- nexus/domain/__init__.py +2 -0
- nexus/domain/agent_decision.py +113 -0
- nexus/domain/agent_state.py +49 -0
- nexus/domain/approvals.py +64 -0
- nexus/domain/context.py +108 -0
- nexus/domain/exploration.py +86 -0
- nexus/domain/mcp.py +51 -0
- nexus/domain/model.py +173 -0
- nexus/domain/observability.py +326 -0
- nexus/domain/persistence.py +122 -0
- nexus/domain/planning.py +329 -0
- nexus/domain/ports/__init__.py +61 -0
- nexus/domain/ports/agent_decision.py +9 -0
- nexus/domain/ports/approval_unit_of_work.py +27 -0
- nexus/domain/ports/checkpoint_provider.py +11 -0
- nexus/domain/ports/context.py +71 -0
- nexus/domain/ports/events.py +21 -0
- nexus/domain/ports/graph_runtime.py +30 -0
- nexus/domain/ports/mcp.py +23 -0
- nexus/domain/ports/model_gateway.py +21 -0
- nexus/domain/ports/model_input_budget.py +15 -0
- nexus/domain/ports/planning.py +62 -0
- nexus/domain/ports/repositories.py +54 -0
- nexus/domain/ports/repository_context.py +18 -0
- nexus/domain/ports/session_unit_of_work.py +33 -0
- nexus/domain/ports/skills.py +41 -0
- nexus/domain/ports/tooling.py +39 -0
- nexus/domain/ports/tracer.py +13 -0
- nexus/domain/ports/validation.py +32 -0
- nexus/domain/runtime_events.py +445 -0
- nexus/domain/skills.py +129 -0
- nexus/domain/tooling.py +163 -0
- nexus/domain/validation.py +161 -0
- nexus/errors/__init__.py +82 -0
- nexus/evaluation/__init__.py +7 -0
- nexus/evaluation/assertions.py +4 -0
- nexus/evaluation/evaluator.py +263 -0
- nexus/evaluation/fixtures.py +36 -0
- nexus/evaluation/harness.py +606 -0
- nexus/evaluation/loader.py +415 -0
- nexus/evaluation/metrics.py +27 -0
- nexus/evaluation/models.py +362 -0
- nexus/evaluation/report.py +43 -0
- nexus/evaluation/runner.py +480 -0
- nexus/infrastructure/__init__.py +2 -0
- nexus/infrastructure/asyncio_compat.py +13 -0
- nexus/infrastructure/bootstrap/__init__.py +17 -0
- nexus/infrastructure/bootstrap/composition.py +663 -0
- nexus/infrastructure/checkpoint/__init__.py +5 -0
- nexus/infrastructure/checkpoint/postgres.py +62 -0
- nexus/infrastructure/database.py +39 -0
- nexus/infrastructure/embedding.py +61 -0
- nexus/infrastructure/graph/__init__.py +6 -0
- nexus/infrastructure/graph/day4_runtime.py +966 -0
- nexus/infrastructure/graph/langgraph_runtime.py +172 -0
- nexus/infrastructure/mcp/__init__.py +5 -0
- nexus/infrastructure/mcp/manager.py +380 -0
- nexus/infrastructure/model_gateway/__init__.py +6 -0
- nexus/infrastructure/model_gateway/openai_compatible.py +345 -0
- nexus/infrastructure/observability/__init__.py +7 -0
- nexus/infrastructure/observability/console.py +31 -0
- nexus/infrastructure/observability/langsmith.py +236 -0
- nexus/infrastructure/observability/structured_logger.py +70 -0
- nexus/infrastructure/persistence/__init__.py +13 -0
- nexus/infrastructure/persistence/approval_unit_of_work.py +74 -0
- nexus/infrastructure/persistence/models.py +146 -0
- nexus/infrastructure/persistence/repositories.py +326 -0
- nexus/infrastructure/persistence/unit_of_work.py +82 -0
- nexus/infrastructure/repository_files.py +117 -0
- nexus/infrastructure/sandbox/__init__.py +5 -0
- nexus/infrastructure/sandbox/local_process.py +353 -0
- nexus/infrastructure/semantic.py +276 -0
- nexus/infrastructure/semantic_models.py +56 -0
- nexus/interfaces/__init__.py +2 -0
- nexus/interfaces/cli/__init__.py +2 -0
- nexus/interfaces/cli/__main__.py +6 -0
- nexus/interfaces/cli/app.py +266 -0
- nexus/interfaces/cli/renderer.py +118 -0
- nexus/security/__init__.py +14 -0
- nexus/security/approval_policies.py +73 -0
- nexus/security/command_policy.py +117 -0
- nexus/security/executables.py +107 -0
- nexus/security/git_commands.py +65 -0
- nexus/security/workspace.py +93 -0
- nexus/skills/__init__.py +7 -0
- nexus/skills/builtin/__init__.py +1 -0
- nexus/skills/builtin/debug-python/SKILL.md +26 -0
- nexus/skills/builtin/review-repository/SKILL.md +26 -0
- nexus/skills/builtin/write-tests/SKILL.md +26 -0
- nexus/skills/loader.py +439 -0
- nexus/skills/registry.py +146 -0
- nexus/skills/selector.py +152 -0
- nexus/tools/__init__.py +6 -0
- nexus/tools/editing.py +312 -0
- nexus/tools/mcp.py +160 -0
- nexus/tools/native.py +654 -0
- nexus/tools/registry.py +27 -0
- nexus_coding_agent-0.1.0.dist-info/METADATA +142 -0
- nexus_coding_agent-0.1.0.dist-info/RECORD +131 -0
- nexus_coding_agent-0.1.0.dist-info/WHEEL +4 -0
- 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)
|