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,92 @@
|
|
|
1
|
+
"""Domain model for a coding agent execution session."""
|
|
2
|
+
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from enum import StrEnum
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
8
|
+
|
|
9
|
+
from cortexshift.domain.identifiers import generate_id, utc_now
|
|
10
|
+
from cortexshift.domain.provider import ProviderId
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class SessionStatus(StrEnum):
|
|
14
|
+
"""Execution status of an individual agent session.
|
|
15
|
+
|
|
16
|
+
Phase 4 Active Statuses:
|
|
17
|
+
- INITIALIZING: Session record created before process launch.
|
|
18
|
+
- RUNNING: Session process is actively executing.
|
|
19
|
+
- COMPLETED: Process exited normally with exit code 0.
|
|
20
|
+
- INTERRUPTED: Session was interrupted by user (SIGINT/Ctrl+C, exit code 130).
|
|
21
|
+
- FAILED: Session process exited with non-zero exit code or spawn failed.
|
|
22
|
+
|
|
23
|
+
Reserved Future Statuses (not emitted in Phase 4):
|
|
24
|
+
- ACTIVE: Reserved alias for active execution states.
|
|
25
|
+
- TIMED_OUT: Reserved for execution timeout policies.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
INITIALIZING = "initializing"
|
|
29
|
+
RUNNING = "running"
|
|
30
|
+
ACTIVE = "active"
|
|
31
|
+
COMPLETED = "completed"
|
|
32
|
+
INTERRUPTED = "interrupted"
|
|
33
|
+
FAILED = "failed"
|
|
34
|
+
TIMED_OUT = "timed_out"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class SessionExitReason(StrEnum):
|
|
38
|
+
"""Reason why an agent session concluded or halted.
|
|
39
|
+
|
|
40
|
+
Phase 4 Active Exit Reasons:
|
|
41
|
+
- NORMAL_COMPLETION: Native CLI process exited with code 0.
|
|
42
|
+
- USER_INTERRUPTED: User interrupted interactive session via SIGINT (Ctrl+C).
|
|
43
|
+
- PROCESS_CRASHED: Native CLI process exited with non-zero exit code.
|
|
44
|
+
- SPAWN_FAILED: Process runner failed to spawn the native CLI executable.
|
|
45
|
+
|
|
46
|
+
Reserved Future Exit Reasons (not emitted in Phase 4):
|
|
47
|
+
- QUOTA_EXHAUSTED: Reserved for future structured API quota exhaustion detection.
|
|
48
|
+
- RATE_LIMITED: Reserved for future provider rate limit detection.
|
|
49
|
+
- UNEXPECTED_TERMINATION: Reserved for unexpected external process termination.
|
|
50
|
+
- UNKNOWN: Reserved fallback for unclassifiable exits.
|
|
51
|
+
|
|
52
|
+
Note: Phase 4 does not infer quota exhaustion or rate limits from generic
|
|
53
|
+
non-zero exit codes. Generic non-zero process exits map strictly to PROCESS_CRASHED.
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
NORMAL_COMPLETION = "normal_completion"
|
|
57
|
+
USER_INTERRUPTED = "user_interrupted"
|
|
58
|
+
PROCESS_CRASHED = "process_crashed"
|
|
59
|
+
SPAWN_FAILED = "spawn_failed"
|
|
60
|
+
|
|
61
|
+
# Reserved for future phases (not emitted in Phase 4):
|
|
62
|
+
QUOTA_EXHAUSTED = "quota_exhausted"
|
|
63
|
+
RATE_LIMITED = "rate_limited"
|
|
64
|
+
UNEXPECTED_TERMINATION = "unexpected_termination"
|
|
65
|
+
UNKNOWN = "unknown"
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def generate_session_id() -> str:
|
|
69
|
+
"""Generate default ID for sessions."""
|
|
70
|
+
return generate_id("sess")
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class Session(BaseModel):
|
|
74
|
+
"""An execution session by a specific coding agent working on a task.
|
|
75
|
+
|
|
76
|
+
Sessions are transient worker executions; tasks outlast sessions.
|
|
77
|
+
"""
|
|
78
|
+
|
|
79
|
+
model_config = ConfigDict(frozen=True)
|
|
80
|
+
|
|
81
|
+
id: str = Field(default_factory=generate_session_id)
|
|
82
|
+
task_id: str
|
|
83
|
+
provider_id: ProviderId
|
|
84
|
+
native_session_id: str | None = None
|
|
85
|
+
resumed_from_session_id: str | None = None
|
|
86
|
+
status: SessionStatus = SessionStatus.INITIALIZING
|
|
87
|
+
started_at: datetime = Field(default_factory=utc_now)
|
|
88
|
+
ended_at: datetime | None = None
|
|
89
|
+
exit_reason: SessionExitReason | None = None
|
|
90
|
+
exit_code: int | None = None
|
|
91
|
+
reconciled_at: datetime | None = None
|
|
92
|
+
metadata: dict[str, Any] = Field(default_factory=dict)
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Domain models representing project and task status."""
|
|
2
|
+
|
|
3
|
+
from pydantic import BaseModel, ConfigDict
|
|
4
|
+
|
|
5
|
+
from cortexshift.domain.task import TaskStatus
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ProgressSummary(BaseModel):
|
|
9
|
+
"""Summary counts of task progression items."""
|
|
10
|
+
|
|
11
|
+
model_config = ConfigDict(frozen=True)
|
|
12
|
+
|
|
13
|
+
completed: int = 0
|
|
14
|
+
remaining: int = 0
|
|
15
|
+
issues: int = 0
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ActiveTaskSummary(BaseModel):
|
|
19
|
+
"""Summary of the currently active task on a project."""
|
|
20
|
+
|
|
21
|
+
model_config = ConfigDict(frozen=True)
|
|
22
|
+
|
|
23
|
+
id: str
|
|
24
|
+
title: str
|
|
25
|
+
status: TaskStatus
|
|
26
|
+
objective: str
|
|
27
|
+
progress: ProgressSummary
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class ProjectStatus(BaseModel):
|
|
31
|
+
"""Comprehensive status of an initialized CortexShift project."""
|
|
32
|
+
|
|
33
|
+
model_config = ConfigDict(frozen=True)
|
|
34
|
+
|
|
35
|
+
project_id: str
|
|
36
|
+
name: str
|
|
37
|
+
repo_path: str
|
|
38
|
+
state_file: str
|
|
39
|
+
schema_version: int
|
|
40
|
+
active_task: ActiveTaskSummary | None = None
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
"""Domain model for a CortexShift Task."""
|
|
2
|
+
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from enum import StrEnum
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel, ConfigDict, Field, computed_field, field_validator, model_validator
|
|
8
|
+
|
|
9
|
+
from cortexshift.domain.identifiers import generate_id, utc_now
|
|
10
|
+
|
|
11
|
+
# Canonical input bounds for task progress mutations. Shared by every adapter that
|
|
12
|
+
# accepts operator or agent supplied task text (MCP tools, the TUI control center).
|
|
13
|
+
MAX_CURRENT_WORK_CHARS = 2000
|
|
14
|
+
MAX_ITEM_CHARS = 500
|
|
15
|
+
MAX_ITEMS_PER_CALL = 50
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class TaskStatus(StrEnum):
|
|
19
|
+
"""Lifecycle status of a persistent development task."""
|
|
20
|
+
|
|
21
|
+
PENDING = "pending"
|
|
22
|
+
IN_PROGRESS = "in_progress"
|
|
23
|
+
BLOCKED = "blocked"
|
|
24
|
+
COMPLETED = "completed"
|
|
25
|
+
CANCELLED = "cancelled"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def generate_task_id() -> str:
|
|
29
|
+
"""Generate default ID for tasks."""
|
|
30
|
+
return generate_id("task")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class Task(BaseModel):
|
|
34
|
+
"""Represents a persistent development task.
|
|
35
|
+
|
|
36
|
+
The task is the central abstraction of CortexShift. Coding agents are temporary
|
|
37
|
+
workers operating sequentially on this task.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
model_config = ConfigDict(frozen=True)
|
|
41
|
+
|
|
42
|
+
id: str = Field(default_factory=generate_task_id)
|
|
43
|
+
project_id: str
|
|
44
|
+
title: str
|
|
45
|
+
objective: str
|
|
46
|
+
requirements: list[str] = Field(default_factory=list)
|
|
47
|
+
constraints: list[str] = Field(default_factory=list)
|
|
48
|
+
status: TaskStatus = TaskStatus.PENDING
|
|
49
|
+
completed_items: list[str] = Field(default_factory=list)
|
|
50
|
+
current_work: str | None = None
|
|
51
|
+
remaining_items: list[str] = Field(default_factory=list)
|
|
52
|
+
known_issues: list[str] = Field(default_factory=list)
|
|
53
|
+
created_at: datetime = Field(default_factory=utc_now)
|
|
54
|
+
updated_at: datetime = Field(default_factory=utc_now)
|
|
55
|
+
metadata: dict[str, Any] = Field(default_factory=dict)
|
|
56
|
+
|
|
57
|
+
@model_validator(mode="before")
|
|
58
|
+
@classmethod
|
|
59
|
+
def _normalize_completed_and_remaining(cls, data: Any) -> Any:
|
|
60
|
+
if isinstance(data, dict):
|
|
61
|
+
# Support both completed and completed_items
|
|
62
|
+
if "completed" in data and "completed_items" not in data:
|
|
63
|
+
data["completed_items"] = data.pop("completed")
|
|
64
|
+
# Support both remaining and remaining_items
|
|
65
|
+
if "remaining" in data and "remaining_items" not in data:
|
|
66
|
+
data["remaining_items"] = data.pop("remaining")
|
|
67
|
+
return data
|
|
68
|
+
|
|
69
|
+
@field_validator("title", "objective")
|
|
70
|
+
@classmethod
|
|
71
|
+
def validate_non_empty_strings(cls, v: str) -> str:
|
|
72
|
+
stripped = v.strip()
|
|
73
|
+
if not stripped:
|
|
74
|
+
raise ValueError("Field cannot be empty or blank.")
|
|
75
|
+
return stripped
|
|
76
|
+
|
|
77
|
+
@computed_field # type: ignore[prop-decorator]
|
|
78
|
+
@property
|
|
79
|
+
def completed(self) -> list[str]:
|
|
80
|
+
"""Canonical alias for completed_items."""
|
|
81
|
+
return self.completed_items
|
|
82
|
+
|
|
83
|
+
@computed_field # type: ignore[prop-decorator]
|
|
84
|
+
@property
|
|
85
|
+
def remaining(self) -> list[str]:
|
|
86
|
+
"""Canonical alias for remaining_items."""
|
|
87
|
+
return self.remaining_items
|
|
88
|
+
|
|
89
|
+
@property
|
|
90
|
+
def is_terminal(self) -> bool:
|
|
91
|
+
"""Return True if the task is in a terminal state (cannot be resumed/activated)."""
|
|
92
|
+
return self.status in (TaskStatus.COMPLETED, TaskStatus.CANCELLED)
|
|
93
|
+
|
|
94
|
+
def mark_completed(self) -> "Task":
|
|
95
|
+
"""Return a copy of this task marked as completed with updated timestamp."""
|
|
96
|
+
now = utc_now()
|
|
97
|
+
return self.model_copy(
|
|
98
|
+
update={
|
|
99
|
+
"status": TaskStatus.COMPLETED,
|
|
100
|
+
"updated_at": now,
|
|
101
|
+
}
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
def with_current_work(self, work: str | None) -> "Task":
|
|
105
|
+
"""Return a copy of this task with updated current work and timestamp."""
|
|
106
|
+
now = utc_now()
|
|
107
|
+
cleaned = work.strip() if work is not None else None
|
|
108
|
+
return self.model_copy(
|
|
109
|
+
update={
|
|
110
|
+
"current_work": cleaned if cleaned else None,
|
|
111
|
+
"updated_at": now,
|
|
112
|
+
}
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
def add_completed_items(self, items: list[str]) -> "Task":
|
|
116
|
+
"""Return a copy of this task with additional completed items (deduplicated)."""
|
|
117
|
+
new_items = list(self.completed_items)
|
|
118
|
+
existing_set = set(new_items)
|
|
119
|
+
for item in items:
|
|
120
|
+
cleaned = item.strip()
|
|
121
|
+
if cleaned and cleaned not in existing_set:
|
|
122
|
+
new_items.append(cleaned)
|
|
123
|
+
existing_set.add(cleaned)
|
|
124
|
+
return self.model_copy(
|
|
125
|
+
update={
|
|
126
|
+
"completed_items": new_items,
|
|
127
|
+
"updated_at": utc_now(),
|
|
128
|
+
}
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
def add_remaining_items(self, items: list[str]) -> "Task":
|
|
132
|
+
"""Return a copy of this task with additional remaining items (deduplicated)."""
|
|
133
|
+
new_items = list(self.remaining_items)
|
|
134
|
+
existing_set = set(new_items)
|
|
135
|
+
for item in items:
|
|
136
|
+
cleaned = item.strip()
|
|
137
|
+
if cleaned and cleaned not in existing_set:
|
|
138
|
+
new_items.append(cleaned)
|
|
139
|
+
existing_set.add(cleaned)
|
|
140
|
+
return self.model_copy(
|
|
141
|
+
update={
|
|
142
|
+
"remaining_items": new_items,
|
|
143
|
+
"updated_at": utc_now(),
|
|
144
|
+
}
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
def add_known_issues(self, items: list[str]) -> "Task":
|
|
148
|
+
"""Return a copy of this task with additional known issues (deduplicated)."""
|
|
149
|
+
new_items = list(self.known_issues)
|
|
150
|
+
existing_set = set(new_items)
|
|
151
|
+
for item in items:
|
|
152
|
+
cleaned = item.strip()
|
|
153
|
+
if cleaned and cleaned not in existing_set:
|
|
154
|
+
new_items.append(cleaned)
|
|
155
|
+
existing_set.add(cleaned)
|
|
156
|
+
return self.model_copy(
|
|
157
|
+
update={
|
|
158
|
+
"known_issues": new_items,
|
|
159
|
+
"updated_at": utc_now(),
|
|
160
|
+
}
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
def complete_items(self, items: list[str]) -> "Task":
|
|
164
|
+
"""Return a copy with items marked completed and dropped from remaining.
|
|
165
|
+
|
|
166
|
+
This is the canonical "mark completed" rule: items are appended to
|
|
167
|
+
`completed_items` (deduplicated) and any exactly matching entry is removed from
|
|
168
|
+
`remaining_items`. It never completes the Task itself — see `mark_completed`.
|
|
169
|
+
"""
|
|
170
|
+
cleaned = [stripped for item in items if (stripped := item.strip())]
|
|
171
|
+
if not cleaned:
|
|
172
|
+
return self
|
|
173
|
+
|
|
174
|
+
updated = self.add_completed_items(cleaned)
|
|
175
|
+
completed_set = set(cleaned)
|
|
176
|
+
return updated.model_copy(
|
|
177
|
+
update={
|
|
178
|
+
"remaining_items": [
|
|
179
|
+
item for item in updated.remaining_items if item not in completed_set
|
|
180
|
+
],
|
|
181
|
+
"updated_at": utc_now(),
|
|
182
|
+
}
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
def add_completed(self, items: list[str]) -> "Task":
|
|
186
|
+
"""Alias for add_completed_items."""
|
|
187
|
+
return self.add_completed_items(items)
|
|
188
|
+
|
|
189
|
+
def add_remaining(self, items: list[str]) -> "Task":
|
|
190
|
+
"""Alias for add_remaining_items."""
|
|
191
|
+
return self.add_remaining_items(items)
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""CortexShift Model Context Protocol (MCP) shared state integration."""
|
|
2
|
+
|
|
3
|
+
from cortexshift.mcp.context import (
|
|
4
|
+
ENV_MCP_READ_ONLY,
|
|
5
|
+
ENV_PROJECT_ROOT,
|
|
6
|
+
ENV_PROVIDER_ID,
|
|
7
|
+
ENV_SESSION_ID,
|
|
8
|
+
ENV_TASK_ID,
|
|
9
|
+
McpExecutionContext,
|
|
10
|
+
resolve_mcp_context,
|
|
11
|
+
)
|
|
12
|
+
from cortexshift.mcp.facade import McpApplicationFacade
|
|
13
|
+
from cortexshift.mcp.models import (
|
|
14
|
+
CheckpointResult,
|
|
15
|
+
CreateCheckpointResult,
|
|
16
|
+
DecisionResult,
|
|
17
|
+
ProjectContextResult,
|
|
18
|
+
TaskMutationResult,
|
|
19
|
+
)
|
|
20
|
+
from cortexshift.mcp.server import create_mcp_server, run_mcp_server
|
|
21
|
+
|
|
22
|
+
__all__ = [
|
|
23
|
+
"ENV_MCP_READ_ONLY",
|
|
24
|
+
"ENV_PROJECT_ROOT",
|
|
25
|
+
"ENV_PROVIDER_ID",
|
|
26
|
+
"ENV_SESSION_ID",
|
|
27
|
+
"ENV_TASK_ID",
|
|
28
|
+
"CheckpointResult",
|
|
29
|
+
"CreateCheckpointResult",
|
|
30
|
+
"DecisionResult",
|
|
31
|
+
"McpApplicationFacade",
|
|
32
|
+
"McpExecutionContext",
|
|
33
|
+
"ProjectContextResult",
|
|
34
|
+
"TaskMutationResult",
|
|
35
|
+
"create_mcp_server",
|
|
36
|
+
"resolve_mcp_context",
|
|
37
|
+
"run_mcp_server",
|
|
38
|
+
]
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
"""Execution context resolution and validation for CortexShift MCP server."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from pydantic import BaseModel, ConfigDict
|
|
7
|
+
|
|
8
|
+
from cortexshift.adapters.sqlite.store import SQLiteStateStore
|
|
9
|
+
from cortexshift.application.locator import ProjectLocator
|
|
10
|
+
from cortexshift.domain.errors import McpContextError
|
|
11
|
+
from cortexshift.domain.mcp_binding import (
|
|
12
|
+
ENV_MCP_READ_ONLY,
|
|
13
|
+
ENV_PROJECT_ROOT,
|
|
14
|
+
ENV_PROVIDER_ID,
|
|
15
|
+
ENV_SESSION_ID,
|
|
16
|
+
ENV_TASK_ID,
|
|
17
|
+
)
|
|
18
|
+
from cortexshift.domain.provider import ProviderId
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"ENV_MCP_READ_ONLY",
|
|
22
|
+
"ENV_PROJECT_ROOT",
|
|
23
|
+
"ENV_PROVIDER_ID",
|
|
24
|
+
"ENV_SESSION_ID",
|
|
25
|
+
"ENV_TASK_ID",
|
|
26
|
+
"McpExecutionContext",
|
|
27
|
+
"resolve_mcp_context",
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class McpExecutionContext(BaseModel):
|
|
32
|
+
"""Canonical execution context binding the MCP server to local project state."""
|
|
33
|
+
|
|
34
|
+
model_config = ConfigDict(frozen=True)
|
|
35
|
+
|
|
36
|
+
project_root: Path
|
|
37
|
+
project_id: str
|
|
38
|
+
task_id: str | None = None
|
|
39
|
+
session_id: str | None = None
|
|
40
|
+
provider_id: ProviderId | None = None
|
|
41
|
+
managed_session: bool = False
|
|
42
|
+
read_only: bool = False
|
|
43
|
+
|
|
44
|
+
def can_mutate(self) -> bool:
|
|
45
|
+
"""Return True if write operations are permitted in this context."""
|
|
46
|
+
return self.managed_session and not self.read_only
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def resolve_mcp_context(
|
|
50
|
+
project_root_override: Path | None = None,
|
|
51
|
+
store_override: SQLiteStateStore | None = None,
|
|
52
|
+
env_override: dict[str, str] | None = None,
|
|
53
|
+
) -> tuple[McpExecutionContext, SQLiteStateStore]:
|
|
54
|
+
"""Resolve and validate the canonical McpExecutionContext.
|
|
55
|
+
|
|
56
|
+
Validates:
|
|
57
|
+
- Project initialization and root resolution
|
|
58
|
+
- Session existence, task ownership, and provider identity (if session_id provided)
|
|
59
|
+
- Bound task existence and project membership
|
|
60
|
+
- Read-only enforcement for unmanaged invocations or explicit read-only flag
|
|
61
|
+
|
|
62
|
+
Returns:
|
|
63
|
+
A tuple of (McpExecutionContext, initialized SQLiteStateStore).
|
|
64
|
+
|
|
65
|
+
Raises:
|
|
66
|
+
McpContextError: If the context violates invariants or SQLite state is invalid.
|
|
67
|
+
"""
|
|
68
|
+
env = os.environ if env_override is None else env_override
|
|
69
|
+
|
|
70
|
+
# 1. Resolve project root
|
|
71
|
+
explicit_root_str = env.get(ENV_PROJECT_ROOT)
|
|
72
|
+
candidate_root: Path | None = None
|
|
73
|
+
if project_root_override is not None:
|
|
74
|
+
candidate_root = project_root_override.resolve()
|
|
75
|
+
elif explicit_root_str:
|
|
76
|
+
candidate_root = Path(explicit_root_str).resolve()
|
|
77
|
+
else:
|
|
78
|
+
candidate_root = ProjectLocator.find_project_root()
|
|
79
|
+
|
|
80
|
+
if candidate_root is None or not ProjectLocator.is_initialized(candidate_root):
|
|
81
|
+
loc = candidate_root or Path.cwd()
|
|
82
|
+
raise McpContextError(f"No initialized CortexShift project found at or above '{loc}'.")
|
|
83
|
+
|
|
84
|
+
project_root = candidate_root.resolve()
|
|
85
|
+
|
|
86
|
+
# 2. Connect store and fetch project
|
|
87
|
+
store = (
|
|
88
|
+
store_override
|
|
89
|
+
if store_override is not None
|
|
90
|
+
else SQLiteStateStore(ProjectLocator.get_database_path(project_root))
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
project = store.get_default_project()
|
|
94
|
+
if project is None:
|
|
95
|
+
raise McpContextError(f"No project record found in database at {project_root}.")
|
|
96
|
+
|
|
97
|
+
if Path(project.repo_path).resolve() != project_root:
|
|
98
|
+
raise McpContextError(
|
|
99
|
+
f"Project root mismatch: record specifies '{project.repo_path}', "
|
|
100
|
+
f"but current root is '{project_root}'."
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
# 3. Read-only override
|
|
104
|
+
explicit_read_only = env.get(ENV_MCP_READ_ONLY, "0").strip().lower() in ("1", "true", "yes")
|
|
105
|
+
|
|
106
|
+
session_id = env.get(ENV_SESSION_ID)
|
|
107
|
+
env_task_id = env.get(ENV_TASK_ID)
|
|
108
|
+
env_provider_id = env.get(ENV_PROVIDER_ID)
|
|
109
|
+
|
|
110
|
+
if session_id:
|
|
111
|
+
# Managed invocation: must be strictly validated
|
|
112
|
+
session = store.get_session(session_id)
|
|
113
|
+
if session is None:
|
|
114
|
+
raise McpContextError(f"Session '{session_id}' not found in project '{project.id}'.")
|
|
115
|
+
|
|
116
|
+
if env_task_id and session.task_id != env_task_id:
|
|
117
|
+
raise McpContextError(
|
|
118
|
+
f"Session '{session_id}' task '{session.task_id}' does not match "
|
|
119
|
+
f"environment task '{env_task_id}'."
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
if env_provider_id and str(session.provider_id) != env_provider_id:
|
|
123
|
+
raise McpContextError(
|
|
124
|
+
f"Session '{session_id}' provider '{session.provider_id}' does not match "
|
|
125
|
+
f"environment provider '{env_provider_id}'."
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
task = store.get_task(session.task_id)
|
|
129
|
+
if task is None:
|
|
130
|
+
raise McpContextError(
|
|
131
|
+
f"Task '{session.task_id}' bound to session '{session_id}' not found."
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
if task.project_id != project.id:
|
|
135
|
+
raise McpContextError(f"Task '{task.id}' does not belong to project '{project.id}'.")
|
|
136
|
+
|
|
137
|
+
context = McpExecutionContext(
|
|
138
|
+
project_root=project_root,
|
|
139
|
+
project_id=project.id,
|
|
140
|
+
task_id=session.task_id,
|
|
141
|
+
session_id=session.id,
|
|
142
|
+
provider_id=session.provider_id,
|
|
143
|
+
managed_session=True,
|
|
144
|
+
read_only=explicit_read_only,
|
|
145
|
+
)
|
|
146
|
+
else:
|
|
147
|
+
# Unmanaged invocation: strictly read-only
|
|
148
|
+
candidate_task_id = env_task_id or store.get_active_task_id(project.id)
|
|
149
|
+
resolved_task_id: str | None = None
|
|
150
|
+
if candidate_task_id:
|
|
151
|
+
task = store.get_task(candidate_task_id)
|
|
152
|
+
if task and task.project_id == project.id:
|
|
153
|
+
resolved_task_id = task.id
|
|
154
|
+
|
|
155
|
+
context = McpExecutionContext(
|
|
156
|
+
project_root=project_root,
|
|
157
|
+
project_id=project.id,
|
|
158
|
+
task_id=resolved_task_id,
|
|
159
|
+
session_id=None,
|
|
160
|
+
provider_id=None,
|
|
161
|
+
managed_session=False,
|
|
162
|
+
read_only=True,
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
return context, store
|