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,65 @@
|
|
|
1
|
+
"""Exact resume selection from canonical CortexShift records only."""
|
|
2
|
+
|
|
3
|
+
from cortexshift.domain.errors import (
|
|
4
|
+
NativeResumeError,
|
|
5
|
+
SessionNotFoundError,
|
|
6
|
+
SessionTaskMismatchError,
|
|
7
|
+
)
|
|
8
|
+
from cortexshift.domain.native_session import NativeSessionCapabilities, valid_native_id
|
|
9
|
+
from cortexshift.domain.provider import ProviderId
|
|
10
|
+
from cortexshift.domain.session import Session, SessionExitReason, SessionStatus
|
|
11
|
+
from cortexshift.ports.session_store import SessionStore
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def is_native_resumable(session: Session, capabilities: NativeSessionCapabilities) -> bool:
|
|
15
|
+
"""Require a known ID and evidence that the provider process actually launched.
|
|
16
|
+
|
|
17
|
+
An allocated ID on an unfinished or spawn-failed invocation is not enough.
|
|
18
|
+
Exit evidence remains valid even when a launched process crashed or was interrupted.
|
|
19
|
+
"""
|
|
20
|
+
return (
|
|
21
|
+
capabilities.supports_exact_resume
|
|
22
|
+
and valid_native_id(session.native_session_id)
|
|
23
|
+
and session.status
|
|
24
|
+
in (SessionStatus.COMPLETED, SessionStatus.INTERRUPTED, SessionStatus.FAILED)
|
|
25
|
+
and session.exit_code is not None
|
|
26
|
+
and session.exit_reason != SessionExitReason.SPAWN_FAILED
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def select_native_session(
|
|
31
|
+
store: SessionStore,
|
|
32
|
+
task_id: str,
|
|
33
|
+
provider_id: ProviderId,
|
|
34
|
+
capabilities: NativeSessionCapabilities,
|
|
35
|
+
explicit_session_id: str | None = None,
|
|
36
|
+
) -> Session | None:
|
|
37
|
+
if explicit_session_id is not None:
|
|
38
|
+
session = store.get_session(explicit_session_id)
|
|
39
|
+
if session is None:
|
|
40
|
+
raise SessionNotFoundError(explicit_session_id)
|
|
41
|
+
if session.task_id != task_id:
|
|
42
|
+
raise SessionTaskMismatchError(explicit_session_id, task_id)
|
|
43
|
+
if session.provider_id != provider_id:
|
|
44
|
+
raise NativeResumeError("The selected CortexShift Session belongs to another provider.")
|
|
45
|
+
if not is_native_resumable(session, capabilities):
|
|
46
|
+
raise NativeResumeError("The selected CortexShift Session is not exactly resumable.")
|
|
47
|
+
return session
|
|
48
|
+
|
|
49
|
+
return next(
|
|
50
|
+
(
|
|
51
|
+
session
|
|
52
|
+
for session in store.list_sessions(task_id=task_id, limit=None)
|
|
53
|
+
if session.provider_id == provider_id and is_native_resumable(session, capabilities)
|
|
54
|
+
),
|
|
55
|
+
None,
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def native_capabilities(adapter: object) -> NativeSessionCapabilities:
|
|
60
|
+
"""Unsupported adapters honestly advertise no native continuity."""
|
|
61
|
+
from cortexshift.ports.native_session import ProviderNativeSessionAdapter
|
|
62
|
+
|
|
63
|
+
if isinstance(adapter, ProviderNativeSessionAdapter):
|
|
64
|
+
return adapter.get_native_capabilities()
|
|
65
|
+
return NativeSessionCapabilities()
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
"""Application service for crash recovery and stale session reconciliation."""
|
|
2
|
+
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
8
|
+
|
|
9
|
+
from cortexshift.adapters.git.inspector import GitRepositoryInspector
|
|
10
|
+
from cortexshift.adapters.sqlite.store import SQLiteStateStore
|
|
11
|
+
from cortexshift.adapters.workspace_lease import FileWorkspaceLeaseManager
|
|
12
|
+
from cortexshift.application.checkpoint_builder import CheckpointBuilder
|
|
13
|
+
from cortexshift.application.handoff_builder import derive_files_touched
|
|
14
|
+
from cortexshift.application.locator import ProjectLocator
|
|
15
|
+
from cortexshift.domain.checkpoint import CheckpointKind
|
|
16
|
+
from cortexshift.domain.errors import (
|
|
17
|
+
GitProbeError,
|
|
18
|
+
NoActiveTaskError,
|
|
19
|
+
ProjectNotInitializedError,
|
|
20
|
+
WorkspaceLockedError,
|
|
21
|
+
)
|
|
22
|
+
from cortexshift.domain.git import RepositoryInspectionStatus
|
|
23
|
+
from cortexshift.domain.identifiers import utc_now
|
|
24
|
+
from cortexshift.domain.session import SessionExitReason, SessionStatus
|
|
25
|
+
from cortexshift.ports.repository import RepositoryInspector
|
|
26
|
+
from cortexshift.ports.workspace_lease import WorkspaceLeaseManager
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class RecoveryReport(BaseModel):
|
|
30
|
+
"""Structured representation of crash recovery results."""
|
|
31
|
+
|
|
32
|
+
model_config = ConfigDict(frozen=True)
|
|
33
|
+
|
|
34
|
+
project_id: str
|
|
35
|
+
task_id: str
|
|
36
|
+
task_title: str
|
|
37
|
+
stale_session_ids: list[str] = Field(default_factory=list)
|
|
38
|
+
reconciled_session_ids: list[str] = Field(default_factory=list)
|
|
39
|
+
checkpoint_id: str | None = None
|
|
40
|
+
git_snapshot_id: str | None = None
|
|
41
|
+
repository_status: RepositoryInspectionStatus
|
|
42
|
+
dirty: bool = False
|
|
43
|
+
files_touched: list[str] = Field(default_factory=list)
|
|
44
|
+
dry_run: bool = False
|
|
45
|
+
created_at: datetime = Field(default_factory=utc_now)
|
|
46
|
+
metadata: dict[str, Any] = Field(default_factory=dict)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class RecoveryService:
|
|
50
|
+
"""Coordinates crash recovery, lease acquisition, and session reconciliation."""
|
|
51
|
+
|
|
52
|
+
def __init__(
|
|
53
|
+
self,
|
|
54
|
+
inspector: RepositoryInspector | None = None,
|
|
55
|
+
lease_manager: WorkspaceLeaseManager | None = None,
|
|
56
|
+
) -> None:
|
|
57
|
+
self._inspector = inspector or GitRepositoryInspector()
|
|
58
|
+
self._lease_manager = lease_manager or FileWorkspaceLeaseManager()
|
|
59
|
+
|
|
60
|
+
def recover(
|
|
61
|
+
self,
|
|
62
|
+
start_dir: Path | str | None = None,
|
|
63
|
+
*,
|
|
64
|
+
dry_run: bool = False,
|
|
65
|
+
) -> RecoveryReport:
|
|
66
|
+
"""Reconcile interrupted orchestration sessions and capture a Recovery Checkpoint.
|
|
67
|
+
|
|
68
|
+
Requires an exclusive workspace lease to verify no active CortexShift provider
|
|
69
|
+
process currently owns the workspace.
|
|
70
|
+
"""
|
|
71
|
+
start_path = Path(start_dir) if start_dir is not None else None
|
|
72
|
+
project_root = ProjectLocator.find_project_root(start_path)
|
|
73
|
+
if project_root is None:
|
|
74
|
+
raise ProjectNotInitializedError()
|
|
75
|
+
|
|
76
|
+
db_path = ProjectLocator.get_database_path(project_root)
|
|
77
|
+
store = SQLiteStateStore(db_path, auto_migrate=False)
|
|
78
|
+
|
|
79
|
+
try:
|
|
80
|
+
project = store.get_default_project()
|
|
81
|
+
if project is None:
|
|
82
|
+
raise ProjectNotInitializedError()
|
|
83
|
+
|
|
84
|
+
active_task_id = store.get_active_task_id(project.id)
|
|
85
|
+
if not active_task_id:
|
|
86
|
+
raise NoActiveTaskError()
|
|
87
|
+
|
|
88
|
+
task = store.get_task(active_task_id)
|
|
89
|
+
if task is None:
|
|
90
|
+
raise NoActiveTaskError()
|
|
91
|
+
|
|
92
|
+
lease = self._lease_manager.get_lease(Path(project.repo_path))
|
|
93
|
+
|
|
94
|
+
if dry_run:
|
|
95
|
+
# Probe lease non-destructively
|
|
96
|
+
if not lease.acquire():
|
|
97
|
+
raise WorkspaceLockedError(lock_path=lease.lock_path)
|
|
98
|
+
lease.release()
|
|
99
|
+
|
|
100
|
+
# Find stale sessions
|
|
101
|
+
sessions = store.list_sessions(task_id=task.id, limit=None)
|
|
102
|
+
stale_sessions = [
|
|
103
|
+
s
|
|
104
|
+
for s in sessions
|
|
105
|
+
if s.status in (SessionStatus.INITIALIZING, SessionStatus.RUNNING)
|
|
106
|
+
]
|
|
107
|
+
stale_ids = [s.id for s in stale_sessions]
|
|
108
|
+
|
|
109
|
+
inspection = self._inspector.inspect(
|
|
110
|
+
project_root=Path(project.repo_path),
|
|
111
|
+
project_id=project.id,
|
|
112
|
+
)
|
|
113
|
+
if inspection.status == RepositoryInspectionStatus.PROBE_ERROR:
|
|
114
|
+
raise GitProbeError(
|
|
115
|
+
inspection.diagnostic
|
|
116
|
+
or "Git repository inspection failed during recovery probe."
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
dirty = inspection.snapshot.dirty if inspection.snapshot else False
|
|
120
|
+
files_touched = derive_files_touched(inspection)
|
|
121
|
+
|
|
122
|
+
return RecoveryReport(
|
|
123
|
+
project_id=project.id,
|
|
124
|
+
task_id=task.id,
|
|
125
|
+
task_title=task.title,
|
|
126
|
+
stale_session_ids=stale_ids,
|
|
127
|
+
reconciled_session_ids=stale_ids,
|
|
128
|
+
checkpoint_id=None,
|
|
129
|
+
git_snapshot_id=None,
|
|
130
|
+
repository_status=inspection.status,
|
|
131
|
+
dirty=dirty,
|
|
132
|
+
files_touched=files_touched,
|
|
133
|
+
dry_run=True,
|
|
134
|
+
metadata={
|
|
135
|
+
"would_reconcile": bool(stale_sessions),
|
|
136
|
+
"would_create_checkpoint": bool(stale_sessions),
|
|
137
|
+
},
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
# Non-dry-run: acquire lease
|
|
141
|
+
if not lease.acquire():
|
|
142
|
+
raise WorkspaceLockedError(lock_path=lease.lock_path)
|
|
143
|
+
|
|
144
|
+
try:
|
|
145
|
+
sessions = store.list_sessions(task_id=task.id, limit=None)
|
|
146
|
+
stale_sessions = [
|
|
147
|
+
s
|
|
148
|
+
for s in sessions
|
|
149
|
+
if s.status in (SessionStatus.INITIALIZING, SessionStatus.RUNNING)
|
|
150
|
+
]
|
|
151
|
+
stale_ids = [s.id for s in stale_sessions]
|
|
152
|
+
|
|
153
|
+
inspection = self._inspector.inspect(
|
|
154
|
+
project_root=Path(project.repo_path),
|
|
155
|
+
project_id=project.id,
|
|
156
|
+
)
|
|
157
|
+
if inspection.status == RepositoryInspectionStatus.PROBE_ERROR:
|
|
158
|
+
raise GitProbeError(
|
|
159
|
+
inspection.diagnostic or "Git repository inspection failed during recovery."
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
dirty = inspection.snapshot.dirty if inspection.snapshot else False
|
|
163
|
+
files_touched = derive_files_touched(inspection)
|
|
164
|
+
|
|
165
|
+
if not stale_sessions:
|
|
166
|
+
# Predictable no-op when no stale sessions exist
|
|
167
|
+
return RecoveryReport(
|
|
168
|
+
project_id=project.id,
|
|
169
|
+
task_id=task.id,
|
|
170
|
+
task_title=task.title,
|
|
171
|
+
stale_session_ids=[],
|
|
172
|
+
reconciled_session_ids=[],
|
|
173
|
+
checkpoint_id=None,
|
|
174
|
+
git_snapshot_id=None,
|
|
175
|
+
repository_status=inspection.status,
|
|
176
|
+
dirty=dirty,
|
|
177
|
+
files_touched=files_touched,
|
|
178
|
+
dry_run=False,
|
|
179
|
+
metadata={"message": "No stale sessions found to recover."},
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
# Save Git snapshot if ready
|
|
183
|
+
snapshot_id: str | None = None
|
|
184
|
+
if (
|
|
185
|
+
inspection.status == RepositoryInspectionStatus.READY
|
|
186
|
+
and inspection.snapshot is not None
|
|
187
|
+
):
|
|
188
|
+
store.save_snapshot(inspection.snapshot)
|
|
189
|
+
snapshot_id = inspection.snapshot.id
|
|
190
|
+
|
|
191
|
+
# Recovery Checkpoint associates with newest stale session
|
|
192
|
+
primary_stale = stale_sessions[0]
|
|
193
|
+
checkpoint = CheckpointBuilder.build(
|
|
194
|
+
project=project,
|
|
195
|
+
task=task,
|
|
196
|
+
inspection=inspection,
|
|
197
|
+
kind=CheckpointKind.RECOVERY,
|
|
198
|
+
snapshot_id=snapshot_id,
|
|
199
|
+
session=primary_stale,
|
|
200
|
+
metadata={"reconciled_session_ids": stale_ids},
|
|
201
|
+
)
|
|
202
|
+
store.save_checkpoint(checkpoint)
|
|
203
|
+
|
|
204
|
+
# Reconcile all stale sessions
|
|
205
|
+
recovery_time = utc_now()
|
|
206
|
+
reconciled_ids: list[str] = []
|
|
207
|
+
for s in stale_sessions:
|
|
208
|
+
reconciled = s.model_copy(
|
|
209
|
+
update={
|
|
210
|
+
"status": SessionStatus.INTERRUPTED,
|
|
211
|
+
"exit_reason": SessionExitReason.UNEXPECTED_TERMINATION,
|
|
212
|
+
"reconciled_at": recovery_time,
|
|
213
|
+
}
|
|
214
|
+
)
|
|
215
|
+
store.save_session(reconciled)
|
|
216
|
+
reconciled_ids.append(reconciled.id)
|
|
217
|
+
|
|
218
|
+
return RecoveryReport(
|
|
219
|
+
project_id=project.id,
|
|
220
|
+
task_id=task.id,
|
|
221
|
+
task_title=task.title,
|
|
222
|
+
stale_session_ids=stale_ids,
|
|
223
|
+
reconciled_session_ids=reconciled_ids,
|
|
224
|
+
checkpoint_id=checkpoint.id,
|
|
225
|
+
git_snapshot_id=snapshot_id,
|
|
226
|
+
repository_status=inspection.status,
|
|
227
|
+
dirty=dirty,
|
|
228
|
+
files_touched=files_touched,
|
|
229
|
+
dry_run=False,
|
|
230
|
+
metadata={"reconciled_count": len(reconciled_ids)},
|
|
231
|
+
)
|
|
232
|
+
finally:
|
|
233
|
+
lease.release()
|
|
234
|
+
finally:
|
|
235
|
+
store.close()
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""Application service for Git repository inspection and snapshot persistence."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Iterator
|
|
4
|
+
from contextlib import contextmanager
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from cortexshift.adapters.git.inspector import GitRepositoryInspector
|
|
8
|
+
from cortexshift.adapters.sqlite.store import SQLiteStateStore
|
|
9
|
+
from cortexshift.application.locator import ProjectLocator
|
|
10
|
+
from cortexshift.domain.errors import (
|
|
11
|
+
GitNotInstalledError,
|
|
12
|
+
GitProbeError,
|
|
13
|
+
GitProbeTimeoutError,
|
|
14
|
+
NotAGitRepositoryError,
|
|
15
|
+
ProjectNotInitializedError,
|
|
16
|
+
)
|
|
17
|
+
from cortexshift.domain.git import (
|
|
18
|
+
GitSnapshot,
|
|
19
|
+
RepositoryInspection,
|
|
20
|
+
RepositoryInspectionStatus,
|
|
21
|
+
)
|
|
22
|
+
from cortexshift.domain.project import Project
|
|
23
|
+
from cortexshift.ports.repository import RepositoryInspector, RepositorySnapshotStore
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class RepositoryService:
|
|
27
|
+
"""Coordinates live Git repository inspection and snapshot persistence.
|
|
28
|
+
|
|
29
|
+
Enforces architecture layering:
|
|
30
|
+
- Never executes raw subprocess commands directly (delegates to RepositoryInspector).
|
|
31
|
+
- Never executes raw SQL statements directly (delegates to RepositorySnapshotStore).
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
def __init__(
|
|
35
|
+
self,
|
|
36
|
+
inspector: RepositoryInspector | None = None,
|
|
37
|
+
store: RepositorySnapshotStore | None = None,
|
|
38
|
+
locator: type[ProjectLocator] = ProjectLocator,
|
|
39
|
+
) -> None:
|
|
40
|
+
self._inspector = inspector or GitRepositoryInspector()
|
|
41
|
+
self._store = store
|
|
42
|
+
self._locator = locator
|
|
43
|
+
|
|
44
|
+
@contextmanager
|
|
45
|
+
def _resolve_project_and_store(
|
|
46
|
+
self, start_path: Path | None = None
|
|
47
|
+
) -> Iterator[tuple[Path, Project, RepositorySnapshotStore]]:
|
|
48
|
+
"""Locate the initialized project and yield its snapshot store.
|
|
49
|
+
|
|
50
|
+
A store opened here is closed on exit. An injected store belongs to its caller
|
|
51
|
+
and is left open, so callers that share one connection (the MCP server, for
|
|
52
|
+
instance) keep working.
|
|
53
|
+
"""
|
|
54
|
+
project_root = self._locator.find_project_root(start_path)
|
|
55
|
+
if project_root is None:
|
|
56
|
+
raise ProjectNotInitializedError()
|
|
57
|
+
|
|
58
|
+
if self._store is not None:
|
|
59
|
+
store = self._store
|
|
60
|
+
# If store is also a StateStore, fetch default project
|
|
61
|
+
if hasattr(store, "get_default_project"):
|
|
62
|
+
project = store.get_default_project()
|
|
63
|
+
if project is None:
|
|
64
|
+
raise ProjectNotInitializedError()
|
|
65
|
+
else:
|
|
66
|
+
project = Project(
|
|
67
|
+
id="proj_resolved",
|
|
68
|
+
name=project_root.name,
|
|
69
|
+
repo_path=str(project_root),
|
|
70
|
+
)
|
|
71
|
+
yield project_root, project, store
|
|
72
|
+
return
|
|
73
|
+
|
|
74
|
+
db_path = self._locator.get_database_path(project_root)
|
|
75
|
+
sqlite_store = SQLiteStateStore(db_path, auto_migrate=True)
|
|
76
|
+
try:
|
|
77
|
+
project = sqlite_store.get_default_project()
|
|
78
|
+
if project is None:
|
|
79
|
+
raise ProjectNotInitializedError()
|
|
80
|
+
yield project_root, project, sqlite_store
|
|
81
|
+
finally:
|
|
82
|
+
sqlite_store.close()
|
|
83
|
+
|
|
84
|
+
def inspect_repository(self, start_path: Path | None = None) -> RepositoryInspection:
|
|
85
|
+
"""Perform a live, non-persisting inspection of the repository."""
|
|
86
|
+
with self._resolve_project_and_store(start_path) as (project_root, project, _):
|
|
87
|
+
return self._inspector.inspect(project_root=project_root, project_id=project.id)
|
|
88
|
+
|
|
89
|
+
def capture_snapshot(self, start_path: Path | None = None) -> GitSnapshot:
|
|
90
|
+
"""Perform live inspection and persist the snapshot if successful.
|
|
91
|
+
|
|
92
|
+
Raises:
|
|
93
|
+
GitNotInstalledError: If Git is not found in PATH.
|
|
94
|
+
NotAGitRepositoryError: If the project is not in a Git repository.
|
|
95
|
+
GitProbeTimeoutError: If inspection timed out.
|
|
96
|
+
GitProbeError: If inspection failed.
|
|
97
|
+
"""
|
|
98
|
+
with self._resolve_project_and_store(start_path) as (project_root, project, store):
|
|
99
|
+
return self._capture(project_root, project, store)
|
|
100
|
+
|
|
101
|
+
def _capture(
|
|
102
|
+
self,
|
|
103
|
+
project_root: Path,
|
|
104
|
+
project: Project,
|
|
105
|
+
store: RepositorySnapshotStore,
|
|
106
|
+
) -> GitSnapshot:
|
|
107
|
+
"""Inspect the working tree and persist the resulting snapshot."""
|
|
108
|
+
inspection = self._inspector.inspect(project_root=project_root, project_id=project.id)
|
|
109
|
+
|
|
110
|
+
if inspection.status == RepositoryInspectionStatus.GIT_NOT_INSTALLED:
|
|
111
|
+
raise GitNotInstalledError(
|
|
112
|
+
inspection.diagnostic or "Git executable was not found in PATH."
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
if inspection.status == RepositoryInspectionStatus.NOT_GIT_REPOSITORY:
|
|
116
|
+
raise NotAGitRepositoryError(
|
|
117
|
+
inspection.diagnostic or "This CortexShift project is not inside a Git repository."
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
if inspection.status == RepositoryInspectionStatus.PROBE_ERROR:
|
|
121
|
+
diag = inspection.diagnostic or "Git repository inspection failed."
|
|
122
|
+
if "timed out" in diag.lower():
|
|
123
|
+
raise GitProbeTimeoutError(diag)
|
|
124
|
+
raise GitProbeError(diag)
|
|
125
|
+
|
|
126
|
+
if inspection.snapshot is None:
|
|
127
|
+
raise GitProbeError("No repository snapshot was generated.")
|
|
128
|
+
|
|
129
|
+
store.save_snapshot(inspection.snapshot)
|
|
130
|
+
return inspection.snapshot
|
|
131
|
+
|
|
132
|
+
def list_snapshots(
|
|
133
|
+
self,
|
|
134
|
+
project_id: str | None = None,
|
|
135
|
+
limit: int = 10,
|
|
136
|
+
start_path: Path | None = None,
|
|
137
|
+
) -> list[GitSnapshot]:
|
|
138
|
+
"""List historical snapshots for a project, newest first."""
|
|
139
|
+
with self._resolve_project_and_store(start_path) as (_, project, store):
|
|
140
|
+
pid = project_id or project.id
|
|
141
|
+
return store.list_snapshots(project_id=pid, limit=limit)
|
|
142
|
+
|
|
143
|
+
def get_snapshot(self, snapshot_id: str, start_path: Path | None = None) -> GitSnapshot | None:
|
|
144
|
+
"""Retrieve a stored snapshot by ID."""
|
|
145
|
+
with self._resolve_project_and_store(start_path) as (_, _, store):
|
|
146
|
+
return store.get_snapshot(snapshot_id)
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""Exact native resume without creating a handoff or repository snapshot."""
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from cortexshift.adapters.providers.antigravity import (
|
|
7
|
+
ANTIGRAVITY_MCP_MISSING_NOTICE,
|
|
8
|
+
is_antigravity_mcp_configured,
|
|
9
|
+
)
|
|
10
|
+
from cortexshift.application.native_session import select_native_session
|
|
11
|
+
from cortexshift.application.run_service import DryRunResult, RunService
|
|
12
|
+
from cortexshift.application.session_launcher import ProviderSessionLauncher
|
|
13
|
+
from cortexshift.domain.errors import NativeResumeError, TerminalRequiredError, WorkspaceLockedError
|
|
14
|
+
from cortexshift.domain.provider import PROVIDER_ANTIGRAVITY
|
|
15
|
+
from cortexshift.domain.session import Session
|
|
16
|
+
from cortexshift.ports.native_session import ProviderNativeSessionAdapter
|
|
17
|
+
from cortexshift.ports.provider import ManagedMcpBinder
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ResumeDryRunResult(DryRunResult):
|
|
21
|
+
source_session_id: str
|
|
22
|
+
native_session_id: str
|
|
23
|
+
native_session_known: bool = True
|
|
24
|
+
resume_strategy: str = "exact_native_id"
|
|
25
|
+
bootstrap_model_turn_required: bool = False
|
|
26
|
+
|
|
27
|
+
def to_dict(self) -> dict[str, object]:
|
|
28
|
+
return self.model_dump(mode="json")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class ResumeService(RunService):
|
|
32
|
+
"""Share run context resolution and injected execution boundaries; never nest leases."""
|
|
33
|
+
|
|
34
|
+
def resume(
|
|
35
|
+
self,
|
|
36
|
+
provider_name: str,
|
|
37
|
+
session_id: str | None = None,
|
|
38
|
+
start_dir: Path | str | None = None,
|
|
39
|
+
*,
|
|
40
|
+
dry_run: bool = False,
|
|
41
|
+
) -> Session | ResumeDryRunResult:
|
|
42
|
+
_, project, task, adapter, executable, store = self._resolve_context(
|
|
43
|
+
provider_name, start_dir
|
|
44
|
+
)
|
|
45
|
+
try:
|
|
46
|
+
if not isinstance(adapter, ProviderNativeSessionAdapter):
|
|
47
|
+
raise NativeResumeError("This provider does not support exact native resume.")
|
|
48
|
+
|
|
49
|
+
def select() -> Session:
|
|
50
|
+
source = select_native_session(
|
|
51
|
+
store,
|
|
52
|
+
task.id,
|
|
53
|
+
adapter.provider_id,
|
|
54
|
+
adapter.get_native_capabilities(),
|
|
55
|
+
session_id,
|
|
56
|
+
)
|
|
57
|
+
if source is None:
|
|
58
|
+
raise NativeResumeError(
|
|
59
|
+
f"No exact resumable {adapter.display_name} native session is recorded "
|
|
60
|
+
"for the active task. CortexShift will not guess a provider session. "
|
|
61
|
+
"Start a new native session with run, or switch to this provider instead."
|
|
62
|
+
)
|
|
63
|
+
return source
|
|
64
|
+
|
|
65
|
+
source = select()
|
|
66
|
+
assert source.native_session_id is not None
|
|
67
|
+
spec = adapter.build_exact_resume(
|
|
68
|
+
Path(project.repo_path), executable, source.native_session_id
|
|
69
|
+
)
|
|
70
|
+
if dry_run:
|
|
71
|
+
return ResumeDryRunResult(
|
|
72
|
+
provider_id=adapter.provider_id,
|
|
73
|
+
display_name=adapter.display_name,
|
|
74
|
+
executable=executable,
|
|
75
|
+
project_id=project.id,
|
|
76
|
+
project_name=project.name,
|
|
77
|
+
task_id=task.id,
|
|
78
|
+
task_title=task.title,
|
|
79
|
+
cwd=spec.cwd,
|
|
80
|
+
argv=spec.to_redacted_argv(),
|
|
81
|
+
mode="native_resume",
|
|
82
|
+
source_session_id=source.id,
|
|
83
|
+
native_session_id=source.native_session_id,
|
|
84
|
+
)
|
|
85
|
+
if not self._is_tty():
|
|
86
|
+
raise TerminalRequiredError(
|
|
87
|
+
"Interactive resume requires a terminal (TTY). "
|
|
88
|
+
f"Use cortexshift resume {provider_name} --dry-run to preview."
|
|
89
|
+
)
|
|
90
|
+
lease = self._lease_manager.get_lease(Path(project.repo_path))
|
|
91
|
+
if not lease.acquire():
|
|
92
|
+
raise WorkspaceLockedError(lock_path=lease.lock_path)
|
|
93
|
+
try:
|
|
94
|
+
# Select again under the lease in case another invocation just finished.
|
|
95
|
+
source = select()
|
|
96
|
+
assert source.native_session_id is not None
|
|
97
|
+
spec = adapter.build_exact_resume(
|
|
98
|
+
Path(project.repo_path), executable, source.native_session_id
|
|
99
|
+
)
|
|
100
|
+
launcher = ProviderSessionLauncher(
|
|
101
|
+
self._runner,
|
|
102
|
+
store,
|
|
103
|
+
checkpoint_service=self._checkpoint_service,
|
|
104
|
+
)
|
|
105
|
+
invocation = launcher.start_session(
|
|
106
|
+
task.id,
|
|
107
|
+
adapter.provider_id,
|
|
108
|
+
native_session_id=source.native_session_id,
|
|
109
|
+
resumed_from_session_id=source.id,
|
|
110
|
+
)
|
|
111
|
+
if str(adapter.provider_id) == str(
|
|
112
|
+
PROVIDER_ANTIGRAVITY
|
|
113
|
+
) and not is_antigravity_mcp_configured(Path(project.repo_path)):
|
|
114
|
+
sys.stderr.write(f"\n{ANTIGRAVITY_MCP_MISSING_NOTICE}\n\n")
|
|
115
|
+
|
|
116
|
+
return launcher.run(
|
|
117
|
+
invocation,
|
|
118
|
+
spec,
|
|
119
|
+
mcp_binder=adapter if isinstance(adapter, ManagedMcpBinder) else None,
|
|
120
|
+
)
|
|
121
|
+
finally:
|
|
122
|
+
lease.release()
|
|
123
|
+
finally:
|
|
124
|
+
store.close()
|