cortexshift 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.
- cortexshift/__init__.py +10 -0
- cortexshift/__main__.py +6 -0
- cortexshift/adapters/__init__.py +22 -0
- cortexshift/adapters/command_runner.py +116 -0
- cortexshift/adapters/discovery.py +55 -0
- cortexshift/adapters/git/__init__.py +10 -0
- cortexshift/adapters/git/inspector.py +321 -0
- cortexshift/adapters/git/parser.py +140 -0
- cortexshift/adapters/headless_runner.py +92 -0
- cortexshift/adapters/process_runner.py +56 -0
- cortexshift/adapters/providers/__init__.py +4 -0
- cortexshift/adapters/providers/antigravity.py +530 -0
- cortexshift/adapters/providers/claude.py +375 -0
- cortexshift/adapters/providers/codex.py +434 -0
- cortexshift/adapters/sqlite/__init__.py +10 -0
- cortexshift/adapters/sqlite/migrations.py +268 -0
- cortexshift/adapters/sqlite/store.py +914 -0
- cortexshift/adapters/workspace_lease.py +123 -0
- cortexshift/application/__init__.py +42 -0
- cortexshift/application/checkpoint_builder.py +218 -0
- cortexshift/application/checkpoint_service.py +273 -0
- cortexshift/application/doctor.py +80 -0
- cortexshift/application/handoff_builder.py +281 -0
- cortexshift/application/handoff_renderer.py +430 -0
- cortexshift/application/handoff_service.py +66 -0
- cortexshift/application/init_service.py +86 -0
- cortexshift/application/locator.py +48 -0
- cortexshift/application/native_session.py +65 -0
- cortexshift/application/recovery_service.py +235 -0
- cortexshift/application/repository_service.py +146 -0
- cortexshift/application/resume_service.py +124 -0
- cortexshift/application/run_service.py +270 -0
- cortexshift/application/session_launcher.py +183 -0
- cortexshift/application/session_service.py +63 -0
- cortexshift/application/source_session.py +62 -0
- cortexshift/application/status_service.py +73 -0
- cortexshift/application/switch_service.py +671 -0
- cortexshift/application/task_service.py +201 -0
- cortexshift/application/task_workspace.py +152 -0
- cortexshift/cli/__init__.py +5 -0
- cortexshift/cli/app.py +2477 -0
- cortexshift/domain/__init__.py +153 -0
- cortexshift/domain/checkpoint.py +174 -0
- cortexshift/domain/doctor.py +68 -0
- cortexshift/domain/errors.py +277 -0
- cortexshift/domain/git.py +102 -0
- cortexshift/domain/handoff.py +241 -0
- cortexshift/domain/identifiers.py +27 -0
- cortexshift/domain/launch.py +58 -0
- cortexshift/domain/mcp_binding.py +81 -0
- cortexshift/domain/native_session.py +19 -0
- cortexshift/domain/project.py +37 -0
- cortexshift/domain/provider.py +67 -0
- cortexshift/domain/session.py +92 -0
- cortexshift/domain/status.py +40 -0
- cortexshift/domain/task.py +191 -0
- cortexshift/mcp/__init__.py +38 -0
- cortexshift/mcp/context.py +165 -0
- cortexshift/mcp/facade.py +513 -0
- cortexshift/mcp/models.py +178 -0
- cortexshift/mcp/resources.py +45 -0
- cortexshift/mcp/server.py +52 -0
- cortexshift/mcp/tools.py +176 -0
- cortexshift/ports/__init__.py +39 -0
- cortexshift/ports/checkpoint_store.py +45 -0
- cortexshift/ports/command_runner.py +56 -0
- cortexshift/ports/discovery.py +41 -0
- cortexshift/ports/handoff_delivery.py +91 -0
- cortexshift/ports/handoff_store.py +43 -0
- cortexshift/ports/headless_runner.py +58 -0
- cortexshift/ports/native_session.py +20 -0
- cortexshift/ports/process_runner.py +31 -0
- cortexshift/ports/provider.py +152 -0
- cortexshift/ports/repository.py +44 -0
- cortexshift/ports/session_store.py +27 -0
- cortexshift/ports/state_store.py +55 -0
- cortexshift/ports/workspace_lease.py +39 -0
- cortexshift/tui/__init__.py +24 -0
- cortexshift/tui/actions.py +58 -0
- cortexshift/tui/app.py +1051 -0
- cortexshift/tui/coordinator.py +173 -0
- cortexshift/tui/cortexshift.tcss +258 -0
- cortexshift/tui/facade.py +614 -0
- cortexshift/tui/modals.py +594 -0
- cortexshift/tui/models.py +503 -0
- cortexshift/tui/screens/__init__.py +81 -0
- cortexshift/tui/screens/checkpoints.py +188 -0
- cortexshift/tui/screens/handoffs.py +180 -0
- cortexshift/tui/screens/help.py +117 -0
- cortexshift/tui/screens/overview.py +200 -0
- cortexshift/tui/screens/providers.py +169 -0
- cortexshift/tui/screens/repository.py +143 -0
- cortexshift/tui/screens/sessions.py +146 -0
- cortexshift/tui/screens/task.py +174 -0
- cortexshift/tui/widgets.py +209 -0
- cortexshift-0.1.0.dist-info/METADATA +202 -0
- cortexshift-0.1.0.dist-info/RECORD +100 -0
- cortexshift-0.1.0.dist-info/WHEEL +4 -0
- cortexshift-0.1.0.dist-info/entry_points.txt +2 -0
- cortexshift-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
"""Application service for orchestrating native provider launch and session lifecycle."""
|
|
2
|
+
|
|
3
|
+
import shutil
|
|
4
|
+
import sys
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from pydantic import BaseModel, ConfigDict
|
|
10
|
+
|
|
11
|
+
from cortexshift.adapters.process_runner import SubprocessInteractiveProcessRunner
|
|
12
|
+
from cortexshift.adapters.providers.antigravity import (
|
|
13
|
+
ANTIGRAVITY_MCP_MISSING_NOTICE,
|
|
14
|
+
AntigravityRuntimeAdapter,
|
|
15
|
+
is_antigravity_mcp_configured,
|
|
16
|
+
)
|
|
17
|
+
from cortexshift.adapters.providers.claude import ClaudeRuntimeAdapter
|
|
18
|
+
from cortexshift.adapters.providers.codex import CodexRuntimeAdapter
|
|
19
|
+
from cortexshift.adapters.sqlite.store import SQLiteStateStore
|
|
20
|
+
from cortexshift.adapters.workspace_lease import FileWorkspaceLeaseManager
|
|
21
|
+
from cortexshift.application.checkpoint_service import CheckpointService
|
|
22
|
+
from cortexshift.application.locator import ProjectLocator
|
|
23
|
+
from cortexshift.application.session_launcher import ProviderSessionLauncher
|
|
24
|
+
from cortexshift.domain.errors import (
|
|
25
|
+
NoActiveTaskError,
|
|
26
|
+
ProjectNotInitializedError,
|
|
27
|
+
ProviderNotFoundError,
|
|
28
|
+
TerminalRequiredError,
|
|
29
|
+
UnknownProviderError,
|
|
30
|
+
WorkspaceLockedError,
|
|
31
|
+
)
|
|
32
|
+
from cortexshift.domain.launch import LaunchSpecification
|
|
33
|
+
from cortexshift.domain.project import Project
|
|
34
|
+
from cortexshift.domain.provider import (
|
|
35
|
+
PROVIDER_ANTIGRAVITY,
|
|
36
|
+
ProviderId,
|
|
37
|
+
)
|
|
38
|
+
from cortexshift.domain.session import Session
|
|
39
|
+
from cortexshift.domain.task import Task
|
|
40
|
+
from cortexshift.ports.process_runner import InteractiveProcessRunner
|
|
41
|
+
from cortexshift.ports.provider import ManagedMcpBinder, ProviderRuntimeAdapter
|
|
42
|
+
from cortexshift.ports.workspace_lease import WorkspaceLeaseManager
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class DryRunResult(BaseModel):
|
|
46
|
+
"""Result of a dry-run provider launch preview."""
|
|
47
|
+
|
|
48
|
+
model_config = ConfigDict(frozen=True)
|
|
49
|
+
|
|
50
|
+
provider_id: ProviderId
|
|
51
|
+
display_name: str
|
|
52
|
+
executable: str
|
|
53
|
+
project_id: str
|
|
54
|
+
project_name: str
|
|
55
|
+
task_id: str
|
|
56
|
+
task_title: str
|
|
57
|
+
cwd: Path
|
|
58
|
+
argv: list[str]
|
|
59
|
+
mode: str = "interactive"
|
|
60
|
+
prompt_supplied: bool = False
|
|
61
|
+
|
|
62
|
+
def to_dict(self) -> dict[str, Any]:
|
|
63
|
+
"""Serialize dry-run result without leaking raw prompts."""
|
|
64
|
+
return {
|
|
65
|
+
"provider_id": str(self.provider_id),
|
|
66
|
+
"display_name": self.display_name,
|
|
67
|
+
"executable": self.executable,
|
|
68
|
+
"project_id": self.project_id,
|
|
69
|
+
"project_name": self.project_name,
|
|
70
|
+
"task_id": self.task_id,
|
|
71
|
+
"task_title": self.task_title,
|
|
72
|
+
"cwd": str(self.cwd),
|
|
73
|
+
"argv": self.argv,
|
|
74
|
+
"mode": self.mode,
|
|
75
|
+
"prompt_supplied": self.prompt_supplied,
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class ProviderRuntimeRegistry:
|
|
80
|
+
"""Registry of supported provider runtime adapters."""
|
|
81
|
+
|
|
82
|
+
def __init__(self, adapters: list[ProviderRuntimeAdapter] | None = None) -> None:
|
|
83
|
+
self._adapters: dict[str, ProviderRuntimeAdapter] = {}
|
|
84
|
+
if adapters is None:
|
|
85
|
+
adapters = [
|
|
86
|
+
ClaudeRuntimeAdapter(),
|
|
87
|
+
CodexRuntimeAdapter(),
|
|
88
|
+
AntigravityRuntimeAdapter(),
|
|
89
|
+
]
|
|
90
|
+
for adapter in adapters:
|
|
91
|
+
self._adapters[str(adapter.provider_id).lower()] = adapter
|
|
92
|
+
|
|
93
|
+
def get(self, provider_id_str: str) -> ProviderRuntimeAdapter | None:
|
|
94
|
+
return self._adapters.get(provider_id_str.strip().lower())
|
|
95
|
+
|
|
96
|
+
def list_supported_ids(self) -> list[str]:
|
|
97
|
+
return sorted(self._adapters.keys())
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class RunService:
|
|
101
|
+
"""Orchestrates native provider process launching and session lifecycle."""
|
|
102
|
+
|
|
103
|
+
def __init__(
|
|
104
|
+
self,
|
|
105
|
+
registry: ProviderRuntimeRegistry | None = None,
|
|
106
|
+
process_runner: InteractiveProcessRunner | None = None,
|
|
107
|
+
lease_manager: WorkspaceLeaseManager | None = None,
|
|
108
|
+
which_fn: Callable[[str], str | None] | None = None,
|
|
109
|
+
is_tty_fn: Callable[[], bool] | None = None,
|
|
110
|
+
checkpoint_service: CheckpointService | None = None,
|
|
111
|
+
) -> None:
|
|
112
|
+
self._registry = registry or ProviderRuntimeRegistry()
|
|
113
|
+
self._runner = process_runner or SubprocessInteractiveProcessRunner()
|
|
114
|
+
self._lease_manager = lease_manager or FileWorkspaceLeaseManager()
|
|
115
|
+
self._which = which_fn if which_fn is not None else (lambda cmd: shutil.which(cmd))
|
|
116
|
+
self._is_tty = is_tty_fn if is_tty_fn is not None else self._check_tty
|
|
117
|
+
self._checkpoint_service = checkpoint_service or CheckpointService()
|
|
118
|
+
|
|
119
|
+
@staticmethod
|
|
120
|
+
def _check_tty() -> bool:
|
|
121
|
+
return sys.stdin.isatty() and sys.stdout.isatty()
|
|
122
|
+
|
|
123
|
+
def _resolve_context(
|
|
124
|
+
self,
|
|
125
|
+
provider_name: str,
|
|
126
|
+
start_dir: Path | str | None = None,
|
|
127
|
+
) -> tuple[Path, Project, Task, ProviderRuntimeAdapter, str, SQLiteStateStore]:
|
|
128
|
+
"""Resolve and validate project, active task, provider, and executable."""
|
|
129
|
+
start_path = Path(start_dir) if start_dir is not None else None
|
|
130
|
+
project_root = ProjectLocator.find_project_root(start_path)
|
|
131
|
+
if project_root is None:
|
|
132
|
+
raise ProjectNotInitializedError()
|
|
133
|
+
|
|
134
|
+
db_path = ProjectLocator.get_database_path(project_root)
|
|
135
|
+
store = SQLiteStateStore(db_path, auto_migrate=False)
|
|
136
|
+
|
|
137
|
+
try:
|
|
138
|
+
project = store.get_default_project()
|
|
139
|
+
if project is None:
|
|
140
|
+
raise ProjectNotInitializedError()
|
|
141
|
+
|
|
142
|
+
active_task_id = store.get_active_task_id(project.id)
|
|
143
|
+
if not active_task_id:
|
|
144
|
+
raise NoActiveTaskError()
|
|
145
|
+
|
|
146
|
+
task = store.get_task(active_task_id)
|
|
147
|
+
if task is None:
|
|
148
|
+
raise NoActiveTaskError()
|
|
149
|
+
|
|
150
|
+
adapter = self._registry.get(provider_name)
|
|
151
|
+
if adapter is None:
|
|
152
|
+
raise UnknownProviderError(provider_name, self._registry.list_supported_ids())
|
|
153
|
+
|
|
154
|
+
resolved_executable = self._which(adapter.executable)
|
|
155
|
+
if not resolved_executable:
|
|
156
|
+
raise ProviderNotFoundError(f"{adapter.display_name} was not found in PATH.")
|
|
157
|
+
|
|
158
|
+
return project_root, project, task, adapter, resolved_executable, store
|
|
159
|
+
except Exception:
|
|
160
|
+
store.close()
|
|
161
|
+
raise
|
|
162
|
+
|
|
163
|
+
def dry_run(
|
|
164
|
+
self,
|
|
165
|
+
provider_name: str,
|
|
166
|
+
prompt: str | None = None,
|
|
167
|
+
start_dir: Path | str | None = None,
|
|
168
|
+
) -> DryRunResult:
|
|
169
|
+
"""Perform a dry-run preview of provider launch without side effects."""
|
|
170
|
+
project_root, project, task, adapter, resolved_executable, store = self._resolve_context(
|
|
171
|
+
provider_name, start_dir
|
|
172
|
+
)
|
|
173
|
+
try:
|
|
174
|
+
launch_spec = adapter.build_launch_spec(
|
|
175
|
+
project_root=Path(project.repo_path),
|
|
176
|
+
executable_path=resolved_executable,
|
|
177
|
+
prompt=prompt,
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
return DryRunResult(
|
|
181
|
+
provider_id=adapter.provider_id,
|
|
182
|
+
display_name=adapter.display_name,
|
|
183
|
+
executable=resolved_executable,
|
|
184
|
+
project_id=project.id,
|
|
185
|
+
project_name=project.name,
|
|
186
|
+
task_id=task.id,
|
|
187
|
+
task_title=task.title,
|
|
188
|
+
cwd=launch_spec.cwd,
|
|
189
|
+
argv=launch_spec.to_redacted_argv(),
|
|
190
|
+
mode="interactive",
|
|
191
|
+
prompt_supplied=launch_spec.prompt_supplied,
|
|
192
|
+
)
|
|
193
|
+
finally:
|
|
194
|
+
store.close()
|
|
195
|
+
|
|
196
|
+
def run(
|
|
197
|
+
self,
|
|
198
|
+
provider_name: str,
|
|
199
|
+
prompt: str | None = None,
|
|
200
|
+
start_dir: Path | str | None = None,
|
|
201
|
+
on_launch: Callable[[LaunchSpecification, Session, Task, Project], None] | None = None,
|
|
202
|
+
) -> Session:
|
|
203
|
+
"""Launch an interactive provider session and manage its lifecycle.
|
|
204
|
+
|
|
205
|
+
Args:
|
|
206
|
+
provider_name: Canonical identifier of provider (e.g. 'claude', 'codex', 'antigravity').
|
|
207
|
+
prompt: Optional initial prompt.
|
|
208
|
+
start_dir: Directory where the command was invoked.
|
|
209
|
+
on_launch: Optional callback executed immediately before spawning process.
|
|
210
|
+
|
|
211
|
+
Returns:
|
|
212
|
+
The finalized Session entity.
|
|
213
|
+
"""
|
|
214
|
+
project_root, project, task, adapter, resolved_executable, store = self._resolve_context(
|
|
215
|
+
provider_name, start_dir
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
try:
|
|
219
|
+
# 1. Check TTY requirement
|
|
220
|
+
if not self._is_tty():
|
|
221
|
+
raise TerminalRequiredError(
|
|
222
|
+
"Interactive provider launch requires a terminal (TTY).\n\n"
|
|
223
|
+
f"To simulate provider launch non-interactively, use: "
|
|
224
|
+
f"cortexshift run {provider_name} --dry-run"
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
# 2. Build launch specification (validates prompt capability before lease)
|
|
228
|
+
launch_spec = adapter.build_launch_spec(
|
|
229
|
+
project_root=Path(project.repo_path),
|
|
230
|
+
executable_path=resolved_executable,
|
|
231
|
+
prompt=prompt,
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
# 3. Acquire workspace lease
|
|
235
|
+
lease = self._lease_manager.get_lease(Path(project.repo_path))
|
|
236
|
+
if not lease.acquire():
|
|
237
|
+
raise WorkspaceLockedError(lock_path=lease.lock_path)
|
|
238
|
+
|
|
239
|
+
# 4. Delegate Session creation and lifecycle to the shared launcher
|
|
240
|
+
launcher = ProviderSessionLauncher(
|
|
241
|
+
process_runner=self._runner,
|
|
242
|
+
store=store,
|
|
243
|
+
checkpoint_service=self._checkpoint_service,
|
|
244
|
+
)
|
|
245
|
+
try:
|
|
246
|
+
session = launcher.start_session(
|
|
247
|
+
task_id=task.id,
|
|
248
|
+
provider_id=adapter.provider_id,
|
|
249
|
+
native_session_id=launch_spec.native_session_id,
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
def _forward(spec: LaunchSpecification, current: Session) -> None:
|
|
253
|
+
if on_launch:
|
|
254
|
+
on_launch(spec, current, task, project)
|
|
255
|
+
|
|
256
|
+
if str(adapter.provider_id) == str(
|
|
257
|
+
PROVIDER_ANTIGRAVITY
|
|
258
|
+
) and not is_antigravity_mcp_configured(Path(project.repo_path)):
|
|
259
|
+
sys.stderr.write(f"\n{ANTIGRAVITY_MCP_MISSING_NOTICE}\n\n")
|
|
260
|
+
|
|
261
|
+
return launcher.run(
|
|
262
|
+
session,
|
|
263
|
+
launch_spec,
|
|
264
|
+
on_launch=_forward,
|
|
265
|
+
mcp_binder=adapter if isinstance(adapter, ManagedMcpBinder) else None,
|
|
266
|
+
)
|
|
267
|
+
finally:
|
|
268
|
+
lease.release()
|
|
269
|
+
finally:
|
|
270
|
+
store.close()
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
"""Shared provider session lifecycle used by both `run` and `switch`.
|
|
2
|
+
|
|
3
|
+
Extracted from ``RunService`` in Phase 5 so that launching a native provider and
|
|
4
|
+
tracking its CortexShift Session lifecycle exists in exactly one place. The launcher
|
|
5
|
+
assumes its caller has already resolved the project and task and already holds the
|
|
6
|
+
exclusive workspace lease, which keeps `switch` from nesting a second advisory lock
|
|
7
|
+
around the same workspace.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import contextlib
|
|
11
|
+
from collections.abc import Callable
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from cortexshift.domain.identifiers import utc_now
|
|
15
|
+
from cortexshift.domain.launch import LaunchSpecification
|
|
16
|
+
from cortexshift.domain.mcp_binding import McpSessionBinding
|
|
17
|
+
from cortexshift.domain.provider import ProviderId
|
|
18
|
+
from cortexshift.domain.session import Session, SessionExitReason, SessionStatus
|
|
19
|
+
from cortexshift.ports.process_runner import InteractiveProcessRunner
|
|
20
|
+
from cortexshift.ports.provider import ManagedMcpBinder
|
|
21
|
+
from cortexshift.ports.session_store import SessionStore
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ProviderSessionLauncher:
|
|
25
|
+
"""Creates, runs, and finalizes CortexShift Sessions for native provider processes."""
|
|
26
|
+
|
|
27
|
+
def __init__(
|
|
28
|
+
self,
|
|
29
|
+
process_runner: InteractiveProcessRunner,
|
|
30
|
+
store: SessionStore,
|
|
31
|
+
checkpoint_service: Any | None = None,
|
|
32
|
+
) -> None:
|
|
33
|
+
self._runner = process_runner
|
|
34
|
+
self._store = store
|
|
35
|
+
self._checkpoint_service = checkpoint_service
|
|
36
|
+
|
|
37
|
+
def start_session(
|
|
38
|
+
self,
|
|
39
|
+
task_id: str,
|
|
40
|
+
provider_id: ProviderId,
|
|
41
|
+
native_session_id: str | None = None,
|
|
42
|
+
metadata: dict[str, Any] | None = None,
|
|
43
|
+
resumed_from_session_id: str | None = None,
|
|
44
|
+
) -> Session:
|
|
45
|
+
"""Create and persist a running Session immediately before provider work begins."""
|
|
46
|
+
session = Session(
|
|
47
|
+
task_id=task_id,
|
|
48
|
+
provider_id=provider_id,
|
|
49
|
+
native_session_id=native_session_id,
|
|
50
|
+
resumed_from_session_id=resumed_from_session_id,
|
|
51
|
+
status=SessionStatus.RUNNING,
|
|
52
|
+
started_at=utc_now(),
|
|
53
|
+
metadata=metadata or {},
|
|
54
|
+
)
|
|
55
|
+
self._store.save_session(session)
|
|
56
|
+
return session
|
|
57
|
+
|
|
58
|
+
def attach_native_session_id(self, session: Session, native_session_id: str) -> Session:
|
|
59
|
+
"""Bind a provider-native conversation identifier to an existing Session."""
|
|
60
|
+
updated = session.model_copy(update={"native_session_id": native_session_id})
|
|
61
|
+
self._store.save_session(updated)
|
|
62
|
+
return updated
|
|
63
|
+
|
|
64
|
+
def fail_session(
|
|
65
|
+
self,
|
|
66
|
+
session: Session,
|
|
67
|
+
exit_reason: SessionExitReason,
|
|
68
|
+
exit_code: int | None = None,
|
|
69
|
+
) -> Session:
|
|
70
|
+
"""Mark a Session failed before or instead of an interactive process run."""
|
|
71
|
+
updated = session.model_copy(
|
|
72
|
+
update={
|
|
73
|
+
"status": SessionStatus.FAILED,
|
|
74
|
+
"ended_at": utc_now(),
|
|
75
|
+
"exit_reason": exit_reason,
|
|
76
|
+
"exit_code": exit_code,
|
|
77
|
+
}
|
|
78
|
+
)
|
|
79
|
+
self._store.save_session(updated)
|
|
80
|
+
return updated
|
|
81
|
+
|
|
82
|
+
def run(
|
|
83
|
+
self,
|
|
84
|
+
session: Session,
|
|
85
|
+
launch_spec: LaunchSpecification,
|
|
86
|
+
on_launch: Callable[[LaunchSpecification, Session], None] | None = None,
|
|
87
|
+
mcp_binder: ManagedMcpBinder | None = None,
|
|
88
|
+
) -> Session:
|
|
89
|
+
"""Run the native provider process and finalize the Session lifecycle.
|
|
90
|
+
|
|
91
|
+
This is the only place a managed MCP binding is minted, and it is minted from the
|
|
92
|
+
persisted Session rather than from anything a provider or model supplied. Providers
|
|
93
|
+
that configure the CortexShift MCP server per launch declare the binding inside
|
|
94
|
+
that configuration, because a provider decides for itself how much of CortexShift's
|
|
95
|
+
environment the MCP server it spawns will actually see.
|
|
96
|
+
|
|
97
|
+
Generic non-zero exits map strictly to `process_crashed`; CortexShift never infers
|
|
98
|
+
quota exhaustion or rate limiting from an exit code. Spawn failures map to
|
|
99
|
+
`spawn_failed` and re-raise so the caller can classify the operation as failed.
|
|
100
|
+
"""
|
|
101
|
+
try:
|
|
102
|
+
binding = McpSessionBinding.from_session(
|
|
103
|
+
session=session,
|
|
104
|
+
project_root=launch_spec.cwd,
|
|
105
|
+
)
|
|
106
|
+
if mcp_binder is not None:
|
|
107
|
+
launch_spec = mcp_binder.bind_managed_mcp(launch_spec, binding)
|
|
108
|
+
|
|
109
|
+
if on_launch:
|
|
110
|
+
on_launch(launch_spec, session)
|
|
111
|
+
|
|
112
|
+
# A launch specification may contribute environment of its own, but the trusted
|
|
113
|
+
# binding always wins: execution context is never negotiable by an adapter.
|
|
114
|
+
binding_env: dict[str, str] = dict(launch_spec.env)
|
|
115
|
+
binding_env.update(binding.to_env())
|
|
116
|
+
|
|
117
|
+
exit_code = self._runner.run_interactive(
|
|
118
|
+
argv=launch_spec.argv,
|
|
119
|
+
cwd=launch_spec.cwd,
|
|
120
|
+
env=binding_env,
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
ended_at = utc_now()
|
|
124
|
+
if exit_code == 0:
|
|
125
|
+
session = session.model_copy(
|
|
126
|
+
update={
|
|
127
|
+
"status": SessionStatus.COMPLETED,
|
|
128
|
+
"ended_at": ended_at,
|
|
129
|
+
"exit_code": 0,
|
|
130
|
+
"exit_reason": SessionExitReason.NORMAL_COMPLETION,
|
|
131
|
+
}
|
|
132
|
+
)
|
|
133
|
+
elif exit_code in (130, -2):
|
|
134
|
+
session = session.model_copy(
|
|
135
|
+
update={
|
|
136
|
+
"status": SessionStatus.INTERRUPTED,
|
|
137
|
+
"ended_at": ended_at,
|
|
138
|
+
"exit_code": 130,
|
|
139
|
+
"exit_reason": SessionExitReason.USER_INTERRUPTED,
|
|
140
|
+
}
|
|
141
|
+
)
|
|
142
|
+
else:
|
|
143
|
+
session = session.model_copy(
|
|
144
|
+
update={
|
|
145
|
+
"status": SessionStatus.FAILED,
|
|
146
|
+
"ended_at": ended_at,
|
|
147
|
+
"exit_code": exit_code,
|
|
148
|
+
"exit_reason": SessionExitReason.PROCESS_CRASHED,
|
|
149
|
+
}
|
|
150
|
+
)
|
|
151
|
+
except KeyboardInterrupt:
|
|
152
|
+
session = session.model_copy(
|
|
153
|
+
update={
|
|
154
|
+
"status": SessionStatus.INTERRUPTED,
|
|
155
|
+
"ended_at": utc_now(),
|
|
156
|
+
"exit_code": 130,
|
|
157
|
+
"exit_reason": SessionExitReason.USER_INTERRUPTED,
|
|
158
|
+
}
|
|
159
|
+
)
|
|
160
|
+
except Exception:
|
|
161
|
+
session = session.model_copy(
|
|
162
|
+
update={
|
|
163
|
+
"status": SessionStatus.FAILED,
|
|
164
|
+
"ended_at": utc_now(),
|
|
165
|
+
"exit_reason": SessionExitReason.SPAWN_FAILED,
|
|
166
|
+
}
|
|
167
|
+
)
|
|
168
|
+
self._store.save_session(session)
|
|
169
|
+
raise
|
|
170
|
+
|
|
171
|
+
self._store.save_session(session)
|
|
172
|
+
|
|
173
|
+
# Automatic session-end checkpoint capture for completed, failed, or interrupted runs
|
|
174
|
+
if self._checkpoint_service is not None and hasattr(
|
|
175
|
+
self._checkpoint_service, "capture_session_end_checkpoint"
|
|
176
|
+
):
|
|
177
|
+
with contextlib.suppress(Exception):
|
|
178
|
+
self._checkpoint_service.capture_session_end_checkpoint(
|
|
179
|
+
session=session,
|
|
180
|
+
store=self._store,
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
return session
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Application service for querying CortexShift session history."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from cortexshift.adapters.sqlite.store import SQLiteStateStore
|
|
6
|
+
from cortexshift.application.locator import ProjectLocator
|
|
7
|
+
from cortexshift.domain.errors import ProjectNotInitializedError, SessionNotFoundError
|
|
8
|
+
from cortexshift.domain.session import Session
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class SessionService:
|
|
12
|
+
"""Provides querying capabilities for agent execution sessions."""
|
|
13
|
+
|
|
14
|
+
def __init__(self, project_locator: type[ProjectLocator] = ProjectLocator) -> None:
|
|
15
|
+
self._locator = project_locator
|
|
16
|
+
|
|
17
|
+
def list_sessions(
|
|
18
|
+
self,
|
|
19
|
+
start_dir: Path | str | None = None,
|
|
20
|
+
limit: int = 20,
|
|
21
|
+
) -> list[Session]:
|
|
22
|
+
"""List sessions for the current project, ordered newest first."""
|
|
23
|
+
start_path = Path(start_dir) if start_dir is not None else None
|
|
24
|
+
project_root = self._locator.find_project_root(start_path)
|
|
25
|
+
if project_root is None:
|
|
26
|
+
raise ProjectNotInitializedError()
|
|
27
|
+
|
|
28
|
+
db_path = self._locator.get_database_path(project_root)
|
|
29
|
+
store = SQLiteStateStore(db_path, auto_migrate=False)
|
|
30
|
+
try:
|
|
31
|
+
project = store.get_default_project()
|
|
32
|
+
if project is None:
|
|
33
|
+
raise ProjectNotInitializedError()
|
|
34
|
+
|
|
35
|
+
return store.list_sessions(project_id=project.id, limit=limit)
|
|
36
|
+
finally:
|
|
37
|
+
store.close()
|
|
38
|
+
|
|
39
|
+
def get_session(
|
|
40
|
+
self,
|
|
41
|
+
session_id: str,
|
|
42
|
+
start_dir: Path | str | None = None,
|
|
43
|
+
) -> Session:
|
|
44
|
+
"""Retrieve a specific session by ID.
|
|
45
|
+
|
|
46
|
+
Raises:
|
|
47
|
+
ProjectNotInitializedError: If no project is found.
|
|
48
|
+
SessionNotFoundError: If the session ID does not exist.
|
|
49
|
+
"""
|
|
50
|
+
start_path = Path(start_dir) if start_dir is not None else None
|
|
51
|
+
project_root = self._locator.find_project_root(start_path)
|
|
52
|
+
if project_root is None:
|
|
53
|
+
raise ProjectNotInitializedError()
|
|
54
|
+
|
|
55
|
+
db_path = self._locator.get_database_path(project_root)
|
|
56
|
+
store = SQLiteStateStore(db_path, auto_migrate=False)
|
|
57
|
+
try:
|
|
58
|
+
session = store.get_session(session_id)
|
|
59
|
+
if session is None:
|
|
60
|
+
raise SessionNotFoundError(session_id)
|
|
61
|
+
return session
|
|
62
|
+
finally:
|
|
63
|
+
store.close()
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Selection of the CortexShift Session a handoff originates from.
|
|
2
|
+
|
|
3
|
+
Source selection reads only durable CortexShift session records. Provider transcript
|
|
4
|
+
history, hidden conversation storage, and provider-native session databases are never
|
|
5
|
+
consulted, and the outgoing provider is never contacted.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from cortexshift.domain.errors import (
|
|
9
|
+
NoSourceSessionError,
|
|
10
|
+
SessionNotFoundError,
|
|
11
|
+
SessionTaskMismatchError,
|
|
12
|
+
)
|
|
13
|
+
from cortexshift.domain.session import Session, SessionExitReason
|
|
14
|
+
from cortexshift.domain.task import Task
|
|
15
|
+
from cortexshift.ports.session_store import SessionStore
|
|
16
|
+
|
|
17
|
+
# How far back to look for a meaningful prior session on the active task.
|
|
18
|
+
_SOURCE_LOOKUP_LIMIT = 100
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _is_meaningful(session: Session) -> bool:
|
|
22
|
+
"""Whether a session represents real prior agent work.
|
|
23
|
+
|
|
24
|
+
A session whose provider process never spawned contributed nothing, so it is not
|
|
25
|
+
preferred as a handoff source while an earlier real session exists.
|
|
26
|
+
"""
|
|
27
|
+
return session.exit_reason != SessionExitReason.SPAWN_FAILED
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def select_source_session(
|
|
31
|
+
store: SessionStore,
|
|
32
|
+
task: Task,
|
|
33
|
+
explicit_session_id: str | None = None,
|
|
34
|
+
) -> Session:
|
|
35
|
+
"""Resolve the source Session for a handoff on the given active task.
|
|
36
|
+
|
|
37
|
+
With no explicit override, the most recent meaningful session on the active task
|
|
38
|
+
wins, falling back to the most recent session overall when every recorded session
|
|
39
|
+
failed to spawn.
|
|
40
|
+
|
|
41
|
+
Raises:
|
|
42
|
+
SessionNotFoundError: If an explicitly requested session does not exist.
|
|
43
|
+
SessionTaskMismatchError: If an explicit session belongs to a different task.
|
|
44
|
+
NoSourceSessionError: If the active task has no prior session at all.
|
|
45
|
+
"""
|
|
46
|
+
if explicit_session_id:
|
|
47
|
+
session = store.get_session(explicit_session_id)
|
|
48
|
+
if session is None:
|
|
49
|
+
raise SessionNotFoundError(explicit_session_id)
|
|
50
|
+
if session.task_id != task.id:
|
|
51
|
+
raise SessionTaskMismatchError(explicit_session_id, task.id)
|
|
52
|
+
return session
|
|
53
|
+
|
|
54
|
+
sessions = store.list_sessions(task_id=task.id, limit=_SOURCE_LOOKUP_LIMIT)
|
|
55
|
+
if not sessions:
|
|
56
|
+
raise NoSourceSessionError()
|
|
57
|
+
|
|
58
|
+
for session in sessions:
|
|
59
|
+
if _is_meaningful(session):
|
|
60
|
+
return session
|
|
61
|
+
|
|
62
|
+
return sessions[0]
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""Application service for assembling project status and active task metrics."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from cortexshift.adapters.sqlite.store import SQLiteStateStore
|
|
6
|
+
from cortexshift.application.locator import (
|
|
7
|
+
DATABASE_FILE_NAME,
|
|
8
|
+
STATE_DIR_NAME,
|
|
9
|
+
ProjectLocator,
|
|
10
|
+
)
|
|
11
|
+
from cortexshift.domain.errors import ProjectNotInitializedError
|
|
12
|
+
from cortexshift.domain.status import ActiveTaskSummary, ProgressSummary, ProjectStatus
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ProjectStatusService:
|
|
16
|
+
"""Queries project persistence to construct canonical status models."""
|
|
17
|
+
|
|
18
|
+
def __init__(self, locator: type[ProjectLocator] = ProjectLocator) -> None:
|
|
19
|
+
self.locator = locator
|
|
20
|
+
|
|
21
|
+
def get_status(self, start_path: Path | str | None = None) -> ProjectStatus:
|
|
22
|
+
"""Inspect the project root for the given path and return structured status.
|
|
23
|
+
|
|
24
|
+
Args:
|
|
25
|
+
start_path: Starting path or project directory. Defaults to Path.cwd().
|
|
26
|
+
|
|
27
|
+
Returns:
|
|
28
|
+
A populated ProjectStatus entity.
|
|
29
|
+
|
|
30
|
+
Raises:
|
|
31
|
+
ProjectNotInitializedError: If no initialized project root is found.
|
|
32
|
+
DatabaseStateError: If the database is inaccessible or corrupted.
|
|
33
|
+
UnsupportedSchemaVersionError: If the database is at an incompatible schema version.
|
|
34
|
+
"""
|
|
35
|
+
raw_path = Path(start_path) if start_path is not None else Path.cwd()
|
|
36
|
+
project_root = self.locator.find_project_root(raw_path)
|
|
37
|
+
if project_root is None:
|
|
38
|
+
raise ProjectNotInitializedError()
|
|
39
|
+
|
|
40
|
+
db_path = self.locator.get_database_path(project_root)
|
|
41
|
+
with SQLiteStateStore(db_path, auto_migrate=False) as store:
|
|
42
|
+
project = store.get_default_project()
|
|
43
|
+
if project is None:
|
|
44
|
+
raise ProjectNotInitializedError()
|
|
45
|
+
|
|
46
|
+
schema_version = store.get_schema_version()
|
|
47
|
+
active_id = store.get_active_task_id(project.id)
|
|
48
|
+
|
|
49
|
+
active_task_summary: ActiveTaskSummary | None = None
|
|
50
|
+
if active_id is not None:
|
|
51
|
+
active_task = store.get_task(active_id)
|
|
52
|
+
if active_task is not None:
|
|
53
|
+
active_task_summary = ActiveTaskSummary(
|
|
54
|
+
id=active_task.id,
|
|
55
|
+
title=active_task.title,
|
|
56
|
+
status=active_task.status,
|
|
57
|
+
objective=active_task.objective,
|
|
58
|
+
progress=ProgressSummary(
|
|
59
|
+
completed=len(active_task.completed_items),
|
|
60
|
+
remaining=len(active_task.remaining_items),
|
|
61
|
+
issues=len(active_task.known_issues),
|
|
62
|
+
),
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
state_rel_path = f"{STATE_DIR_NAME}/{DATABASE_FILE_NAME}"
|
|
66
|
+
return ProjectStatus(
|
|
67
|
+
project_id=project.id,
|
|
68
|
+
name=project.name,
|
|
69
|
+
repo_path=project.repo_path,
|
|
70
|
+
state_file=state_rel_path,
|
|
71
|
+
schema_version=schema_version,
|
|
72
|
+
active_task=active_task_summary,
|
|
73
|
+
)
|