patchshuttle 0.1.0a2__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.
- patchshuttle/__init__.py +98 -0
- patchshuttle/_diff.py +317 -0
- patchshuttle/_process.py +198 -0
- patchshuttle/_version.py +3 -0
- patchshuttle/actions/__init__.py +80 -0
- patchshuttle/actions/constructors.py +211 -0
- patchshuttle/actions/create.py +155 -0
- patchshuttle/actions/modify.py +174 -0
- patchshuttle/audit.py +588 -0
- patchshuttle/backup.py +712 -0
- patchshuttle/checks/__init__.py +37 -0
- patchshuttle/checks/constructors.py +67 -0
- patchshuttle/checks/runner.py +233 -0
- patchshuttle/cli.py +766 -0
- patchshuttle/config.py +247 -0
- patchshuttle/context.py +370 -0
- patchshuttle/errors.py +291 -0
- patchshuttle/execution.py +651 -0
- patchshuttle/formatters/__init__.py +25 -0
- patchshuttle/formatters/runner.py +240 -0
- patchshuttle/identifiers.py +20 -0
- patchshuttle/inventory.py +331 -0
- patchshuttle/logging.py +741 -0
- patchshuttle/models.py +496 -0
- patchshuttle/operations.py +292 -0
- patchshuttle/parser.py +243 -0
- patchshuttle/planner.py +1144 -0
- patchshuttle/policy.py +377 -0
- patchshuttle/py.typed +1 -0
- patchshuttle/registry.py +275 -0
- patchshuttle/resources/AI_GUIDE.md +163 -0
- patchshuttle/resources/AUDIT-EXAMPLE.psh.yaml +10 -0
- patchshuttle/resources/PATCH-EXAMPLE.psh.yaml +17 -0
- patchshuttle/resources/PATCHSHUTTLE_PROTOCOL.md +109 -0
- patchshuttle/resources/__init__.py +1 -0
- patchshuttle/rollback.py +306 -0
- patchshuttle/runner.py +880 -0
- patchshuttle/verification.py +107 -0
- patchshuttle/workspace.py +382 -0
- patchshuttle-0.1.0a2.dist-info/METADATA +535 -0
- patchshuttle-0.1.0a2.dist-info/RECORD +44 -0
- patchshuttle-0.1.0a2.dist-info/WHEEL +4 -0
- patchshuttle-0.1.0a2.dist-info/entry_points.txt +2 -0
- patchshuttle-0.1.0a2.dist-info/licenses/LICENSE +21 -0
patchshuttle/config.py
ADDED
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
"""Typed loading and default rendering for ``patches/patchshuttle.toml``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import stat
|
|
7
|
+
from enum import Enum
|
|
8
|
+
from os import PathLike
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Annotated, Literal, TypeAlias
|
|
11
|
+
|
|
12
|
+
from pydantic import BaseModel, ConfigDict, Field, ValidationError
|
|
13
|
+
|
|
14
|
+
from patchshuttle.errors import WorkspaceError, WorkspaceErrorCode
|
|
15
|
+
from patchshuttle.identifiers import ProjectId
|
|
16
|
+
|
|
17
|
+
try:
|
|
18
|
+
import tomllib
|
|
19
|
+
except ModuleNotFoundError: # pragma: no cover - Python 3.10 compatibility
|
|
20
|
+
import tomli as tomllib # type: ignore[no-redef]
|
|
21
|
+
|
|
22
|
+
StrictString: TypeAlias = Annotated[str, Field(strict=True, min_length=1)]
|
|
23
|
+
PositiveInteger: TypeAlias = Annotated[int, Field(strict=True, ge=1)]
|
|
24
|
+
|
|
25
|
+
DEFAULT_PROTECTED_PATHS = (
|
|
26
|
+
".git/**",
|
|
27
|
+
".env",
|
|
28
|
+
".env.*",
|
|
29
|
+
"patches/**",
|
|
30
|
+
".venv/**",
|
|
31
|
+
"venv/**",
|
|
32
|
+
"node_modules/**",
|
|
33
|
+
)
|
|
34
|
+
DEFAULT_PROTECTED_PATH_EXCEPTIONS = (
|
|
35
|
+
".env.example",
|
|
36
|
+
".env.sample",
|
|
37
|
+
".env.template",
|
|
38
|
+
)
|
|
39
|
+
DEFAULT_IGNORED_PATHS = (
|
|
40
|
+
".git/**",
|
|
41
|
+
"patches/backups/**",
|
|
42
|
+
"patches/logs/**",
|
|
43
|
+
"patches/state/**",
|
|
44
|
+
".venv/**",
|
|
45
|
+
"venv/**",
|
|
46
|
+
"node_modules/**",
|
|
47
|
+
"**/__pycache__/**",
|
|
48
|
+
".pytest_cache/**",
|
|
49
|
+
".mypy_cache/**",
|
|
50
|
+
".ruff_cache/**",
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class ProjectOrigin(str, Enum):
|
|
55
|
+
"""How a workspace was classified during its first initialization."""
|
|
56
|
+
|
|
57
|
+
EXISTING = "existing"
|
|
58
|
+
NEW = "new"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class _ConfigModel(BaseModel):
|
|
62
|
+
model_config = ConfigDict(extra="forbid", frozen=True, validate_default=True)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class ProjectSettings(_ConfigModel):
|
|
66
|
+
project_id: ProjectId
|
|
67
|
+
origin: ProjectOrigin
|
|
68
|
+
protected_paths: tuple[StrictString, ...] = DEFAULT_PROTECTED_PATHS
|
|
69
|
+
protected_path_exceptions: tuple[StrictString, ...] = (
|
|
70
|
+
DEFAULT_PROTECTED_PATH_EXCEPTIONS
|
|
71
|
+
)
|
|
72
|
+
ignored_paths: tuple[StrictString, ...] = DEFAULT_IGNORED_PATHS
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class ExecutionSettings(_ConfigModel):
|
|
76
|
+
confirm: bool = Field(default=True, strict=True)
|
|
77
|
+
auto_rollback: bool = Field(default=True, strict=True)
|
|
78
|
+
allow_keep_changes: bool = Field(default=True, strict=True)
|
|
79
|
+
default_timeout_seconds: PositiveInteger = 300
|
|
80
|
+
max_job_bytes: PositiveInteger = 2_000_000
|
|
81
|
+
max_actions: PositiveInteger = 100
|
|
82
|
+
max_single_file_bytes: PositiveInteger = 1_000_000
|
|
83
|
+
max_command_output_bytes: PositiveInteger = 2_000_000
|
|
84
|
+
max_inventory_entries: PositiveInteger = 50_000
|
|
85
|
+
max_inventory_bytes: PositiveInteger = 1_000_000_000
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class FormattingSettings(_ConfigModel):
|
|
89
|
+
enabled: bool = Field(default=True, strict=True)
|
|
90
|
+
order: tuple[Literal["isort"], Literal["black"]] = ("isort", "black")
|
|
91
|
+
scope: Literal["changed_python_files"] = "changed_python_files"
|
|
92
|
+
rerun_checks: bool = Field(default=True, strict=True)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class LoggingSettings(_ConfigModel):
|
|
96
|
+
timezone: StrictString = "local"
|
|
97
|
+
include_command_output: bool = Field(default=True, strict=True)
|
|
98
|
+
redact_known_secrets: bool = Field(default=True, strict=True)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class CheckProfileSettings(_ConfigModel):
|
|
102
|
+
argv: Annotated[tuple[StrictString, ...], Field(min_length=1)]
|
|
103
|
+
timeout_seconds: PositiveInteger = 300
|
|
104
|
+
allow_job_args: bool = Field(default=False, strict=True)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class ChecksSettings(_ConfigModel):
|
|
108
|
+
require_at_least_one_for_patch: bool = Field(default=True, strict=True)
|
|
109
|
+
profiles: dict[str, CheckProfileSettings] = Field(default_factory=dict)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class PatchShuttleConfig(_ConfigModel):
|
|
113
|
+
"""The complete local policy document used by one workspace."""
|
|
114
|
+
|
|
115
|
+
project: ProjectSettings
|
|
116
|
+
execution: ExecutionSettings = Field(default_factory=ExecutionSettings)
|
|
117
|
+
formatting: FormattingSettings = Field(default_factory=FormattingSettings)
|
|
118
|
+
logging: LoggingSettings = Field(default_factory=LoggingSettings)
|
|
119
|
+
checks: ChecksSettings = Field(default_factory=ChecksSettings)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def load_config(path: str | PathLike[str]) -> PatchShuttleConfig:
|
|
123
|
+
"""Load one UTF-8 TOML configuration with a closed typed schema."""
|
|
124
|
+
|
|
125
|
+
config_path = Path(path)
|
|
126
|
+
if config_path.is_symlink():
|
|
127
|
+
raise WorkspaceError(
|
|
128
|
+
WorkspaceErrorCode.CONFIG_NOT_REGULAR,
|
|
129
|
+
"configuration path must not be a symbolic link",
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
try:
|
|
133
|
+
file_stat = config_path.stat()
|
|
134
|
+
except FileNotFoundError as exc:
|
|
135
|
+
raise WorkspaceError(
|
|
136
|
+
WorkspaceErrorCode.CONFIG_NOT_FOUND,
|
|
137
|
+
"workspace configuration was not found",
|
|
138
|
+
) from exc
|
|
139
|
+
except OSError as exc:
|
|
140
|
+
raise WorkspaceError(
|
|
141
|
+
WorkspaceErrorCode.CONFIG_READ_FAILED,
|
|
142
|
+
"configuration metadata could not be read",
|
|
143
|
+
) from exc
|
|
144
|
+
|
|
145
|
+
if not stat.S_ISREG(file_stat.st_mode):
|
|
146
|
+
raise WorkspaceError(
|
|
147
|
+
WorkspaceErrorCode.CONFIG_NOT_REGULAR,
|
|
148
|
+
"configuration path must identify a regular file",
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
try:
|
|
152
|
+
raw = config_path.read_bytes()
|
|
153
|
+
except OSError as exc:
|
|
154
|
+
raise WorkspaceError(
|
|
155
|
+
WorkspaceErrorCode.CONFIG_READ_FAILED,
|
|
156
|
+
"configuration file could not be read",
|
|
157
|
+
) from exc
|
|
158
|
+
|
|
159
|
+
try:
|
|
160
|
+
text = raw.decode("utf-8")
|
|
161
|
+
value = tomllib.loads(text)
|
|
162
|
+
return PatchShuttleConfig.model_validate(value)
|
|
163
|
+
except UnicodeDecodeError as exc:
|
|
164
|
+
raise WorkspaceError(
|
|
165
|
+
WorkspaceErrorCode.CONFIG_INVALID,
|
|
166
|
+
"configuration must be valid UTF-8 text",
|
|
167
|
+
) from exc
|
|
168
|
+
except tomllib.TOMLDecodeError as exc:
|
|
169
|
+
raise WorkspaceError(
|
|
170
|
+
WorkspaceErrorCode.CONFIG_INVALID,
|
|
171
|
+
"configuration contains invalid TOML syntax",
|
|
172
|
+
) from exc
|
|
173
|
+
except ValidationError as exc:
|
|
174
|
+
first_error = exc.errors(include_url=False)[0]
|
|
175
|
+
raise WorkspaceError(
|
|
176
|
+
WorkspaceErrorCode.CONFIG_INVALID,
|
|
177
|
+
str(first_error["msg"]),
|
|
178
|
+
path=_format_validation_path(first_error["loc"]),
|
|
179
|
+
) from exc
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def render_default_config(
|
|
183
|
+
project_id: str,
|
|
184
|
+
origin: ProjectOrigin | str,
|
|
185
|
+
) -> str:
|
|
186
|
+
"""Render the canonical default configuration for a new workspace."""
|
|
187
|
+
|
|
188
|
+
project = ProjectSettings(project_id=project_id, origin=origin)
|
|
189
|
+
return (
|
|
190
|
+
"[project]\n"
|
|
191
|
+
f"project_id = {json.dumps(project.project_id)}\n"
|
|
192
|
+
f"origin = {json.dumps(project.origin.value)}\n\n"
|
|
193
|
+
f"protected_paths = {_render_array(project.protected_paths)}\n\n"
|
|
194
|
+
"protected_path_exceptions = "
|
|
195
|
+
f"{_render_array(project.protected_path_exceptions)}\n\n"
|
|
196
|
+
f"ignored_paths = {_render_array(project.ignored_paths)}\n\n"
|
|
197
|
+
"[execution]\n"
|
|
198
|
+
"confirm = true\n"
|
|
199
|
+
"auto_rollback = true\n"
|
|
200
|
+
"allow_keep_changes = true\n"
|
|
201
|
+
"default_timeout_seconds = 300\n"
|
|
202
|
+
"max_job_bytes = 2000000\n"
|
|
203
|
+
"max_actions = 100\n"
|
|
204
|
+
"max_single_file_bytes = 1000000\n"
|
|
205
|
+
"max_command_output_bytes = 2000000\n"
|
|
206
|
+
"max_inventory_entries = 50000\n"
|
|
207
|
+
"max_inventory_bytes = 1000000000\n\n"
|
|
208
|
+
"[formatting]\n"
|
|
209
|
+
"enabled = true\n"
|
|
210
|
+
'order = ["isort", "black"]\n'
|
|
211
|
+
'scope = "changed_python_files"\n'
|
|
212
|
+
"rerun_checks = true\n\n"
|
|
213
|
+
"[logging]\n"
|
|
214
|
+
'timezone = "local"\n'
|
|
215
|
+
"include_command_output = true\n"
|
|
216
|
+
"redact_known_secrets = true\n\n"
|
|
217
|
+
"[checks]\n"
|
|
218
|
+
"require_at_least_one_for_patch = true\n"
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _render_array(values: tuple[str, ...]) -> str:
|
|
223
|
+
lines = ["["]
|
|
224
|
+
lines.extend(f" {json.dumps(value)}," for value in values)
|
|
225
|
+
lines.append("]")
|
|
226
|
+
return "\n".join(lines)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _format_validation_path(location: tuple[int | str, ...]) -> str:
|
|
230
|
+
path = "$"
|
|
231
|
+
for part in location:
|
|
232
|
+
path = f"{path}[{part}]" if isinstance(part, int) else f"{path}.{part}"
|
|
233
|
+
return path
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
__all__ = [
|
|
237
|
+
"CheckProfileSettings",
|
|
238
|
+
"ChecksSettings",
|
|
239
|
+
"ExecutionSettings",
|
|
240
|
+
"FormattingSettings",
|
|
241
|
+
"LoggingSettings",
|
|
242
|
+
"PatchShuttleConfig",
|
|
243
|
+
"ProjectOrigin",
|
|
244
|
+
"ProjectSettings",
|
|
245
|
+
"load_config",
|
|
246
|
+
"render_default_config",
|
|
247
|
+
]
|
patchshuttle/context.py
ADDED
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
"""Bounded project snapshots and upload-friendly AI handoffs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import platform
|
|
7
|
+
import shutil
|
|
8
|
+
import stat
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from patchshuttle._process import ProcessCommand, ProcessStatus, run_process
|
|
13
|
+
from patchshuttle._version import __version__
|
|
14
|
+
from patchshuttle.errors import ExecutionError, ExecutionErrorCode
|
|
15
|
+
from patchshuttle.inventory import (
|
|
16
|
+
InventoryEntryKind,
|
|
17
|
+
InventoryError,
|
|
18
|
+
WorkspaceInventory,
|
|
19
|
+
capture_inventory,
|
|
20
|
+
)
|
|
21
|
+
from patchshuttle.logging import current_run_clock, write_named_log
|
|
22
|
+
from patchshuttle.registry import Registry, load_registry
|
|
23
|
+
from patchshuttle.runner import acquire_workspace_lock
|
|
24
|
+
from patchshuttle.workspace import Workspace
|
|
25
|
+
|
|
26
|
+
_AUDIT_ACTIONS = (
|
|
27
|
+
"tree, read, search, find_files, file_info, hash, git_status, environment"
|
|
28
|
+
)
|
|
29
|
+
_CHANGE_ACTIONS = (
|
|
30
|
+
"create_directory, create_file, replace_exact, insert_before, insert_after, "
|
|
31
|
+
"delete_exact, apply_diff"
|
|
32
|
+
)
|
|
33
|
+
_CHECKS = (
|
|
34
|
+
"compileall, pytest, unittest, django_check, django_migrations_check, "
|
|
35
|
+
"django_test, import_check, profile"
|
|
36
|
+
)
|
|
37
|
+
_TREE_LIMIT = 500
|
|
38
|
+
_FILE_LIMIT = 2_000
|
|
39
|
+
_HISTORY_LIMIT = 20
|
|
40
|
+
_TRUNCATION = "\n[TRUNCATED BY PATCHSHUTTLE]\n"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(frozen=True, slots=True)
|
|
44
|
+
class SnapshotResult:
|
|
45
|
+
"""One generated read-only project snapshot."""
|
|
46
|
+
|
|
47
|
+
path: Path
|
|
48
|
+
inventory_entries: int
|
|
49
|
+
output_truncated: bool
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass(frozen=True, slots=True)
|
|
53
|
+
class HandoffResult:
|
|
54
|
+
"""One generated AI-facing project handoff."""
|
|
55
|
+
|
|
56
|
+
path: Path
|
|
57
|
+
inventory_entries: int
|
|
58
|
+
recent_jobs: int
|
|
59
|
+
output_truncated: bool
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def create_snapshot(workspace: Workspace) -> SnapshotResult:
|
|
63
|
+
"""Capture bounded metadata without including source-file contents."""
|
|
64
|
+
|
|
65
|
+
clock = current_run_clock(workspace)
|
|
66
|
+
with acquire_workspace_lock(workspace):
|
|
67
|
+
inventory = _capture(workspace)
|
|
68
|
+
registry = load_registry(workspace)
|
|
69
|
+
content = _snapshot_text(workspace, inventory, registry, clock.iso_timestamp)
|
|
70
|
+
content, truncated = _bounded(
|
|
71
|
+
content,
|
|
72
|
+
workspace.config.execution.max_command_output_bytes,
|
|
73
|
+
)
|
|
74
|
+
path = write_named_log(
|
|
75
|
+
workspace,
|
|
76
|
+
clock=clock,
|
|
77
|
+
label="SNAPSHOT",
|
|
78
|
+
content=content,
|
|
79
|
+
)
|
|
80
|
+
return SnapshotResult(
|
|
81
|
+
path=path,
|
|
82
|
+
inventory_entries=len(inventory.entries),
|
|
83
|
+
output_truncated=truncated,
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def create_handoff(workspace: Workspace) -> HandoffResult:
|
|
88
|
+
"""Create one compact provider-neutral context file for an AI service."""
|
|
89
|
+
|
|
90
|
+
clock = current_run_clock(workspace)
|
|
91
|
+
with acquire_workspace_lock(workspace):
|
|
92
|
+
inventory = _capture(workspace)
|
|
93
|
+
registry = load_registry(workspace)
|
|
94
|
+
latest_summary, latest_handoff = _latest_run_context(workspace)
|
|
95
|
+
recent = _recent_records(registry)
|
|
96
|
+
content = _handoff_text(
|
|
97
|
+
workspace,
|
|
98
|
+
inventory,
|
|
99
|
+
recent,
|
|
100
|
+
clock.iso_timestamp,
|
|
101
|
+
latest_summary=latest_summary,
|
|
102
|
+
latest_handoff=latest_handoff,
|
|
103
|
+
)
|
|
104
|
+
content, truncated = _bounded(
|
|
105
|
+
content,
|
|
106
|
+
workspace.config.execution.max_command_output_bytes,
|
|
107
|
+
)
|
|
108
|
+
path = write_named_log(
|
|
109
|
+
workspace,
|
|
110
|
+
clock=clock,
|
|
111
|
+
label="HANDOFF",
|
|
112
|
+
content=content,
|
|
113
|
+
)
|
|
114
|
+
return HandoffResult(
|
|
115
|
+
path=path,
|
|
116
|
+
inventory_entries=len(inventory.entries),
|
|
117
|
+
recent_jobs=len(recent),
|
|
118
|
+
output_truncated=truncated,
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _snapshot_text(
|
|
123
|
+
workspace: Workspace,
|
|
124
|
+
inventory: WorkspaceInventory,
|
|
125
|
+
registry: Registry,
|
|
126
|
+
timestamp: str,
|
|
127
|
+
) -> str:
|
|
128
|
+
tree_lines, tree_truncated = _tree_lines(inventory)
|
|
129
|
+
file_lines, files_truncated = _file_lines(inventory)
|
|
130
|
+
recent = _recent_records(registry)
|
|
131
|
+
return "\n".join(
|
|
132
|
+
(
|
|
133
|
+
"=== PATCHSHUTTLE_SNAPSHOT ===",
|
|
134
|
+
"protocol: 1",
|
|
135
|
+
f"timestamp: {timestamp}",
|
|
136
|
+
f"project_id: {workspace.project_id}",
|
|
137
|
+
f"patchshuttle_version: {__version__}",
|
|
138
|
+
f"python_version: {platform.python_version()}",
|
|
139
|
+
f"workspace_root: {workspace.root.as_posix()}",
|
|
140
|
+
f"inventory_entries: {len(inventory.entries)}",
|
|
141
|
+
f"inventory_hashed_bytes: {inventory.hashed_bytes}",
|
|
142
|
+
"",
|
|
143
|
+
"=== CAPABILITIES ===",
|
|
144
|
+
"job_kinds: audit, patch, verify",
|
|
145
|
+
f"audit_actions: {_AUDIT_ACTIONS}",
|
|
146
|
+
f"change_actions: {_CHANGE_ACTIONS}",
|
|
147
|
+
f"checks: {_CHECKS}",
|
|
148
|
+
"",
|
|
149
|
+
"=== POLICY_SUMMARY ===",
|
|
150
|
+
"ignored_paths: "
|
|
151
|
+
+ json.dumps(
|
|
152
|
+
workspace.config.project.ignored_paths,
|
|
153
|
+
ensure_ascii=False,
|
|
154
|
+
),
|
|
155
|
+
"protected_paths: "
|
|
156
|
+
+ json.dumps(
|
|
157
|
+
workspace.config.project.protected_paths,
|
|
158
|
+
ensure_ascii=False,
|
|
159
|
+
),
|
|
160
|
+
"",
|
|
161
|
+
"=== PROJECT_TREE ===",
|
|
162
|
+
*tree_lines,
|
|
163
|
+
f"tree_truncated: {str(tree_truncated).lower()}",
|
|
164
|
+
"",
|
|
165
|
+
"=== FILE_FINGERPRINTS ===",
|
|
166
|
+
*file_lines,
|
|
167
|
+
f"file_list_truncated: {str(files_truncated).lower()}",
|
|
168
|
+
"",
|
|
169
|
+
"=== GIT_STATUS ===",
|
|
170
|
+
_git_status(workspace),
|
|
171
|
+
"",
|
|
172
|
+
"=== RECENT_JOBS ===",
|
|
173
|
+
*(_record_line(item) for item in recent),
|
|
174
|
+
"=== END_PATCHSHUTTLE_SNAPSHOT ===",
|
|
175
|
+
)
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _handoff_text(
|
|
180
|
+
workspace: Workspace,
|
|
181
|
+
inventory: WorkspaceInventory,
|
|
182
|
+
recent: tuple,
|
|
183
|
+
timestamp: str,
|
|
184
|
+
*,
|
|
185
|
+
latest_summary: str,
|
|
186
|
+
latest_handoff: str,
|
|
187
|
+
) -> str:
|
|
188
|
+
tree_lines, tree_truncated = _tree_lines(inventory)
|
|
189
|
+
return "\n".join(
|
|
190
|
+
(
|
|
191
|
+
"=== PATCHSHUTTLE_HANDOFF ===",
|
|
192
|
+
"AI_INSTRUCTION:",
|
|
193
|
+
"Inspect this context and the latest run result. Return exactly one "
|
|
194
|
+
".psh.yaml job using protocol 1 and the project_id below. Use an "
|
|
195
|
+
"audit job when more evidence is needed. Do not return shell commands "
|
|
196
|
+
"or ask PatchShuttle to weaken local policy. After the user runs the "
|
|
197
|
+
"job, request the resulting PatchShuttle log before preparing another job.",
|
|
198
|
+
"",
|
|
199
|
+
"=== PROJECT ===",
|
|
200
|
+
"protocol: 1",
|
|
201
|
+
f"timestamp: {timestamp}",
|
|
202
|
+
f"project_id: {workspace.project_id}",
|
|
203
|
+
f"patchshuttle_version: {__version__}",
|
|
204
|
+
"",
|
|
205
|
+
"=== CAPABILITIES ===",
|
|
206
|
+
"job_kinds: audit, patch, verify",
|
|
207
|
+
f"audit_actions: {_AUDIT_ACTIONS}",
|
|
208
|
+
f"change_actions: {_CHANGE_ACTIONS}",
|
|
209
|
+
f"checks: {_CHECKS}",
|
|
210
|
+
"",
|
|
211
|
+
"=== LATEST_RUN_SUMMARY ===",
|
|
212
|
+
latest_summary,
|
|
213
|
+
"",
|
|
214
|
+
"=== LATEST_AI_HANDOFF ===",
|
|
215
|
+
latest_handoff,
|
|
216
|
+
"",
|
|
217
|
+
"=== BOUNDED_PROJECT_TREE ===",
|
|
218
|
+
*tree_lines,
|
|
219
|
+
f"tree_truncated: {str(tree_truncated).lower()}",
|
|
220
|
+
"",
|
|
221
|
+
"=== RECENT_JOB_HISTORY ===",
|
|
222
|
+
*(_record_line(item) for item in recent),
|
|
223
|
+
"",
|
|
224
|
+
"EXPECTED_RESPONSE: one .psh.yaml file only",
|
|
225
|
+
"=== END_PATCHSHUTTLE_HANDOFF ===",
|
|
226
|
+
)
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _capture(workspace: Workspace) -> WorkspaceInventory:
|
|
231
|
+
try:
|
|
232
|
+
return capture_inventory(workspace)
|
|
233
|
+
except InventoryError as exc:
|
|
234
|
+
raise ExecutionError(
|
|
235
|
+
ExecutionErrorCode.WORKSPACE_INVENTORY_FAILED,
|
|
236
|
+
"project context inventory could not be captured",
|
|
237
|
+
path=exc.path.as_posix() if exc.path is not None else None,
|
|
238
|
+
) from exc
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _tree_lines(
|
|
242
|
+
inventory: WorkspaceInventory,
|
|
243
|
+
) -> tuple[tuple[str, ...], bool]:
|
|
244
|
+
selected = inventory.entries[:_TREE_LIMIT]
|
|
245
|
+
lines = tuple(
|
|
246
|
+
f"{entry.path.as_posix()}{'/' if entry.kind is InventoryEntryKind.DIRECTORY else ''} [{entry.kind.value.lower()}]"
|
|
247
|
+
for entry in selected
|
|
248
|
+
)
|
|
249
|
+
return lines or ("[EMPTY PROJECT]",), len(inventory.entries) > len(selected)
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _file_lines(
|
|
253
|
+
inventory: WorkspaceInventory,
|
|
254
|
+
) -> tuple[tuple[str, ...], bool]:
|
|
255
|
+
files = tuple(
|
|
256
|
+
entry for entry in inventory.entries if entry.kind is InventoryEntryKind.FILE
|
|
257
|
+
)
|
|
258
|
+
selected = files[:_FILE_LIMIT]
|
|
259
|
+
lines = tuple(
|
|
260
|
+
f"{entry.path.as_posix()} size={entry.size} sha256={entry.sha256}"
|
|
261
|
+
for entry in selected
|
|
262
|
+
)
|
|
263
|
+
return lines or ("[NO FILES]",), len(files) > len(selected)
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _recent_records(registry: Registry) -> tuple:
|
|
267
|
+
return tuple(
|
|
268
|
+
sorted(
|
|
269
|
+
registry.jobs.values(),
|
|
270
|
+
key=lambda item: (item.latest_run_at, item.job_id),
|
|
271
|
+
reverse=True,
|
|
272
|
+
)[:_HISTORY_LIMIT]
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _record_line(record) -> str:
|
|
277
|
+
return (
|
|
278
|
+
f"{record.job_id} kind={record.kind} result={record.latest_result} "
|
|
279
|
+
f"hash={record.job_hash[:8]} at={record.latest_run_at}"
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def _git_status(workspace: Workspace) -> str:
|
|
284
|
+
executable = shutil.which("git")
|
|
285
|
+
marker = workspace.root / ".git"
|
|
286
|
+
if executable is None or not marker.exists() or marker.is_symlink():
|
|
287
|
+
return "NOT_AVAILABLE"
|
|
288
|
+
process = run_process(
|
|
289
|
+
ProcessCommand(
|
|
290
|
+
argv=(
|
|
291
|
+
executable,
|
|
292
|
+
"-c",
|
|
293
|
+
"color.ui=false",
|
|
294
|
+
"status",
|
|
295
|
+
"--short",
|
|
296
|
+
"--branch",
|
|
297
|
+
"--untracked-files=normal",
|
|
298
|
+
),
|
|
299
|
+
working_directory=workspace.root,
|
|
300
|
+
timeout_seconds=workspace.config.execution.default_timeout_seconds,
|
|
301
|
+
),
|
|
302
|
+
maximum_output_bytes=workspace.config.execution.max_command_output_bytes,
|
|
303
|
+
)
|
|
304
|
+
if process.status is not ProcessStatus.PASSED:
|
|
305
|
+
return "NOT_AVAILABLE"
|
|
306
|
+
value = process.stdout.rstrip("\n") or "CLEAN"
|
|
307
|
+
return value + ("\n[TRUNCATED]" if process.stdout_truncated else "")
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def _latest_run_context(workspace: Workspace) -> tuple[str, str]:
|
|
311
|
+
directory = workspace.patches_dir / "logs"
|
|
312
|
+
try:
|
|
313
|
+
candidates = sorted(
|
|
314
|
+
(
|
|
315
|
+
path
|
|
316
|
+
for path in directory.iterdir()
|
|
317
|
+
if path.name.startswith("log_") and path.suffix == ".log"
|
|
318
|
+
),
|
|
319
|
+
key=lambda path: (path.lstat().st_mtime_ns, path.name),
|
|
320
|
+
reverse=True,
|
|
321
|
+
)
|
|
322
|
+
except OSError as exc:
|
|
323
|
+
raise ExecutionError(
|
|
324
|
+
ExecutionErrorCode.OPERATIONAL_RECORD_FAILED,
|
|
325
|
+
"run logs could not be inspected for handoff generation",
|
|
326
|
+
path="patches/logs",
|
|
327
|
+
) from exc
|
|
328
|
+
maximum = workspace.config.execution.max_command_output_bytes
|
|
329
|
+
for path in candidates:
|
|
330
|
+
try:
|
|
331
|
+
metadata = path.lstat()
|
|
332
|
+
if not stat.S_ISREG(metadata.st_mode) or metadata.st_size > maximum:
|
|
333
|
+
continue
|
|
334
|
+
text = path.read_text("utf-8")
|
|
335
|
+
except (OSError, UnicodeError):
|
|
336
|
+
continue
|
|
337
|
+
summary = _section(text, "SUMMARY")
|
|
338
|
+
handoff = _section(text, "PATCHSHUTTLE_AI_HANDOFF")
|
|
339
|
+
if summary is not None and handoff is not None:
|
|
340
|
+
return summary, handoff
|
|
341
|
+
return "NOT_AVAILABLE", "NOT_AVAILABLE"
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def _section(value: str, name: str) -> str | None:
|
|
345
|
+
marker = f"=== {name} ===\n"
|
|
346
|
+
start = value.find(marker)
|
|
347
|
+
if start < 0:
|
|
348
|
+
return None
|
|
349
|
+
content_start = start + len(marker)
|
|
350
|
+
end = value.find("\n=== ", content_start)
|
|
351
|
+
return value[content_start : end if end >= 0 else None].strip()
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def _bounded(value: str, maximum: int) -> tuple[str, bool]:
|
|
355
|
+
raw = value.encode("utf-8")
|
|
356
|
+
if len(raw) <= maximum:
|
|
357
|
+
return value, False
|
|
358
|
+
marker = _TRUNCATION.encode("utf-8")
|
|
359
|
+
if maximum <= len(marker):
|
|
360
|
+
return marker[:maximum].decode("utf-8"), True
|
|
361
|
+
retained = raw[: max(0, maximum - len(marker))]
|
|
362
|
+
return retained.decode("utf-8", errors="ignore") + _TRUNCATION, True
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
__all__ = [
|
|
366
|
+
"HandoffResult",
|
|
367
|
+
"SnapshotResult",
|
|
368
|
+
"create_handoff",
|
|
369
|
+
"create_snapshot",
|
|
370
|
+
]
|