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,123 @@
|
|
|
1
|
+
"""File-based OS advisory lock implementation of the WorkspaceLease port."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from cortexshift.ports.workspace_lease import WorkspaceLease, WorkspaceLeaseManager
|
|
8
|
+
|
|
9
|
+
STATE_DIR_NAME = ".cortexshift"
|
|
10
|
+
LOCK_FILE_NAME = "agent.lock"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class FileWorkspaceLease(WorkspaceLease):
|
|
14
|
+
"""OS-level advisory file lock enforcing single-mutating-agent invariant per project."""
|
|
15
|
+
|
|
16
|
+
def __init__(self, lock_path: Path) -> None:
|
|
17
|
+
self._lock_path = Path(lock_path).resolve()
|
|
18
|
+
self._fd: int | None = None
|
|
19
|
+
|
|
20
|
+
@property
|
|
21
|
+
def lock_path(self) -> Path:
|
|
22
|
+
return self._lock_path
|
|
23
|
+
|
|
24
|
+
def acquire(self) -> bool:
|
|
25
|
+
"""Attempt non-blocking exclusive acquisition of the workspace lock."""
|
|
26
|
+
if self._fd is not None:
|
|
27
|
+
return True
|
|
28
|
+
|
|
29
|
+
self._lock_path.parent.mkdir(parents=True, exist_ok=True)
|
|
30
|
+
try:
|
|
31
|
+
fd = os.open(str(self._lock_path), os.O_RDWR | os.O_CREAT, 0o600)
|
|
32
|
+
except OSError:
|
|
33
|
+
return False
|
|
34
|
+
|
|
35
|
+
try:
|
|
36
|
+
if sys.platform == "win32":
|
|
37
|
+
import msvcrt
|
|
38
|
+
|
|
39
|
+
if os.path.getsize(str(self._lock_path)) == 0:
|
|
40
|
+
os.write(fd, b"\0")
|
|
41
|
+
os.lseek(fd, 0, os.SEEK_SET)
|
|
42
|
+
msvcrt.locking(fd, msvcrt.LK_NBLCK, 1)
|
|
43
|
+
else:
|
|
44
|
+
import fcntl
|
|
45
|
+
|
|
46
|
+
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
47
|
+
|
|
48
|
+
self._fd = fd
|
|
49
|
+
return True
|
|
50
|
+
except (BlockingIOError, OSError):
|
|
51
|
+
os.close(fd)
|
|
52
|
+
return False
|
|
53
|
+
|
|
54
|
+
def release(self) -> None:
|
|
55
|
+
"""Release the exclusive lock and close the underlying file descriptor."""
|
|
56
|
+
if self._fd is None:
|
|
57
|
+
return
|
|
58
|
+
|
|
59
|
+
try:
|
|
60
|
+
if sys.platform == "win32":
|
|
61
|
+
import msvcrt
|
|
62
|
+
|
|
63
|
+
os.lseek(self._fd, 0, os.SEEK_SET)
|
|
64
|
+
msvcrt.locking(self._fd, msvcrt.LK_UNLCK, 1)
|
|
65
|
+
else:
|
|
66
|
+
import fcntl
|
|
67
|
+
|
|
68
|
+
fcntl.flock(self._fd, fcntl.LOCK_UN)
|
|
69
|
+
except OSError:
|
|
70
|
+
pass
|
|
71
|
+
finally:
|
|
72
|
+
os.close(self._fd)
|
|
73
|
+
self._fd = None
|
|
74
|
+
|
|
75
|
+
def is_locked(self) -> bool:
|
|
76
|
+
"""Check whether the lock is currently held by any process."""
|
|
77
|
+
if self._fd is not None:
|
|
78
|
+
return True
|
|
79
|
+
if not self._lock_path.exists():
|
|
80
|
+
return False
|
|
81
|
+
try:
|
|
82
|
+
fd = os.open(str(self._lock_path), os.O_RDWR, 0o600)
|
|
83
|
+
except OSError:
|
|
84
|
+
return False
|
|
85
|
+
|
|
86
|
+
try:
|
|
87
|
+
if sys.platform == "win32":
|
|
88
|
+
import msvcrt
|
|
89
|
+
|
|
90
|
+
if os.path.getsize(str(self._lock_path)) == 0:
|
|
91
|
+
return False
|
|
92
|
+
os.lseek(fd, 0, os.SEEK_SET)
|
|
93
|
+
msvcrt.locking(fd, msvcrt.LK_NBLCK, 1)
|
|
94
|
+
os.lseek(fd, 0, os.SEEK_SET)
|
|
95
|
+
msvcrt.locking(fd, msvcrt.LK_UNLCK, 1)
|
|
96
|
+
else:
|
|
97
|
+
import fcntl
|
|
98
|
+
|
|
99
|
+
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
100
|
+
fcntl.flock(fd, fcntl.LOCK_UN)
|
|
101
|
+
return False
|
|
102
|
+
except (BlockingIOError, OSError):
|
|
103
|
+
return True
|
|
104
|
+
finally:
|
|
105
|
+
os.close(fd)
|
|
106
|
+
|
|
107
|
+
def __enter__(self) -> "FileWorkspaceLease":
|
|
108
|
+
if not self.acquire():
|
|
109
|
+
from cortexshift.domain.errors import WorkspaceLockedError
|
|
110
|
+
|
|
111
|
+
raise WorkspaceLockedError(lock_path=self._lock_path)
|
|
112
|
+
return self
|
|
113
|
+
|
|
114
|
+
def __exit__(self, exc_type: object, exc_val: object, exc_tb: object) -> None:
|
|
115
|
+
self.release()
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class FileWorkspaceLeaseManager(WorkspaceLeaseManager):
|
|
119
|
+
"""Standard file-backed workspace lease manager."""
|
|
120
|
+
|
|
121
|
+
def get_lease(self, project_root: Path) -> WorkspaceLease:
|
|
122
|
+
lock_path = project_root / STATE_DIR_NAME / LOCK_FILE_NAME
|
|
123
|
+
return FileWorkspaceLease(lock_path)
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Application layer for CortexShift use cases and workflows.
|
|
2
|
+
|
|
3
|
+
Orchestrates domain entities, ports, and lifecycle policies.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from cortexshift.application.checkpoint_builder import CheckpointBuilder
|
|
7
|
+
from cortexshift.application.checkpoint_service import CheckpointService
|
|
8
|
+
from cortexshift.application.doctor import DoctorService, UnknownProviderError
|
|
9
|
+
from cortexshift.application.init_service import (
|
|
10
|
+
ProjectInitializationService,
|
|
11
|
+
ProjectInitResult,
|
|
12
|
+
)
|
|
13
|
+
from cortexshift.application.locator import ProjectLocator
|
|
14
|
+
from cortexshift.application.recovery_service import RecoveryReport, RecoveryService
|
|
15
|
+
from cortexshift.application.repository_service import RepositoryService
|
|
16
|
+
from cortexshift.application.run_service import (
|
|
17
|
+
DryRunResult,
|
|
18
|
+
ProviderRuntimeRegistry,
|
|
19
|
+
RunService,
|
|
20
|
+
)
|
|
21
|
+
from cortexshift.application.session_service import SessionService
|
|
22
|
+
from cortexshift.application.status_service import ProjectStatusService
|
|
23
|
+
from cortexshift.application.task_service import TaskService
|
|
24
|
+
|
|
25
|
+
__all__ = [
|
|
26
|
+
"CheckpointBuilder",
|
|
27
|
+
"CheckpointService",
|
|
28
|
+
"DoctorService",
|
|
29
|
+
"DryRunResult",
|
|
30
|
+
"ProjectInitializationService",
|
|
31
|
+
"ProjectInitResult",
|
|
32
|
+
"ProjectLocator",
|
|
33
|
+
"ProjectStatusService",
|
|
34
|
+
"ProviderRuntimeRegistry",
|
|
35
|
+
"RecoveryReport",
|
|
36
|
+
"RecoveryService",
|
|
37
|
+
"RepositoryService",
|
|
38
|
+
"RunService",
|
|
39
|
+
"SessionService",
|
|
40
|
+
"TaskService",
|
|
41
|
+
"UnknownProviderError",
|
|
42
|
+
]
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
"""Deterministic construction of canonical checkpoint records and payloads.
|
|
2
|
+
|
|
3
|
+
The builder is pure: it performs no I/O, invokes no provider, and consumes no model
|
|
4
|
+
quota. Its inputs are canonical Project, canonical Task, live repository inspection,
|
|
5
|
+
optional Session metadata, and optional enrichment (decisions, test summary, note).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from cortexshift.application.handoff_builder import derive_files_touched
|
|
11
|
+
from cortexshift.domain.checkpoint import (
|
|
12
|
+
CHECKPOINT_PROTOCOL_VERSION,
|
|
13
|
+
MAX_DECISION_CHARS,
|
|
14
|
+
MAX_OPERATOR_NOTE_CHARS,
|
|
15
|
+
MAX_TEST_SUMMARY_CHARS,
|
|
16
|
+
CheckpointGitState,
|
|
17
|
+
CheckpointKind,
|
|
18
|
+
CheckpointPayload,
|
|
19
|
+
CheckpointRecord,
|
|
20
|
+
CheckpointSourceSession,
|
|
21
|
+
CheckpointTaskSnapshot,
|
|
22
|
+
CheckpointTestProvenance,
|
|
23
|
+
CheckpointTestStatus,
|
|
24
|
+
generate_checkpoint_id,
|
|
25
|
+
)
|
|
26
|
+
from cortexshift.domain.errors import InvalidCheckpointInputError
|
|
27
|
+
from cortexshift.domain.git import RepositoryInspection, RepositoryInspectionStatus
|
|
28
|
+
from cortexshift.domain.identifiers import utc_now
|
|
29
|
+
from cortexshift.domain.project import Project
|
|
30
|
+
from cortexshift.domain.session import Session
|
|
31
|
+
from cortexshift.domain.task import Task, TaskStatus
|
|
32
|
+
|
|
33
|
+
_GIT_NOTE_READY = (
|
|
34
|
+
"Live Git inspection succeeded at checkpoint time. "
|
|
35
|
+
"This is a historical observation, not current truth; re-check with `git status`."
|
|
36
|
+
)
|
|
37
|
+
_GIT_NOTE_NOT_INSTALLED = (
|
|
38
|
+
"Git was not found on this machine at checkpoint time. No repository state could be observed."
|
|
39
|
+
)
|
|
40
|
+
_GIT_NOTE_NOT_REPOSITORY = (
|
|
41
|
+
"This CortexShift project is not inside a Git repository. "
|
|
42
|
+
"No repository state could be observed."
|
|
43
|
+
)
|
|
44
|
+
_GIT_NOTE_PROBE_ERROR = (
|
|
45
|
+
"Git repository inspection failed at checkpoint time. "
|
|
46
|
+
"Treat any repository claim below as unverified and inspect the workspace directly."
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
_GIT_NOTES: dict[RepositoryInspectionStatus, str] = {
|
|
50
|
+
RepositoryInspectionStatus.READY: _GIT_NOTE_READY,
|
|
51
|
+
RepositoryInspectionStatus.GIT_NOT_INSTALLED: _GIT_NOTE_NOT_INSTALLED,
|
|
52
|
+
RepositoryInspectionStatus.NOT_GIT_REPOSITORY: _GIT_NOTE_NOT_REPOSITORY,
|
|
53
|
+
RepositoryInspectionStatus.PROBE_ERROR: _GIT_NOTE_PROBE_ERROR,
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def build_checkpoint_git_state(
|
|
58
|
+
inspection: RepositoryInspection,
|
|
59
|
+
snapshot_id: str | None = None,
|
|
60
|
+
) -> CheckpointGitState:
|
|
61
|
+
"""Build the checkpoint GIT STATE section honestly encoding unavailable Git."""
|
|
62
|
+
note = _GIT_NOTES.get(inspection.status, _GIT_NOTE_PROBE_ERROR)
|
|
63
|
+
snapshot = inspection.snapshot
|
|
64
|
+
|
|
65
|
+
if inspection.status != RepositoryInspectionStatus.READY or snapshot is None:
|
|
66
|
+
return CheckpointGitState(status=inspection.status, available=False, note=note)
|
|
67
|
+
|
|
68
|
+
return CheckpointGitState(
|
|
69
|
+
status=inspection.status,
|
|
70
|
+
available=True,
|
|
71
|
+
note=note,
|
|
72
|
+
branch=snapshot.branch,
|
|
73
|
+
head_sha=snapshot.head_sha,
|
|
74
|
+
detached_head=snapshot.detached_head,
|
|
75
|
+
dirty=snapshot.dirty,
|
|
76
|
+
staged_count=len(snapshot.staged_files),
|
|
77
|
+
modified_count=len(snapshot.modified_files),
|
|
78
|
+
untracked_count=len(snapshot.untracked_files),
|
|
79
|
+
conflicted_count=len(snapshot.conflicted_files),
|
|
80
|
+
working_tree_diff_summary=snapshot.working_tree_diff_summary,
|
|
81
|
+
staged_diff_summary=snapshot.staged_diff_summary,
|
|
82
|
+
snapshot_id=snapshot_id,
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class CheckpointBuilder:
|
|
87
|
+
"""Constructs canonical CheckpointRecord and CheckpointPayload entities deterministically."""
|
|
88
|
+
|
|
89
|
+
@classmethod
|
|
90
|
+
def build(
|
|
91
|
+
cls,
|
|
92
|
+
project: Project,
|
|
93
|
+
task: Task,
|
|
94
|
+
inspection: RepositoryInspection,
|
|
95
|
+
kind: CheckpointKind,
|
|
96
|
+
snapshot_id: str | None = None,
|
|
97
|
+
session: Session | None = None,
|
|
98
|
+
decisions: list[str] | None = None,
|
|
99
|
+
test_summary: str | None = None,
|
|
100
|
+
test_provenance: CheckpointTestProvenance = CheckpointTestProvenance.UNKNOWN,
|
|
101
|
+
operator_note: str | None = None,
|
|
102
|
+
metadata: dict[str, Any] | None = None,
|
|
103
|
+
) -> CheckpointRecord:
|
|
104
|
+
"""Build a canonical CheckpointRecord from durable inputs.
|
|
105
|
+
|
|
106
|
+
Raises:
|
|
107
|
+
InvalidCheckpointInputError: If note, decisions, or test summary exceed bounds.
|
|
108
|
+
"""
|
|
109
|
+
# Validate inputs
|
|
110
|
+
cleaned_note: str | None = None
|
|
111
|
+
if operator_note is not None:
|
|
112
|
+
cleaned_note = operator_note.strip()
|
|
113
|
+
if len(cleaned_note) > MAX_OPERATOR_NOTE_CHARS:
|
|
114
|
+
raise InvalidCheckpointInputError(
|
|
115
|
+
f"Operator note exceeds maximum allowed length of "
|
|
116
|
+
f"{MAX_OPERATOR_NOTE_CHARS} characters."
|
|
117
|
+
)
|
|
118
|
+
if not cleaned_note:
|
|
119
|
+
cleaned_note = None
|
|
120
|
+
|
|
121
|
+
cleaned_decisions: list[str] = []
|
|
122
|
+
if decisions is not None:
|
|
123
|
+
for decision in decisions:
|
|
124
|
+
d = decision.strip()
|
|
125
|
+
if d:
|
|
126
|
+
if len(d) > MAX_DECISION_CHARS:
|
|
127
|
+
raise InvalidCheckpointInputError(
|
|
128
|
+
f"Decision entry exceeds maximum allowed length of "
|
|
129
|
+
f"{MAX_DECISION_CHARS} characters."
|
|
130
|
+
)
|
|
131
|
+
cleaned_decisions.append(d)
|
|
132
|
+
|
|
133
|
+
cleaned_test_summary: str | None = None
|
|
134
|
+
if test_summary is not None:
|
|
135
|
+
cleaned_test_summary = test_summary.strip()
|
|
136
|
+
if len(cleaned_test_summary) > MAX_TEST_SUMMARY_CHARS:
|
|
137
|
+
raise InvalidCheckpointInputError(
|
|
138
|
+
f"Test summary exceeds maximum allowed length of "
|
|
139
|
+
f"{MAX_TEST_SUMMARY_CHARS} characters."
|
|
140
|
+
)
|
|
141
|
+
if not cleaned_test_summary:
|
|
142
|
+
cleaned_test_summary = None
|
|
143
|
+
|
|
144
|
+
# Build test status
|
|
145
|
+
if cleaned_test_summary is not None:
|
|
146
|
+
provenance = (
|
|
147
|
+
test_provenance
|
|
148
|
+
if test_provenance != CheckpointTestProvenance.UNKNOWN
|
|
149
|
+
else CheckpointTestProvenance.REPORTED
|
|
150
|
+
)
|
|
151
|
+
test_status = CheckpointTestStatus(
|
|
152
|
+
known=True,
|
|
153
|
+
summary=cleaned_test_summary,
|
|
154
|
+
provenance=provenance,
|
|
155
|
+
)
|
|
156
|
+
else:
|
|
157
|
+
test_status = CheckpointTestStatus(
|
|
158
|
+
known=False,
|
|
159
|
+
summary="No independently verified test results recorded.",
|
|
160
|
+
provenance=CheckpointTestProvenance.UNKNOWN,
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
# Build task snapshot
|
|
164
|
+
status_val = task.status.value if isinstance(task.status, TaskStatus) else str(task.status)
|
|
165
|
+
task_snapshot = CheckpointTaskSnapshot(
|
|
166
|
+
task_id=task.id,
|
|
167
|
+
task_title=task.title,
|
|
168
|
+
task_status=status_val,
|
|
169
|
+
objective=task.objective,
|
|
170
|
+
requirements=list(task.requirements),
|
|
171
|
+
constraints=list(task.constraints),
|
|
172
|
+
completed=list(task.completed_items),
|
|
173
|
+
current_work=task.current_work,
|
|
174
|
+
remaining=list(task.remaining_items),
|
|
175
|
+
known_issues=list(task.known_issues),
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
# Build source session summary
|
|
179
|
+
source_session_summary: CheckpointSourceSession | None = None
|
|
180
|
+
if session is not None:
|
|
181
|
+
source_session_summary = CheckpointSourceSession(
|
|
182
|
+
session_id=session.id,
|
|
183
|
+
provider_id=session.provider_id,
|
|
184
|
+
native_session_id=session.native_session_id,
|
|
185
|
+
status=session.status,
|
|
186
|
+
exit_reason=session.exit_reason,
|
|
187
|
+
exit_code=session.exit_code,
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
# Build Git state & files touched
|
|
191
|
+
git_state = build_checkpoint_git_state(inspection, snapshot_id=snapshot_id)
|
|
192
|
+
files_touched = derive_files_touched(inspection)
|
|
193
|
+
|
|
194
|
+
payload = CheckpointPayload(
|
|
195
|
+
protocol_version=CHECKPOINT_PROTOCOL_VERSION,
|
|
196
|
+
generated_at=utc_now(),
|
|
197
|
+
task=task_snapshot,
|
|
198
|
+
git_state=git_state,
|
|
199
|
+
files_touched=files_touched,
|
|
200
|
+
decisions=cleaned_decisions,
|
|
201
|
+
test_status=test_status,
|
|
202
|
+
operator_note=cleaned_note,
|
|
203
|
+
source_session=source_session_summary,
|
|
204
|
+
metadata=dict(metadata or {}),
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
return CheckpointRecord(
|
|
208
|
+
id=generate_checkpoint_id(),
|
|
209
|
+
protocol_version=CHECKPOINT_PROTOCOL_VERSION,
|
|
210
|
+
project_id=project.id,
|
|
211
|
+
task_id=task.id,
|
|
212
|
+
session_id=session.id if session else None,
|
|
213
|
+
git_snapshot_id=snapshot_id,
|
|
214
|
+
kind=kind,
|
|
215
|
+
payload=payload,
|
|
216
|
+
created_at=utc_now(),
|
|
217
|
+
metadata=dict(metadata or {}),
|
|
218
|
+
)
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
"""Application service managing checkpoint creation, querying, and automatic session-end capture."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from cortexshift.adapters.git.inspector import GitRepositoryInspector
|
|
8
|
+
from cortexshift.adapters.sqlite.store import SQLiteStateStore
|
|
9
|
+
from cortexshift.application.checkpoint_builder import CheckpointBuilder
|
|
10
|
+
from cortexshift.application.locator import ProjectLocator
|
|
11
|
+
from cortexshift.domain.checkpoint import (
|
|
12
|
+
CheckpointKind,
|
|
13
|
+
CheckpointRecord,
|
|
14
|
+
CheckpointTestProvenance,
|
|
15
|
+
)
|
|
16
|
+
from cortexshift.domain.errors import (
|
|
17
|
+
CheckpointNotFoundError,
|
|
18
|
+
GitProbeError,
|
|
19
|
+
NoActiveTaskError,
|
|
20
|
+
ProjectNotInitializedError,
|
|
21
|
+
SessionNotFoundError,
|
|
22
|
+
SessionTaskMismatchError,
|
|
23
|
+
)
|
|
24
|
+
from cortexshift.domain.git import RepositoryInspectionStatus
|
|
25
|
+
from cortexshift.domain.project import Project
|
|
26
|
+
from cortexshift.domain.session import Session, SessionExitReason
|
|
27
|
+
from cortexshift.domain.task import Task
|
|
28
|
+
from cortexshift.ports.repository import RepositoryInspector
|
|
29
|
+
|
|
30
|
+
logger = logging.getLogger(__name__)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class CheckpointService:
|
|
34
|
+
"""Coordinates checkpoint operations across Project, Task, Repository, and Store."""
|
|
35
|
+
|
|
36
|
+
def __init__(
|
|
37
|
+
self,
|
|
38
|
+
inspector: RepositoryInspector | None = None,
|
|
39
|
+
) -> None:
|
|
40
|
+
self._inspector = inspector or GitRepositoryInspector()
|
|
41
|
+
|
|
42
|
+
def _resolve_context(
|
|
43
|
+
self,
|
|
44
|
+
start_dir: Path | str | None = None,
|
|
45
|
+
) -> tuple[Path, Project, Task, SQLiteStateStore]:
|
|
46
|
+
"""Resolve project, active task, and state store."""
|
|
47
|
+
start_path = Path(start_dir) if start_dir is not None else None
|
|
48
|
+
project_root = ProjectLocator.find_project_root(start_path)
|
|
49
|
+
if project_root is None:
|
|
50
|
+
raise ProjectNotInitializedError()
|
|
51
|
+
|
|
52
|
+
db_path = ProjectLocator.get_database_path(project_root)
|
|
53
|
+
store = SQLiteStateStore(db_path, auto_migrate=False)
|
|
54
|
+
|
|
55
|
+
try:
|
|
56
|
+
project = store.get_default_project()
|
|
57
|
+
if project is None:
|
|
58
|
+
raise ProjectNotInitializedError()
|
|
59
|
+
|
|
60
|
+
active_task_id = store.get_active_task_id(project.id)
|
|
61
|
+
if not active_task_id:
|
|
62
|
+
raise NoActiveTaskError()
|
|
63
|
+
|
|
64
|
+
task = store.get_task(active_task_id)
|
|
65
|
+
if task is None:
|
|
66
|
+
raise NoActiveTaskError()
|
|
67
|
+
|
|
68
|
+
return project_root, project, task, store
|
|
69
|
+
except Exception:
|
|
70
|
+
store.close()
|
|
71
|
+
raise
|
|
72
|
+
|
|
73
|
+
def create_checkpoint(
|
|
74
|
+
self,
|
|
75
|
+
kind: CheckpointKind = CheckpointKind.MANUAL,
|
|
76
|
+
session_id: str | None = None,
|
|
77
|
+
decisions: list[str] | None = None,
|
|
78
|
+
test_summary: str | None = None,
|
|
79
|
+
test_provenance: CheckpointTestProvenance = CheckpointTestProvenance.REPORTED,
|
|
80
|
+
note: str | None = None,
|
|
81
|
+
start_dir: Path | str | None = None,
|
|
82
|
+
metadata: dict[str, Any] | None = None,
|
|
83
|
+
) -> CheckpointRecord:
|
|
84
|
+
"""Create and persist a canonical Checkpoint without requiring the workspace lease.
|
|
85
|
+
|
|
86
|
+
Manual checkpoints may be called from within a running provider session.
|
|
87
|
+
Inspection is non-atomic and advisory.
|
|
88
|
+
"""
|
|
89
|
+
project_root, project, task, store = self._resolve_context(start_dir)
|
|
90
|
+
try:
|
|
91
|
+
# 1. Resolve session if requested or find latest for active task
|
|
92
|
+
session: Session | None = None
|
|
93
|
+
if session_id is not None:
|
|
94
|
+
session = store.get_session(session_id)
|
|
95
|
+
if session is None:
|
|
96
|
+
raise SessionNotFoundError(session_id)
|
|
97
|
+
if session.task_id != task.id:
|
|
98
|
+
raise SessionTaskMismatchError(session_id, task.id)
|
|
99
|
+
else:
|
|
100
|
+
sessions = store.list_sessions(task_id=task.id, limit=10)
|
|
101
|
+
# Prefer running or finished sessions over spawn-failed
|
|
102
|
+
for candidate in sessions:
|
|
103
|
+
if candidate.exit_reason != SessionExitReason.SPAWN_FAILED:
|
|
104
|
+
session = candidate
|
|
105
|
+
break
|
|
106
|
+
if session is None and sessions:
|
|
107
|
+
session = sessions[0]
|
|
108
|
+
|
|
109
|
+
# 2. Live Git inspection
|
|
110
|
+
inspection = self._inspector.inspect(
|
|
111
|
+
project_root=Path(project.repo_path),
|
|
112
|
+
project_id=project.id,
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
if inspection.status == RepositoryInspectionStatus.PROBE_ERROR:
|
|
116
|
+
raise GitProbeError(
|
|
117
|
+
inspection.diagnostic
|
|
118
|
+
or "Git repository inspection failed; refusing to create a checkpoint "
|
|
119
|
+
"from an unreliable repository observation."
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
snapshot_id: str | None = None
|
|
123
|
+
if (
|
|
124
|
+
inspection.status == RepositoryInspectionStatus.READY
|
|
125
|
+
and inspection.snapshot is not None
|
|
126
|
+
):
|
|
127
|
+
store.save_snapshot(inspection.snapshot)
|
|
128
|
+
snapshot_id = inspection.snapshot.id
|
|
129
|
+
|
|
130
|
+
# 3. Build immutable checkpoint record
|
|
131
|
+
record = CheckpointBuilder.build(
|
|
132
|
+
project=project,
|
|
133
|
+
task=task,
|
|
134
|
+
inspection=inspection,
|
|
135
|
+
kind=kind,
|
|
136
|
+
snapshot_id=snapshot_id,
|
|
137
|
+
session=session,
|
|
138
|
+
decisions=decisions,
|
|
139
|
+
test_summary=test_summary,
|
|
140
|
+
test_provenance=test_provenance,
|
|
141
|
+
operator_note=note,
|
|
142
|
+
metadata=metadata,
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
# 4. Persist
|
|
146
|
+
store.save_checkpoint(record)
|
|
147
|
+
return record
|
|
148
|
+
finally:
|
|
149
|
+
store.close()
|
|
150
|
+
|
|
151
|
+
def get_checkpoint(
|
|
152
|
+
self,
|
|
153
|
+
checkpoint_id: str,
|
|
154
|
+
start_dir: Path | str | None = None,
|
|
155
|
+
) -> CheckpointRecord:
|
|
156
|
+
"""Retrieve a Checkpoint by its unique identifier."""
|
|
157
|
+
start_path = Path(start_dir) if start_dir is not None else None
|
|
158
|
+
project_root = ProjectLocator.find_project_root(start_path)
|
|
159
|
+
if project_root is None:
|
|
160
|
+
raise ProjectNotInitializedError()
|
|
161
|
+
|
|
162
|
+
db_path = ProjectLocator.get_database_path(project_root)
|
|
163
|
+
with SQLiteStateStore(db_path, auto_migrate=False) as store:
|
|
164
|
+
checkpoint = store.get_checkpoint(checkpoint_id)
|
|
165
|
+
if checkpoint is None:
|
|
166
|
+
raise CheckpointNotFoundError(checkpoint_id)
|
|
167
|
+
return checkpoint
|
|
168
|
+
|
|
169
|
+
def list_checkpoints(
|
|
170
|
+
self,
|
|
171
|
+
task_id: str | None = None,
|
|
172
|
+
limit: int | None = 20,
|
|
173
|
+
start_dir: Path | str | None = None,
|
|
174
|
+
) -> list[CheckpointRecord]:
|
|
175
|
+
"""List checkpoints for the active task or project, newest first."""
|
|
176
|
+
start_path = Path(start_dir) if start_dir is not None else None
|
|
177
|
+
project_root = ProjectLocator.find_project_root(start_path)
|
|
178
|
+
if project_root is None:
|
|
179
|
+
raise ProjectNotInitializedError()
|
|
180
|
+
|
|
181
|
+
db_path = ProjectLocator.get_database_path(project_root)
|
|
182
|
+
with SQLiteStateStore(db_path, auto_migrate=False) as store:
|
|
183
|
+
project = store.get_default_project()
|
|
184
|
+
if project is None:
|
|
185
|
+
raise ProjectNotInitializedError()
|
|
186
|
+
|
|
187
|
+
resolved_task_id = task_id
|
|
188
|
+
if resolved_task_id is None:
|
|
189
|
+
resolved_task_id = store.get_active_task_id(project.id)
|
|
190
|
+
|
|
191
|
+
return store.list_checkpoints(
|
|
192
|
+
project_id=project.id,
|
|
193
|
+
task_id=resolved_task_id,
|
|
194
|
+
limit=limit,
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
def get_latest_checkpoint(
|
|
198
|
+
self,
|
|
199
|
+
task_id: str | None = None,
|
|
200
|
+
start_dir: Path | str | None = None,
|
|
201
|
+
) -> CheckpointRecord | None:
|
|
202
|
+
"""Retrieve the newest checkpoint for the active task."""
|
|
203
|
+
results = self.list_checkpoints(task_id=task_id, limit=1, start_dir=start_dir)
|
|
204
|
+
return results[0] if results else None
|
|
205
|
+
|
|
206
|
+
def capture_session_end_checkpoint(
|
|
207
|
+
self,
|
|
208
|
+
session: Session,
|
|
209
|
+
store: SQLiteStateStore,
|
|
210
|
+
) -> CheckpointRecord | None:
|
|
211
|
+
"""Safely capture an automatic SESSION_END checkpoint when a provider process finishes.
|
|
212
|
+
|
|
213
|
+
This method never raises an exception out to the caller, preventing checkpoint
|
|
214
|
+
failures from rewriting or failing an otherwise completed coding session.
|
|
215
|
+
"""
|
|
216
|
+
try:
|
|
217
|
+
task = store.get_task(session.task_id)
|
|
218
|
+
if task is None:
|
|
219
|
+
return None
|
|
220
|
+
|
|
221
|
+
project = store.get_project(task.project_id)
|
|
222
|
+
if project is None:
|
|
223
|
+
return None
|
|
224
|
+
|
|
225
|
+
inspection = self._inspector.inspect(
|
|
226
|
+
project_root=Path(project.repo_path),
|
|
227
|
+
project_id=project.id,
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
if inspection.status == RepositoryInspectionStatus.PROBE_ERROR:
|
|
231
|
+
# Safe warning: record capture warning in session metadata without rewriting status
|
|
232
|
+
logger.warning(
|
|
233
|
+
"Session-end checkpoint capture skipped: git probe error for session %s",
|
|
234
|
+
session.id,
|
|
235
|
+
)
|
|
236
|
+
updated_meta = dict(session.metadata)
|
|
237
|
+
updated_meta["checkpoint_capture_warning"] = "git_probe_error"
|
|
238
|
+
store.save_session(session.model_copy(update={"metadata": updated_meta}))
|
|
239
|
+
return None
|
|
240
|
+
|
|
241
|
+
snapshot_id: str | None = None
|
|
242
|
+
if (
|
|
243
|
+
inspection.status == RepositoryInspectionStatus.READY
|
|
244
|
+
and inspection.snapshot is not None
|
|
245
|
+
):
|
|
246
|
+
store.save_snapshot(inspection.snapshot)
|
|
247
|
+
snapshot_id = inspection.snapshot.id
|
|
248
|
+
|
|
249
|
+
record = CheckpointBuilder.build(
|
|
250
|
+
project=project,
|
|
251
|
+
task=task,
|
|
252
|
+
inspection=inspection,
|
|
253
|
+
kind=CheckpointKind.SESSION_END,
|
|
254
|
+
snapshot_id=snapshot_id,
|
|
255
|
+
session=session,
|
|
256
|
+
test_provenance=CheckpointTestProvenance.UNKNOWN,
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
store.save_checkpoint(record)
|
|
260
|
+
return record
|
|
261
|
+
except Exception as exc:
|
|
262
|
+
logger.warning(
|
|
263
|
+
"Failed to capture session-end checkpoint for session %s: %s",
|
|
264
|
+
session.id,
|
|
265
|
+
exc,
|
|
266
|
+
)
|
|
267
|
+
try:
|
|
268
|
+
updated_meta = dict(session.metadata)
|
|
269
|
+
updated_meta["checkpoint_capture_warning"] = str(exc)
|
|
270
|
+
store.save_session(session.model_copy(update={"metadata": updated_meta}))
|
|
271
|
+
except Exception:
|
|
272
|
+
pass
|
|
273
|
+
return None
|