mycode-coding-agent 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.
- mycode/__init__.py +0 -0
- mycode/adapters/__init__.py +21 -0
- mycode/adapters/jsonl.py +692 -0
- mycode/agent/__init__.py +25 -0
- mycode/agent/events.py +111 -0
- mycode/agent/outcome.py +103 -0
- mycode/agent/progress.py +373 -0
- mycode/agent/runner.py +1481 -0
- mycode/application/__init__.py +38 -0
- mycode/application/agent_session.py +367 -0
- mycode/application/events.py +59 -0
- mycode/application/runtime.py +211 -0
- mycode/application/sessions.py +180 -0
- mycode/cli.py +840 -0
- mycode/config.py +355 -0
- mycode/context/__init__.py +1 -0
- mycode/context/artifacts.py +672 -0
- mycode/context/budget.py +752 -0
- mycode/context/builder.py +112 -0
- mycode/context/compact.py +795 -0
- mycode/context/tool_result_format.py +199 -0
- mycode/context/tool_result_retention.py +261 -0
- mycode/conversation.py +78 -0
- mycode/error_handling.py +481 -0
- mycode/event_format.py +147 -0
- mycode/instructions.py +285 -0
- mycode/llm.py +771 -0
- mycode/mcp/__init__.py +41 -0
- mycode/mcp/client.py +44 -0
- mycode/mcp/config.py +207 -0
- mycode/mcp/errors.py +302 -0
- mycode/mcp/manager.py +339 -0
- mycode/mcp/models.py +20 -0
- mycode/mcp/result_adapter.py +58 -0
- mycode/mcp/tool_adapter.py +145 -0
- mycode/mcp/trust.py +313 -0
- mycode/memory.py +570 -0
- mycode/memory_context.py +245 -0
- mycode/messages.py +63 -0
- mycode/observability.py +28 -0
- mycode/permissions.py +262 -0
- mycode/persistence/__init__.py +1 -0
- mycode/persistence/filesystem.py +291 -0
- mycode/persistence/project_storage.py +208 -0
- mycode/persistence/session_lock.py +138 -0
- mycode/persistence/session_store.py +503 -0
- mycode/presentation/__init__.py +1 -0
- mycode/presentation/cli/__init__.py +14 -0
- mycode/presentation/cli/confirmer.py +116 -0
- mycode/presentation/cli/mcp_trust.py +61 -0
- mycode/presentation/cli/presenter.py +320 -0
- mycode/presentation/cli/session_menu.py +146 -0
- mycode/presentation/cli/subagent_observer.py +124 -0
- mycode/presentation/command_format.py +90 -0
- mycode/presentation/commands.py +95 -0
- mycode/presentation/tui/__init__.py +6 -0
- mycode/presentation/tui/app.py +1351 -0
- mycode/presentation/tui/interactions.py +253 -0
- mycode/presentation/tui/presenter.py +266 -0
- mycode/presentation/tui/screens.py +305 -0
- mycode/presentation/tui/widgets.py +214 -0
- mycode/project.py +22 -0
- mycode/prompts.py +181 -0
- mycode/reasoning.py +40 -0
- mycode/session.py +86 -0
- mycode/skills/__init__.py +27 -0
- mycode/skills/builtin/database-recovery/SKILL.md +138 -0
- mycode/skills/builtin/database-recovery/references/sqlite.md +235 -0
- mycode/skills/registry.py +295 -0
- mycode/skills/state.py +68 -0
- mycode/subagents/__init__.py +1 -0
- mycode/subagents/audit.py +212 -0
- mycode/subagents/concurrency.py +124 -0
- mycode/subagents/contracts.py +421 -0
- mycode/subagents/delegate.py +80 -0
- mycode/subagents/delegation.py +128 -0
- mycode/subagents/lifecycle.py +86 -0
- mycode/subagents/limits.py +7 -0
- mycode/subagents/observability.py +150 -0
- mycode/subagents/persistence.py +152 -0
- mycode/subagents/profiles.py +184 -0
- mycode/subagents/prompts.py +67 -0
- mycode/subagents/results.py +178 -0
- mycode/subagents/runtime.py +528 -0
- mycode/subagents/snapshots.py +211 -0
- mycode/subagents/tool_batch.py +260 -0
- mycode/tools/__init__.py +81 -0
- mycode/tools/base.py +222 -0
- mycode/tools/bounds.py +14 -0
- mycode/tools/command_executor.py +167 -0
- mycode/tools/command_output.py +166 -0
- mycode/tools/command_risk.py +596 -0
- mycode/tools/defaults.py +59 -0
- mycode/tools/edit_file.py +524 -0
- mycode/tools/file_mutation.py +30 -0
- mycode/tools/glob.py +247 -0
- mycode/tools/grep.py +324 -0
- mycode/tools/ignore.py +122 -0
- mycode/tools/inspect_changes.py +269 -0
- mycode/tools/load_skill.py +92 -0
- mycode/tools/memory.py +264 -0
- mycode/tools/path_permissions.py +78 -0
- mycode/tools/patterns.py +48 -0
- mycode/tools/permission_metadata.py +27 -0
- mycode/tools/process_tree.py +166 -0
- mycode/tools/read_file.py +242 -0
- mycode/tools/read_skill_resource.py +93 -0
- mycode/tools/registry.py +279 -0
- mycode/tools/run_command.py +237 -0
- mycode/tools/run_skill_script.py +206 -0
- mycode/tools/run_validation.py +107 -0
- mycode/tools/submit_result.py +93 -0
- mycode/tools/text.py +15 -0
- mycode/tools/validation_command.py +377 -0
- mycode/tools/workspace.py +33 -0
- mycode/tools/write_file.py +169 -0
- mycode_coding_agent-0.1.0.dist-info/METADATA +244 -0
- mycode_coding_agent-0.1.0.dist-info/RECORD +121 -0
- mycode_coding_agent-0.1.0.dist-info/WHEEL +4 -0
- mycode_coding_agent-0.1.0.dist-info/entry_points.txt +2 -0
- mycode_coding_agent-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
from collections.abc import Mapping
|
|
2
|
+
import json
|
|
3
|
+
import os
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
import stat
|
|
6
|
+
import tempfile
|
|
7
|
+
from typing import Literal
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
TrailingRecordPolicy = Literal["error", "ignore", "truncate"]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class FilesystemStorageError(RuntimeError):
|
|
14
|
+
pass
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class StorageBoundaryError(FilesystemStorageError):
|
|
18
|
+
pass
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class JsonSnapshotError(FilesystemStorageError):
|
|
22
|
+
pass
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class JsonLinesError(FilesystemStorageError):
|
|
26
|
+
pass
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class JsonLinesCorruptionError(JsonLinesError):
|
|
30
|
+
pass
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def ensure_storage_directory(root: str | Path, path: str | Path) -> Path:
|
|
34
|
+
resolved_root = Path(root).resolve(strict=False)
|
|
35
|
+
resolved_root.mkdir(parents=True, exist_ok=True)
|
|
36
|
+
raw = Path(path)
|
|
37
|
+
requested = raw if raw.is_absolute() else resolved_root / raw
|
|
38
|
+
if requested.resolve(strict=False) == resolved_root:
|
|
39
|
+
if not resolved_root.is_dir():
|
|
40
|
+
raise StorageBoundaryError(
|
|
41
|
+
f"Storage root is not a directory: {resolved_root}"
|
|
42
|
+
)
|
|
43
|
+
return resolved_root
|
|
44
|
+
candidate = _bounded_path(resolved_root, path)
|
|
45
|
+
_reject_symlink_components(resolved_root, candidate)
|
|
46
|
+
candidate.mkdir(parents=True, exist_ok=True)
|
|
47
|
+
_reject_symlink_components(resolved_root, candidate)
|
|
48
|
+
if not candidate.is_dir():
|
|
49
|
+
raise StorageBoundaryError(f"Storage path is not a directory: {candidate}")
|
|
50
|
+
return candidate
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def write_json_snapshot(
|
|
54
|
+
root: str | Path,
|
|
55
|
+
path: str | Path,
|
|
56
|
+
payload: Mapping[str, object],
|
|
57
|
+
) -> None:
|
|
58
|
+
target = _prepare_file_path(root, path)
|
|
59
|
+
try:
|
|
60
|
+
content = (
|
|
61
|
+
json.dumps(
|
|
62
|
+
dict(payload),
|
|
63
|
+
ensure_ascii=False,
|
|
64
|
+
allow_nan=False,
|
|
65
|
+
indent=2,
|
|
66
|
+
sort_keys=True,
|
|
67
|
+
)
|
|
68
|
+
+ "\n"
|
|
69
|
+
).encode("utf-8")
|
|
70
|
+
except (TypeError, ValueError) as error:
|
|
71
|
+
raise JsonSnapshotError("JSON snapshot payload is not serializable.") from error
|
|
72
|
+
_atomic_write(target, content)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def read_json_snapshot(root: str | Path, path: str | Path) -> dict[str, object]:
|
|
76
|
+
target = _bounded_file_path(root, path, allow_missing=True)
|
|
77
|
+
try:
|
|
78
|
+
payload = json.loads(target.read_text(encoding="utf-8"))
|
|
79
|
+
except (UnicodeError, json.JSONDecodeError) as error:
|
|
80
|
+
raise JsonSnapshotError(f"Invalid JSON snapshot: {target}") from error
|
|
81
|
+
except OSError as error:
|
|
82
|
+
raise JsonSnapshotError(f"Could not read JSON snapshot: {target}") from error
|
|
83
|
+
if not isinstance(payload, dict):
|
|
84
|
+
raise JsonSnapshotError(f"JSON snapshot must contain an object: {target}")
|
|
85
|
+
return payload
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def append_jsonl_record(
|
|
89
|
+
root: str | Path,
|
|
90
|
+
path: str | Path,
|
|
91
|
+
record: Mapping[str, object],
|
|
92
|
+
) -> None:
|
|
93
|
+
target = _prepare_file_path(root, path)
|
|
94
|
+
try:
|
|
95
|
+
content = (
|
|
96
|
+
json.dumps(
|
|
97
|
+
dict(record),
|
|
98
|
+
ensure_ascii=False,
|
|
99
|
+
allow_nan=False,
|
|
100
|
+
separators=(",", ":"),
|
|
101
|
+
sort_keys=True,
|
|
102
|
+
)
|
|
103
|
+
+ "\n"
|
|
104
|
+
).encode("utf-8")
|
|
105
|
+
except (TypeError, ValueError) as error:
|
|
106
|
+
raise JsonLinesError("JSONL record is not serializable.") from error
|
|
107
|
+
|
|
108
|
+
flags = os.O_APPEND | os.O_CREAT | os.O_WRONLY
|
|
109
|
+
if hasattr(os, "O_BINARY"):
|
|
110
|
+
flags |= os.O_BINARY
|
|
111
|
+
try:
|
|
112
|
+
descriptor = os.open(target, flags, 0o600)
|
|
113
|
+
try:
|
|
114
|
+
view = memoryview(content)
|
|
115
|
+
while view:
|
|
116
|
+
written = os.write(descriptor, view)
|
|
117
|
+
if written <= 0:
|
|
118
|
+
raise OSError("append made no progress")
|
|
119
|
+
view = view[written:]
|
|
120
|
+
os.fsync(descriptor)
|
|
121
|
+
finally:
|
|
122
|
+
os.close(descriptor)
|
|
123
|
+
except OSError as error:
|
|
124
|
+
raise JsonLinesError(f"Could not append JSONL record: {target}") from error
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def read_jsonl_records(
|
|
128
|
+
root: str | Path,
|
|
129
|
+
path: str | Path,
|
|
130
|
+
*,
|
|
131
|
+
trailing_record: TrailingRecordPolicy = "error",
|
|
132
|
+
) -> list[dict[str, object]]:
|
|
133
|
+
if trailing_record not in {"error", "ignore", "truncate"}:
|
|
134
|
+
raise ValueError("trailing_record must be 'error', 'ignore', or 'truncate'.")
|
|
135
|
+
target = _bounded_file_path(root, path, allow_missing=True)
|
|
136
|
+
try:
|
|
137
|
+
content = target.read_bytes()
|
|
138
|
+
except OSError as error:
|
|
139
|
+
raise JsonLinesError(f"Could not read JSONL file: {target}") from error
|
|
140
|
+
|
|
141
|
+
lines = content.splitlines(keepends=True)
|
|
142
|
+
has_unterminated_tail = bool(content) and not content.endswith(b"\n")
|
|
143
|
+
records: list[dict[str, object]] = []
|
|
144
|
+
offset = 0
|
|
145
|
+
for index, raw_line in enumerate(lines):
|
|
146
|
+
line_number = index + 1
|
|
147
|
+
try:
|
|
148
|
+
decoded = raw_line.rstrip(b"\r\n").decode("utf-8")
|
|
149
|
+
record = json.loads(decoded)
|
|
150
|
+
except (UnicodeError, json.JSONDecodeError, ValueError) as error:
|
|
151
|
+
is_trailing = index == len(lines) - 1
|
|
152
|
+
is_recoverable_tail = is_trailing and has_unterminated_tail
|
|
153
|
+
if not is_recoverable_tail or trailing_record == "error":
|
|
154
|
+
location = "trailing" if is_trailing else "middle"
|
|
155
|
+
raise JsonLinesCorruptionError(
|
|
156
|
+
f"Invalid {location} JSONL record at line {line_number}: {target}"
|
|
157
|
+
) from error
|
|
158
|
+
if trailing_record == "truncate":
|
|
159
|
+
_truncate_file(target, offset)
|
|
160
|
+
break
|
|
161
|
+
if not isinstance(record, dict):
|
|
162
|
+
raise JsonLinesCorruptionError(
|
|
163
|
+
f"JSONL record at line {line_number} must be an object: {target}"
|
|
164
|
+
)
|
|
165
|
+
records.append(record)
|
|
166
|
+
offset += len(raw_line)
|
|
167
|
+
return records
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def prepare_jsonl_for_append(root: str | Path, path: str | Path) -> list[dict[str, object]]:
|
|
171
|
+
"""Normalize under exclusive ownership and return the records already parsed."""
|
|
172
|
+
records = read_jsonl_records(root, path, trailing_record="truncate")
|
|
173
|
+
target = _bounded_file_path(root, path)
|
|
174
|
+
try:
|
|
175
|
+
with target.open("r+b") as stream:
|
|
176
|
+
stream.seek(0, os.SEEK_END)
|
|
177
|
+
if stream.tell():
|
|
178
|
+
stream.seek(-1, os.SEEK_END)
|
|
179
|
+
if stream.read(1) != b"\n":
|
|
180
|
+
stream.write(b"\n")
|
|
181
|
+
stream.flush()
|
|
182
|
+
os.fsync(stream.fileno())
|
|
183
|
+
except OSError as error:
|
|
184
|
+
raise JsonLinesError("Could not prepare JSONL for append.") from error
|
|
185
|
+
return records
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _prepare_file_path(root: str | Path, path: str | Path) -> Path:
|
|
189
|
+
resolved_root = Path(root).resolve(strict=False)
|
|
190
|
+
resolved_root.mkdir(parents=True, exist_ok=True)
|
|
191
|
+
target = _bounded_path(resolved_root, path)
|
|
192
|
+
ensure_storage_directory(resolved_root, target.parent)
|
|
193
|
+
return _bounded_file_path(resolved_root, target, allow_missing=True)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _bounded_file_path(
|
|
197
|
+
root: str | Path,
|
|
198
|
+
path: str | Path,
|
|
199
|
+
*,
|
|
200
|
+
allow_missing: bool = False,
|
|
201
|
+
) -> Path:
|
|
202
|
+
resolved_root = Path(root).resolve(strict=False)
|
|
203
|
+
target = _bounded_path(resolved_root, path)
|
|
204
|
+
_reject_symlink_components(resolved_root, target)
|
|
205
|
+
if target.is_symlink():
|
|
206
|
+
raise StorageBoundaryError(f"Storage file cannot be a symlink: {target}")
|
|
207
|
+
if not allow_missing and not target.is_file():
|
|
208
|
+
raise JsonSnapshotError(f"Storage file does not exist: {target}")
|
|
209
|
+
if target.exists() and not target.is_file():
|
|
210
|
+
raise StorageBoundaryError(f"Storage path is not a file: {target}")
|
|
211
|
+
return target
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _bounded_path(root: Path, path: str | Path) -> Path:
|
|
215
|
+
raw = Path(path)
|
|
216
|
+
if ".." in raw.parts:
|
|
217
|
+
raise StorageBoundaryError(f"Storage path contains traversal: {raw}")
|
|
218
|
+
candidate = raw if raw.is_absolute() else root / raw
|
|
219
|
+
resolved = candidate.resolve(strict=False)
|
|
220
|
+
if resolved == root or not resolved.is_relative_to(root):
|
|
221
|
+
raise StorageBoundaryError(f"Storage path escapes its root: {candidate}")
|
|
222
|
+
return candidate
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def validate_storage_path(root: str | Path, path: str | Path) -> Path:
|
|
226
|
+
"""Check existing components without creating or resolving away links."""
|
|
227
|
+
boundary = Path(root).resolve(strict=False)
|
|
228
|
+
candidate = _bounded_path(boundary, path)
|
|
229
|
+
_reject_symlink_components(boundary, candidate)
|
|
230
|
+
return candidate
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _reject_symlink_components(root: Path, candidate: Path) -> None:
|
|
234
|
+
try:
|
|
235
|
+
relative = candidate.relative_to(root)
|
|
236
|
+
except ValueError as error:
|
|
237
|
+
raise StorageBoundaryError(
|
|
238
|
+
f"Storage path escapes its root: {candidate}"
|
|
239
|
+
) from error
|
|
240
|
+
current = root
|
|
241
|
+
for part in relative.parts:
|
|
242
|
+
current = current / part
|
|
243
|
+
try:
|
|
244
|
+
info = current.lstat()
|
|
245
|
+
except FileNotFoundError:
|
|
246
|
+
continue
|
|
247
|
+
if current.is_symlink() or getattr(info, "st_file_attributes", 0) & getattr(
|
|
248
|
+
stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0,
|
|
249
|
+
):
|
|
250
|
+
raise StorageBoundaryError(
|
|
251
|
+
f"Storage path contains a symlink below its root: {current}"
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _atomic_write(path: Path, content: bytes) -> None:
|
|
256
|
+
temporary_path: Path | None = None
|
|
257
|
+
try:
|
|
258
|
+
with tempfile.NamedTemporaryFile(
|
|
259
|
+
mode="wb",
|
|
260
|
+
dir=path.parent,
|
|
261
|
+
prefix=f".{path.name}.",
|
|
262
|
+
suffix=".tmp",
|
|
263
|
+
delete=False,
|
|
264
|
+
) as temporary:
|
|
265
|
+
temporary.write(content)
|
|
266
|
+
temporary.flush()
|
|
267
|
+
os.fsync(temporary.fileno())
|
|
268
|
+
temporary_path = Path(temporary.name)
|
|
269
|
+
os.replace(temporary_path, path)
|
|
270
|
+
except OSError as error:
|
|
271
|
+
raise JsonSnapshotError(
|
|
272
|
+
f"Could not atomically write JSON snapshot: {path}"
|
|
273
|
+
) from error
|
|
274
|
+
finally:
|
|
275
|
+
if temporary_path is not None:
|
|
276
|
+
try:
|
|
277
|
+
temporary_path.unlink(missing_ok=True)
|
|
278
|
+
except OSError:
|
|
279
|
+
pass
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def _truncate_file(path: Path, size: int) -> None:
|
|
283
|
+
try:
|
|
284
|
+
with path.open("r+b") as stream:
|
|
285
|
+
stream.truncate(size)
|
|
286
|
+
stream.flush()
|
|
287
|
+
os.fsync(stream.fileno())
|
|
288
|
+
except OSError as error:
|
|
289
|
+
raise JsonLinesError(
|
|
290
|
+
f"Could not truncate trailing JSONL record: {path}"
|
|
291
|
+
) from error
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
import os
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
import re
|
|
5
|
+
|
|
6
|
+
from mycode.persistence.filesystem import (
|
|
7
|
+
JsonSnapshotError,
|
|
8
|
+
StorageBoundaryError,
|
|
9
|
+
ensure_storage_directory,
|
|
10
|
+
read_json_snapshot,
|
|
11
|
+
write_json_snapshot,
|
|
12
|
+
validate_storage_path,
|
|
13
|
+
)
|
|
14
|
+
from mycode.project import ProjectIdentity
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
PROJECT_METADATA_VERSION = 1
|
|
18
|
+
PROJECT_HASH_LENGTH = 12
|
|
19
|
+
MAX_PROJECT_BASENAME_CHARS = 80
|
|
20
|
+
PROJECT_COMPONENT_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$")
|
|
21
|
+
WINDOWS_RESERVED_NAMES = frozenset(
|
|
22
|
+
{"CON", "PRN", "AUX", "NUL"}
|
|
23
|
+
| {f"COM{number}" for number in range(1, 10)}
|
|
24
|
+
| {f"LPT{number}" for number in range(1, 10)}
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class ProjectStorageError(RuntimeError):
|
|
29
|
+
pass
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class ProjectMetadataError(ProjectStorageError):
|
|
33
|
+
pass
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True)
|
|
37
|
+
class SessionStorageLayout:
|
|
38
|
+
root: Path
|
|
39
|
+
transcript_path: Path
|
|
40
|
+
meta_path: Path
|
|
41
|
+
compact_path: Path
|
|
42
|
+
artifacts_directory: Path
|
|
43
|
+
subagents_directory: Path
|
|
44
|
+
lock_path: Path
|
|
45
|
+
|
|
46
|
+
def subagent_log_path(self, run_id: str) -> Path:
|
|
47
|
+
validate_storage_component(run_id, field_name="run_id")
|
|
48
|
+
return self.subagents_directory / f"{run_id}.jsonl"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass(frozen=True)
|
|
52
|
+
class ProjectStorage:
|
|
53
|
+
identity: ProjectIdentity
|
|
54
|
+
projects_root: Path
|
|
55
|
+
project_directory: Path
|
|
56
|
+
metadata_path: Path
|
|
57
|
+
sessions_directory: Path
|
|
58
|
+
locks_directory: Path
|
|
59
|
+
|
|
60
|
+
@classmethod
|
|
61
|
+
def open(
|
|
62
|
+
cls,
|
|
63
|
+
identity: ProjectIdentity,
|
|
64
|
+
*,
|
|
65
|
+
projects_root: str | Path | None = None,
|
|
66
|
+
) -> "ProjectStorage":
|
|
67
|
+
_validate_project_identity(identity)
|
|
68
|
+
root = Path(
|
|
69
|
+
default_projects_root() if projects_root is None else projects_root
|
|
70
|
+
).resolve(strict=False)
|
|
71
|
+
root.mkdir(parents=True, exist_ok=True)
|
|
72
|
+
directory_name = project_directory_name(identity)
|
|
73
|
+
try:
|
|
74
|
+
project_directory = ensure_storage_directory(root, directory_name)
|
|
75
|
+
metadata_path = project_directory / "project.json"
|
|
76
|
+
sessions_directory = project_directory / "sessions"
|
|
77
|
+
locks_directory = project_directory / "locks"
|
|
78
|
+
storage = cls(
|
|
79
|
+
identity=identity,
|
|
80
|
+
projects_root=root,
|
|
81
|
+
project_directory=project_directory,
|
|
82
|
+
metadata_path=metadata_path,
|
|
83
|
+
sessions_directory=sessions_directory,
|
|
84
|
+
locks_directory=locks_directory,
|
|
85
|
+
)
|
|
86
|
+
storage._ensure_metadata()
|
|
87
|
+
ensure_storage_directory(project_directory, sessions_directory)
|
|
88
|
+
ensure_storage_directory(project_directory, locks_directory)
|
|
89
|
+
return storage
|
|
90
|
+
except (OSError, JsonSnapshotError, StorageBoundaryError) as error:
|
|
91
|
+
raise ProjectStorageError(
|
|
92
|
+
f"Could not open project storage for {identity.workspace_root}"
|
|
93
|
+
) from error
|
|
94
|
+
|
|
95
|
+
def session(
|
|
96
|
+
self,
|
|
97
|
+
session_id: str,
|
|
98
|
+
*,
|
|
99
|
+
create: bool = False,
|
|
100
|
+
) -> SessionStorageLayout:
|
|
101
|
+
validate_storage_component(session_id, field_name="session_id")
|
|
102
|
+
session_root = self.sessions_directory / session_id
|
|
103
|
+
try:
|
|
104
|
+
# Check the project entry before using it as the narrower boundary.
|
|
105
|
+
validate_storage_path(self.projects_root, self.project_directory)
|
|
106
|
+
validate_storage_path(self.project_directory, session_root)
|
|
107
|
+
validate_storage_path(self.project_directory, self.locks_directory)
|
|
108
|
+
if create:
|
|
109
|
+
session_root = ensure_storage_directory(
|
|
110
|
+
self.project_directory,
|
|
111
|
+
session_root,
|
|
112
|
+
)
|
|
113
|
+
artifacts = ensure_storage_directory(session_root, "artifacts")
|
|
114
|
+
subagents = ensure_storage_directory(session_root, "subagents")
|
|
115
|
+
else:
|
|
116
|
+
if session_root.is_symlink():
|
|
117
|
+
raise StorageBoundaryError(
|
|
118
|
+
f"Session directory cannot be a symlink: {session_root}"
|
|
119
|
+
)
|
|
120
|
+
resolved = session_root.resolve(strict=False)
|
|
121
|
+
if not resolved.is_relative_to(self.sessions_directory):
|
|
122
|
+
raise StorageBoundaryError(
|
|
123
|
+
f"Session directory escapes project storage: {session_root}"
|
|
124
|
+
)
|
|
125
|
+
artifacts = session_root / "artifacts"
|
|
126
|
+
subagents = session_root / "subagents"
|
|
127
|
+
for target in (artifacts, subagents):
|
|
128
|
+
validate_storage_path(self.project_directory, target)
|
|
129
|
+
except (OSError, StorageBoundaryError) as error:
|
|
130
|
+
raise ProjectStorageError(
|
|
131
|
+
f"Could not resolve session storage for {session_id!r}"
|
|
132
|
+
) from error
|
|
133
|
+
return SessionStorageLayout(
|
|
134
|
+
root=session_root,
|
|
135
|
+
transcript_path=session_root / "transcript.jsonl",
|
|
136
|
+
meta_path=session_root / "meta.json",
|
|
137
|
+
compact_path=session_root / "compact.json",
|
|
138
|
+
artifacts_directory=artifacts,
|
|
139
|
+
subagents_directory=subagents,
|
|
140
|
+
lock_path=self.locks_directory / f"{session_id}.lock",
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
def _ensure_metadata(self) -> None:
|
|
144
|
+
expected = {
|
|
145
|
+
"version": PROJECT_METADATA_VERSION,
|
|
146
|
+
"project_key": self.identity.key,
|
|
147
|
+
"workspace_root": str(self.identity.workspace_root),
|
|
148
|
+
}
|
|
149
|
+
if not self.metadata_path.exists():
|
|
150
|
+
write_json_snapshot(
|
|
151
|
+
self.project_directory,
|
|
152
|
+
self.metadata_path,
|
|
153
|
+
expected,
|
|
154
|
+
)
|
|
155
|
+
return
|
|
156
|
+
actual = read_json_snapshot(self.project_directory, self.metadata_path)
|
|
157
|
+
if actual != expected:
|
|
158
|
+
raise ProjectMetadataError(
|
|
159
|
+
"Project metadata does not match the canonical workspace: "
|
|
160
|
+
f"{self.metadata_path}"
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def default_projects_root() -> Path:
|
|
165
|
+
return Path.home() / ".mycode" / "projects"
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def project_directory_name(identity: ProjectIdentity) -> str:
|
|
169
|
+
_validate_project_identity(identity)
|
|
170
|
+
basename = identity.workspace_root.name or "workspace"
|
|
171
|
+
safe_basename = _safe_project_basename(basename)
|
|
172
|
+
return f"{safe_basename}-{identity.key[:PROJECT_HASH_LENGTH]}"
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _safe_project_basename(value: str) -> str:
|
|
176
|
+
cleaned = "".join(
|
|
177
|
+
"-" if character in '<>:"/\\|?*' or ord(character) < 32 else character
|
|
178
|
+
for character in value
|
|
179
|
+
)
|
|
180
|
+
cleaned = re.sub(r"-+", "-", cleaned).strip(" .-_")
|
|
181
|
+
cleaned = cleaned[:MAX_PROJECT_BASENAME_CHARS].rstrip(" .") or "workspace"
|
|
182
|
+
if cleaned.partition(".")[0].upper() in WINDOWS_RESERVED_NAMES:
|
|
183
|
+
cleaned = f"workspace-{cleaned}"
|
|
184
|
+
return cleaned
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _validate_project_identity(identity: ProjectIdentity) -> None:
|
|
188
|
+
if len(identity.key) != 64 or any(
|
|
189
|
+
character not in "0123456789abcdef" for character in identity.key
|
|
190
|
+
):
|
|
191
|
+
raise ProjectStorageError("ProjectIdentity.key must be a lowercase SHA-256.")
|
|
192
|
+
canonical = ProjectIdentity.from_workspace(identity.workspace_root)
|
|
193
|
+
if canonical != identity:
|
|
194
|
+
raise ProjectStorageError(
|
|
195
|
+
"ProjectIdentity does not match its canonical workspace path."
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def validate_storage_component(value: str, *, field_name: str) -> None:
|
|
200
|
+
"""Validate a Session or SubAgent ID using the shared portable path rules."""
|
|
201
|
+
if (
|
|
202
|
+
not PROJECT_COMPONENT_PATTERN.fullmatch(value)
|
|
203
|
+
or value in {".", ".."}
|
|
204
|
+
or value.rstrip(" .") != value
|
|
205
|
+
or value.partition(".")[0].upper() in WINDOWS_RESERVED_NAMES
|
|
206
|
+
or os.path.isabs(value)
|
|
207
|
+
):
|
|
208
|
+
raise ProjectStorageError(f"Invalid {field_name}: {value!r}")
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
from collections.abc import Iterator
|
|
2
|
+
from contextlib import contextmanager
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
import errno
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
import time
|
|
8
|
+
from typing import BinaryIO
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
DEFAULT_SESSION_LOCK_TIMEOUT_SECONDS = 30.0
|
|
12
|
+
DEFAULT_SESSION_LOCK_POLL_SECONDS = 0.05
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class SessionLockError(RuntimeError):
|
|
16
|
+
pass
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class SessionLockTimeoutError(SessionLockError):
|
|
20
|
+
pass
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True)
|
|
24
|
+
class SessionLifecycleLock:
|
|
25
|
+
"""Exclusive OS lock held for the complete lifetime of one session owner."""
|
|
26
|
+
|
|
27
|
+
path: Path
|
|
28
|
+
timeout_seconds: float = DEFAULT_SESSION_LOCK_TIMEOUT_SECONDS
|
|
29
|
+
poll_seconds: float = DEFAULT_SESSION_LOCK_POLL_SECONDS
|
|
30
|
+
|
|
31
|
+
def __post_init__(self) -> None:
|
|
32
|
+
if self.timeout_seconds < 0:
|
|
33
|
+
raise ValueError("timeout_seconds must be at least 0.")
|
|
34
|
+
if self.poll_seconds <= 0:
|
|
35
|
+
raise ValueError("poll_seconds must be above 0.")
|
|
36
|
+
raw_path = Path(self.path)
|
|
37
|
+
if raw_path.is_symlink():
|
|
38
|
+
raise SessionLockError("Session lifecycle lock file cannot be a symlink.")
|
|
39
|
+
object.__setattr__(
|
|
40
|
+
self,
|
|
41
|
+
"path",
|
|
42
|
+
raw_path.parent.resolve(strict=False) / raw_path.name,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
@contextmanager
|
|
46
|
+
def acquire(self) -> Iterator[None]:
|
|
47
|
+
with _acquire_lifecycle_lock(
|
|
48
|
+
self.path,
|
|
49
|
+
timeout_seconds=self.timeout_seconds,
|
|
50
|
+
poll_seconds=self.poll_seconds,
|
|
51
|
+
):
|
|
52
|
+
yield
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@contextmanager
|
|
56
|
+
def _acquire_lifecycle_lock(
|
|
57
|
+
path: Path,
|
|
58
|
+
*,
|
|
59
|
+
timeout_seconds: float,
|
|
60
|
+
poll_seconds: float,
|
|
61
|
+
) -> Iterator[None]:
|
|
62
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
63
|
+
if path.is_symlink():
|
|
64
|
+
raise SessionLockError("Session lifecycle lock file cannot be a symlink.")
|
|
65
|
+
with path.open("a+b") as stream:
|
|
66
|
+
_ensure_lock_byte(stream)
|
|
67
|
+
deadline = time.monotonic() + timeout_seconds
|
|
68
|
+
while True:
|
|
69
|
+
try:
|
|
70
|
+
_lock_stream(stream)
|
|
71
|
+
break
|
|
72
|
+
except OSError as error:
|
|
73
|
+
if not _is_lock_contention(error):
|
|
74
|
+
raise SessionLockError(
|
|
75
|
+
f"Failed to acquire session lifecycle lock: {path}"
|
|
76
|
+
) from error
|
|
77
|
+
if time.monotonic() >= deadline:
|
|
78
|
+
raise SessionLockTimeoutError(
|
|
79
|
+
"Timed out waiting for another session lifecycle owner to finish."
|
|
80
|
+
) from error
|
|
81
|
+
time.sleep(
|
|
82
|
+
min(
|
|
83
|
+
poll_seconds,
|
|
84
|
+
max(0.0, deadline - time.monotonic()),
|
|
85
|
+
)
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
try:
|
|
89
|
+
yield
|
|
90
|
+
finally:
|
|
91
|
+
try:
|
|
92
|
+
_unlock_stream(stream)
|
|
93
|
+
except OSError as error:
|
|
94
|
+
raise SessionLockError(
|
|
95
|
+
f"Failed to release session lifecycle lock: {path}"
|
|
96
|
+
) from error
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _ensure_lock_byte(stream: BinaryIO) -> None:
|
|
100
|
+
stream.seek(0, os.SEEK_END)
|
|
101
|
+
if stream.tell() == 0:
|
|
102
|
+
stream.write(b"\0")
|
|
103
|
+
stream.flush()
|
|
104
|
+
os.fsync(stream.fileno())
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _lock_stream(stream: BinaryIO) -> None:
|
|
108
|
+
stream.seek(0)
|
|
109
|
+
if os.name == "nt":
|
|
110
|
+
import msvcrt
|
|
111
|
+
|
|
112
|
+
msvcrt.locking(stream.fileno(), msvcrt.LK_NBLCK, 1)
|
|
113
|
+
return
|
|
114
|
+
|
|
115
|
+
import fcntl
|
|
116
|
+
|
|
117
|
+
fcntl.flock(stream.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _unlock_stream(stream: BinaryIO) -> None:
|
|
121
|
+
stream.seek(0)
|
|
122
|
+
if os.name == "nt":
|
|
123
|
+
import msvcrt
|
|
124
|
+
|
|
125
|
+
msvcrt.locking(stream.fileno(), msvcrt.LK_UNLCK, 1)
|
|
126
|
+
return
|
|
127
|
+
|
|
128
|
+
import fcntl
|
|
129
|
+
|
|
130
|
+
fcntl.flock(stream.fileno(), fcntl.LOCK_UN)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _is_lock_contention(error: OSError) -> bool:
|
|
134
|
+
return error.errno in {
|
|
135
|
+
errno.EACCES,
|
|
136
|
+
errno.EAGAIN,
|
|
137
|
+
errno.EDEADLK,
|
|
138
|
+
} or getattr(error, "winerror", None) in {33, 36}
|