solwyn-cli 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.
- solwyn_cli/__init__.py +3 -0
- solwyn_cli/_vendor/__init__.py +1 -0
- solwyn_cli/_vendor/validation.py +21 -0
- solwyn_cli/_vendor/wire.py +185 -0
- solwyn_cli/artifacts/__init__.py +727 -0
- solwyn_cli/artifacts/_atomic.py +79 -0
- solwyn_cli/auth.py +252 -0
- solwyn_cli/client.py +279 -0
- solwyn_cli/commands/__init__.py +1 -0
- solwyn_cli/commands/_shared.py +49 -0
- solwyn_cli/commands/agent_context.py +19 -0
- solwyn_cli/commands/budget.py +976 -0
- solwyn_cli/commands/config.py +110 -0
- solwyn_cli/commands/costs.py +186 -0
- solwyn_cli/commands/init.py +659 -0
- solwyn_cli/commands/keys.py +386 -0
- solwyn_cli/commands/login.py +205 -0
- solwyn_cli/commands/mcp.py +28 -0
- solwyn_cli/commands/projects.py +449 -0
- solwyn_cli/commands/status.py +173 -0
- solwyn_cli/commands/whoami.py +170 -0
- solwyn_cli/config.py +209 -0
- solwyn_cli/errors.py +114 -0
- solwyn_cli/exit_codes.py +56 -0
- solwyn_cli/help.py +19 -0
- solwyn_cli/main.py +135 -0
- solwyn_cli/mcp/__init__.py +5 -0
- solwyn_cli/mcp/server.py +728 -0
- solwyn_cli/output.py +263 -0
- solwyn_cli/runtime.py +115 -0
- solwyn_cli/spec/__init__.py +1 -0
- solwyn_cli/spec/openapi_snapshot.json +12357 -0
- solwyn_cli-0.1.0.dist-info/METADATA +60 -0
- solwyn_cli-0.1.0.dist-info/RECORD +37 -0
- solwyn_cli-0.1.0.dist-info/WHEEL +4 -0
- solwyn_cli-0.1.0.dist-info/entry_points.txt +3 -0
- solwyn_cli-0.1.0.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,727 @@
|
|
|
1
|
+
"""Single-source rendering and safe repository writes for agent guidance."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import secrets
|
|
5
|
+
import stat
|
|
6
|
+
from enum import StrEnum
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from pydantic import BaseModel, ConfigDict
|
|
10
|
+
|
|
11
|
+
from solwyn_cli.artifacts._atomic import (
|
|
12
|
+
atomic_exchange as _atomic_exchange,
|
|
13
|
+
atomic_noreplace as _atomic_noreplace,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
CLAUDE_SKILL_PATH = Path(".claude/skills/solwyn/SKILL.md")
|
|
18
|
+
CURSOR_RULE_PATH = Path(".cursor/rules/solwyn.mdc")
|
|
19
|
+
AGENTS_PATH = Path("AGENTS.md")
|
|
20
|
+
|
|
21
|
+
_CLAUDE_FRONTMATTER = (
|
|
22
|
+
"---\nname: solwyn\ndescription: Use Solwyn budget guardrails safely.\n---\n\n"
|
|
23
|
+
)
|
|
24
|
+
_CURSOR_FRONTMATTER = (
|
|
25
|
+
"---\n"
|
|
26
|
+
"description: Apply Solwyn budget guardrails safely.\n"
|
|
27
|
+
"alwaysApply: false\n"
|
|
28
|
+
"---\n\n"
|
|
29
|
+
)
|
|
30
|
+
_AGENTS_BEGIN = "<!-- BEGIN SOLWYN AGENT CONTEXT -->"
|
|
31
|
+
_AGENTS_END = "<!-- END SOLWYN AGENT CONTEXT -->"
|
|
32
|
+
_TRANSACTION_PREFIX = ".solwyn-agent-"
|
|
33
|
+
_DIRECTORY_FLAGS = (
|
|
34
|
+
os.O_RDONLY
|
|
35
|
+
| getattr(os, "O_DIRECTORY", 0)
|
|
36
|
+
| getattr(os, "O_NOFOLLOW", 0)
|
|
37
|
+
| getattr(os, "O_CLOEXEC", 0)
|
|
38
|
+
)
|
|
39
|
+
_FILE_IDENTITY = tuple[int, int, int, int, int, int]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class _ArtifactState(BaseModel):
|
|
43
|
+
model_config = ConfigDict(extra="forbid", arbitrary_types_allowed=True)
|
|
44
|
+
|
|
45
|
+
relative_path: Path
|
|
46
|
+
parent_path: Path
|
|
47
|
+
parent_fd: int
|
|
48
|
+
parent_identity: tuple[int, int]
|
|
49
|
+
target_name: str
|
|
50
|
+
original_identity: _FILE_IDENTITY | None
|
|
51
|
+
content: str
|
|
52
|
+
temporary_name: str | None = None
|
|
53
|
+
backup_name: str | None = None
|
|
54
|
+
committed: bool = False
|
|
55
|
+
installed_identity: _FILE_IDENTITY | None = None
|
|
56
|
+
retain_backup_for_recovery: bool = False
|
|
57
|
+
retain_temporary_for_recovery: bool = False
|
|
58
|
+
rollback_recovery_name: str | None = None
|
|
59
|
+
conflict_detected: bool = False
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class ArtifactRecoveryError(OSError):
|
|
63
|
+
"""Rollback preserved concurrent edits or retained original recovery bytes."""
|
|
64
|
+
|
|
65
|
+
def __init__(
|
|
66
|
+
self,
|
|
67
|
+
recovery_paths: tuple[Path, ...],
|
|
68
|
+
conflict_paths: tuple[Path, ...] = (),
|
|
69
|
+
) -> None:
|
|
70
|
+
self.recovery_paths = recovery_paths
|
|
71
|
+
self.conflict_paths = conflict_paths
|
|
72
|
+
details: list[str] = []
|
|
73
|
+
if recovery_paths:
|
|
74
|
+
details.append(
|
|
75
|
+
"retained backup(s): " + ", ".join(str(path) for path in recovery_paths)
|
|
76
|
+
)
|
|
77
|
+
if conflict_paths:
|
|
78
|
+
details.append(
|
|
79
|
+
"preserved concurrent target(s): "
|
|
80
|
+
+ ", ".join(str(path) for path in conflict_paths)
|
|
81
|
+
)
|
|
82
|
+
super().__init__(
|
|
83
|
+
"Agent artifact rollback failed; manual recovery required; "
|
|
84
|
+
+ "; ".join(details)
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class ArtifactSurface(StrEnum):
|
|
89
|
+
"""The four public surfaces backed by the same semantic guidance."""
|
|
90
|
+
|
|
91
|
+
CLAUDE_SKILL = "claude_skill"
|
|
92
|
+
CURSOR_RULE = "cursor_rule"
|
|
93
|
+
AGENTS_SECTION = "agents_section"
|
|
94
|
+
AGENT_CONTEXT = "agent_context"
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def agent_guidance() -> str:
|
|
98
|
+
"""Return the canonical semantic source shared by every agent surface."""
|
|
99
|
+
return """# Solwyn budget guardrails
|
|
100
|
+
|
|
101
|
+
Use Solwyn to observe and guard AI-provider spend without exposing credentials or duplicating reservations.
|
|
102
|
+
|
|
103
|
+
## Polling and reservations
|
|
104
|
+
|
|
105
|
+
- Use `solwyn budget status --json` for polling. It is a pure read and does not hold budget.
|
|
106
|
+
- Use `solwyn budget check` followed by `solwyn budget confirm` only around a specific un-instrumented provider spend that you make yourself.
|
|
107
|
+
- Never wrap SDK-instrumented code with `budget check` or `budget confirm`; the Solwyn SDK already reserves and confirms that spend.
|
|
108
|
+
- Fail-open is explicit: when a check returns `allowed=true` and `reservation_id=null`, skip confirm.
|
|
109
|
+
- Do not create repeated unconfirmed checks. They temporarily hold budget and can cause self-denial.
|
|
110
|
+
|
|
111
|
+
## Machine contract and free-tier backoff
|
|
112
|
+
|
|
113
|
+
- Use `--json` (or `--format json` / `SOLWYN_FORMAT=json`) for schema-bearing machine output. Success schema names are additive-only; JSON errors use `solwyn.error.v1` on stderr.
|
|
114
|
+
- Frozen exit codes are: 0 success, 1 generic/API, 2 usage, 3 auth, 4 budget denial, and 5 not found.
|
|
115
|
+
- On rate limits, honor `Retry-After` once for at most 10 seconds. On the free tier, cache and back off rather than creating a retry storm.
|
|
116
|
+
|
|
117
|
+
## Trust boundary
|
|
118
|
+
|
|
119
|
+
- Keep Solwyn credentials only in the OS keychain. Never echo them or place them in config files or argv.
|
|
120
|
+
- Solwyn must never collect, store, or proxy provider keys, prompts, or responses.
|
|
121
|
+
- Do not add telemetry or update checks.
|
|
122
|
+
"""
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _normalized_guidance() -> str:
|
|
126
|
+
return agent_guidance().rstrip("\n")
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def render_surface(surface: ArtifactSurface) -> str:
|
|
130
|
+
"""Render one surface using only framing around the canonical guidance."""
|
|
131
|
+
guidance = _normalized_guidance()
|
|
132
|
+
if surface is ArtifactSurface.CLAUDE_SKILL:
|
|
133
|
+
return f"{_CLAUDE_FRONTMATTER}{guidance}\n"
|
|
134
|
+
if surface is ArtifactSurface.CURSOR_RULE:
|
|
135
|
+
return f"{_CURSOR_FRONTMATTER}{guidance}\n"
|
|
136
|
+
if surface is ArtifactSurface.AGENTS_SECTION:
|
|
137
|
+
return f"{_AGENTS_BEGIN}\n{guidance}\n{_AGENTS_END}\n"
|
|
138
|
+
return f"{guidance}\n"
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def extract_guidance(surface: ArtifactSurface, content: str) -> str:
|
|
142
|
+
"""Extract semantic content for parity checks and downstream validation."""
|
|
143
|
+
if surface is ArtifactSurface.CLAUDE_SKILL:
|
|
144
|
+
if not content.startswith(_CLAUDE_FRONTMATTER):
|
|
145
|
+
raise ValueError("Invalid Claude skill framing.")
|
|
146
|
+
return content.removeprefix(_CLAUDE_FRONTMATTER).rstrip("\n")
|
|
147
|
+
if surface is ArtifactSurface.CURSOR_RULE:
|
|
148
|
+
if not content.startswith(_CURSOR_FRONTMATTER):
|
|
149
|
+
raise ValueError("Invalid Cursor rule framing.")
|
|
150
|
+
return content.removeprefix(_CURSOR_FRONTMATTER).rstrip("\n")
|
|
151
|
+
if surface is ArtifactSurface.AGENTS_SECTION:
|
|
152
|
+
prefix = f"{_AGENTS_BEGIN}\n"
|
|
153
|
+
suffix = f"\n{_AGENTS_END}\n"
|
|
154
|
+
if not content.startswith(prefix) or not content.endswith(suffix):
|
|
155
|
+
raise ValueError("Invalid AGENTS section framing.")
|
|
156
|
+
return content.removeprefix(prefix).removesuffix(suffix)
|
|
157
|
+
return content.rstrip("\n")
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _fence(line: str) -> tuple[str, int] | None:
|
|
161
|
+
stripped = line.lstrip(" ")
|
|
162
|
+
if len(line) - len(stripped) > 3 or not stripped:
|
|
163
|
+
return None
|
|
164
|
+
character = stripped[0]
|
|
165
|
+
if character not in {"`", "~"}:
|
|
166
|
+
return None
|
|
167
|
+
length = len(stripped) - len(stripped.lstrip(character))
|
|
168
|
+
return (character, length) if length >= 3 else None
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _owned_marker_offsets(existing: str) -> tuple[list[int], list[tuple[int, int]]]:
|
|
172
|
+
begins: list[int] = []
|
|
173
|
+
ends: list[tuple[int, int]] = []
|
|
174
|
+
open_fence: tuple[str, int] | None = None
|
|
175
|
+
offset = 0
|
|
176
|
+
for line in existing.splitlines(keepends=True):
|
|
177
|
+
body = line.rstrip("\r\n")
|
|
178
|
+
fence = _fence(body)
|
|
179
|
+
if open_fence is not None:
|
|
180
|
+
if fence is not None and fence[0] == open_fence[0]:
|
|
181
|
+
stripped = body.lstrip(" ")
|
|
182
|
+
run_length = len(stripped) - len(stripped.lstrip(fence[0]))
|
|
183
|
+
if run_length >= open_fence[1] and not stripped[run_length:].strip():
|
|
184
|
+
open_fence = None
|
|
185
|
+
elif fence is not None:
|
|
186
|
+
open_fence = fence
|
|
187
|
+
elif body == _AGENTS_BEGIN:
|
|
188
|
+
begins.append(offset)
|
|
189
|
+
elif body == _AGENTS_END:
|
|
190
|
+
ends.append((offset, offset + len(line)))
|
|
191
|
+
offset += len(line)
|
|
192
|
+
return begins, ends
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _merge_agents(existing: str, section: str) -> str:
|
|
196
|
+
begins, ends = _owned_marker_offsets(existing)
|
|
197
|
+
if not begins and not ends:
|
|
198
|
+
if not existing:
|
|
199
|
+
return section
|
|
200
|
+
separator = "\n" if existing.endswith("\n") else "\n\n"
|
|
201
|
+
return f"{existing}{separator}{section}"
|
|
202
|
+
if len(begins) != 1 or len(ends) != 1 or begins[0] >= ends[0][0]:
|
|
203
|
+
raise ValueError("AGENTS.md contains malformed Solwyn section markers.")
|
|
204
|
+
return f"{existing[: begins[0]]}{section}{existing[ends[0][1] :]}"
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _fsync_staged_file(file_fd: int) -> None:
|
|
208
|
+
os.fsync(file_fd)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _fsync_directory(directory_fd: int) -> None:
|
|
212
|
+
os.fsync(directory_fd)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _open_artifact_parent(
|
|
216
|
+
repository: Path, relative_path: Path
|
|
217
|
+
) -> tuple[int, Path, tuple[int, int]]:
|
|
218
|
+
current_path = repository
|
|
219
|
+
current_fd = os.open(repository, _DIRECTORY_FLAGS)
|
|
220
|
+
try:
|
|
221
|
+
for component in relative_path.parent.parts:
|
|
222
|
+
created = False
|
|
223
|
+
try:
|
|
224
|
+
os.mkdir(component, mode=0o755, dir_fd=current_fd)
|
|
225
|
+
created = True
|
|
226
|
+
except FileExistsError:
|
|
227
|
+
pass
|
|
228
|
+
next_fd = os.open(component, _DIRECTORY_FLAGS, dir_fd=current_fd)
|
|
229
|
+
if created:
|
|
230
|
+
_fsync_directory(current_fd)
|
|
231
|
+
os.close(current_fd)
|
|
232
|
+
current_fd = next_fd
|
|
233
|
+
current_path = current_path / component
|
|
234
|
+
metadata = os.fstat(current_fd)
|
|
235
|
+
if not stat.S_ISDIR(metadata.st_mode):
|
|
236
|
+
raise OSError(f"Agent artifact parent is not a directory: {current_path}")
|
|
237
|
+
return current_fd, current_path, (metadata.st_dev, metadata.st_ino)
|
|
238
|
+
except BaseException:
|
|
239
|
+
os.close(current_fd)
|
|
240
|
+
raise
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _file_identity(metadata: os.stat_result) -> _FILE_IDENTITY:
|
|
244
|
+
return (
|
|
245
|
+
metadata.st_dev,
|
|
246
|
+
metadata.st_ino,
|
|
247
|
+
metadata.st_mode,
|
|
248
|
+
metadata.st_size,
|
|
249
|
+
metadata.st_mtime_ns,
|
|
250
|
+
metadata.st_ctime_ns,
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _target_identity(parent_fd: int, target_name: str) -> _FILE_IDENTITY | None:
|
|
255
|
+
try:
|
|
256
|
+
metadata = os.stat(target_name, dir_fd=parent_fd, follow_symlinks=False)
|
|
257
|
+
except FileNotFoundError:
|
|
258
|
+
return None
|
|
259
|
+
return _file_identity(metadata)
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _validate_initial_target(state: _ArtifactState) -> None:
|
|
263
|
+
identity = state.original_identity
|
|
264
|
+
if identity is not None and not stat.S_ISREG(identity[2]):
|
|
265
|
+
raise OSError(
|
|
266
|
+
f"Refusing non-regular agent artifact target: {state.relative_path}"
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _read_agents(state: _ArtifactState) -> str:
|
|
271
|
+
if state.original_identity is None:
|
|
272
|
+
return ""
|
|
273
|
+
flags = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_CLOEXEC", 0)
|
|
274
|
+
file_fd = os.open(state.target_name, flags, dir_fd=state.parent_fd)
|
|
275
|
+
try:
|
|
276
|
+
if _file_identity(os.fstat(file_fd)) != state.original_identity:
|
|
277
|
+
raise OSError(
|
|
278
|
+
f"Agent artifact changed during agent artifact write: "
|
|
279
|
+
f"{state.relative_path}"
|
|
280
|
+
)
|
|
281
|
+
with os.fdopen(file_fd, encoding="utf-8", newline="") as artifact_file:
|
|
282
|
+
file_fd = -1
|
|
283
|
+
return artifact_file.read()
|
|
284
|
+
finally:
|
|
285
|
+
if file_fd >= 0:
|
|
286
|
+
os.close(file_fd)
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def _unique_transaction_name(kind: str, target_name: str) -> str:
|
|
290
|
+
return f"{_TRANSACTION_PREFIX}{kind}-{target_name}-{secrets.token_hex(8)}"
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def _unlink_at(parent_fd: int, name: str | None) -> None:
|
|
294
|
+
if name is None:
|
|
295
|
+
return
|
|
296
|
+
try:
|
|
297
|
+
os.unlink(name, dir_fd=parent_fd)
|
|
298
|
+
except FileNotFoundError:
|
|
299
|
+
pass
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def _stage_artifact(state: _ArtifactState) -> str:
|
|
303
|
+
temporary_name = _unique_transaction_name("stage", state.target_name)
|
|
304
|
+
mode = 0o644
|
|
305
|
+
if state.original_identity is not None:
|
|
306
|
+
mode = stat.S_IMODE(state.original_identity[2]) & 0o777
|
|
307
|
+
flags = (
|
|
308
|
+
os.O_WRONLY
|
|
309
|
+
| os.O_CREAT
|
|
310
|
+
| os.O_EXCL
|
|
311
|
+
| getattr(os, "O_NOFOLLOW", 0)
|
|
312
|
+
| getattr(os, "O_CLOEXEC", 0)
|
|
313
|
+
)
|
|
314
|
+
file_fd = -1
|
|
315
|
+
try:
|
|
316
|
+
file_fd = os.open(
|
|
317
|
+
temporary_name,
|
|
318
|
+
flags,
|
|
319
|
+
mode,
|
|
320
|
+
dir_fd=state.parent_fd,
|
|
321
|
+
)
|
|
322
|
+
os.fchmod(file_fd, mode)
|
|
323
|
+
with os.fdopen(
|
|
324
|
+
file_fd, mode="w", encoding="utf-8", newline=""
|
|
325
|
+
) as temporary_file:
|
|
326
|
+
file_fd = -1
|
|
327
|
+
temporary_file.write(state.content)
|
|
328
|
+
temporary_file.flush()
|
|
329
|
+
_fsync_staged_file(temporary_file.fileno())
|
|
330
|
+
return temporary_name
|
|
331
|
+
except BaseException:
|
|
332
|
+
if file_fd >= 0:
|
|
333
|
+
os.close(file_fd)
|
|
334
|
+
_unlink_at(state.parent_fd, temporary_name)
|
|
335
|
+
raise
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def _assert_parent_stable(state: _ArtifactState) -> None:
|
|
339
|
+
descriptor_metadata = os.fstat(state.parent_fd)
|
|
340
|
+
try:
|
|
341
|
+
path_metadata = os.stat(state.parent_path, follow_symlinks=False)
|
|
342
|
+
except FileNotFoundError:
|
|
343
|
+
path_metadata = None
|
|
344
|
+
descriptor_identity = (descriptor_metadata.st_dev, descriptor_metadata.st_ino)
|
|
345
|
+
path_identity = (
|
|
346
|
+
(path_metadata.st_dev, path_metadata.st_ino)
|
|
347
|
+
if path_metadata is not None and stat.S_ISDIR(path_metadata.st_mode)
|
|
348
|
+
else None
|
|
349
|
+
)
|
|
350
|
+
if (
|
|
351
|
+
descriptor_identity != state.parent_identity
|
|
352
|
+
or path_identity != state.parent_identity
|
|
353
|
+
):
|
|
354
|
+
raise OSError(
|
|
355
|
+
f"Agent artifact parent changed during agent artifact write: "
|
|
356
|
+
f"{state.relative_path.parent}"
|
|
357
|
+
)
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def _assert_target_stable(state: _ArtifactState) -> None:
|
|
361
|
+
if _target_identity(state.parent_fd, state.target_name) != state.original_identity:
|
|
362
|
+
raise OSError(
|
|
363
|
+
f"Agent artifact changed during agent artifact write: {state.relative_path}"
|
|
364
|
+
)
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def _same_file_version(
|
|
368
|
+
actual: _FILE_IDENTITY | None,
|
|
369
|
+
expected: _FILE_IDENTITY | None,
|
|
370
|
+
) -> bool:
|
|
371
|
+
if actual is None or expected is None:
|
|
372
|
+
return actual is expected
|
|
373
|
+
return actual[:-1] == expected[:-1]
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def _during_backup_copy(state: _ArtifactState) -> None:
|
|
377
|
+
"""Test seam after opening the stable source and before copying its bytes."""
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def _write_all(file_fd: int, content: bytes) -> None:
|
|
381
|
+
remaining = memoryview(content)
|
|
382
|
+
while remaining:
|
|
383
|
+
written = os.write(file_fd, remaining)
|
|
384
|
+
if written == 0:
|
|
385
|
+
raise OSError("Could not copy agent artifact backup.")
|
|
386
|
+
remaining = remaining[written:]
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def _backup_artifact(state: _ArtifactState) -> str | None:
|
|
390
|
+
if state.original_identity is None:
|
|
391
|
+
return None
|
|
392
|
+
backup_name = _unique_transaction_name("backup", state.target_name)
|
|
393
|
+
source_fd = -1
|
|
394
|
+
backup_fd = -1
|
|
395
|
+
try:
|
|
396
|
+
source_flags = (
|
|
397
|
+
os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_CLOEXEC", 0)
|
|
398
|
+
)
|
|
399
|
+
source_fd = os.open(
|
|
400
|
+
state.target_name,
|
|
401
|
+
source_flags,
|
|
402
|
+
dir_fd=state.parent_fd,
|
|
403
|
+
)
|
|
404
|
+
if (
|
|
405
|
+
_file_identity(os.fstat(source_fd)) != state.original_identity
|
|
406
|
+
or _target_identity(state.parent_fd, state.target_name)
|
|
407
|
+
!= state.original_identity
|
|
408
|
+
):
|
|
409
|
+
raise OSError(
|
|
410
|
+
f"Agent artifact changed during agent artifact write: "
|
|
411
|
+
f"{state.relative_path}"
|
|
412
|
+
)
|
|
413
|
+
|
|
414
|
+
backup_flags = (
|
|
415
|
+
os.O_WRONLY
|
|
416
|
+
| os.O_CREAT
|
|
417
|
+
| os.O_EXCL
|
|
418
|
+
| getattr(os, "O_NOFOLLOW", 0)
|
|
419
|
+
| getattr(os, "O_CLOEXEC", 0)
|
|
420
|
+
)
|
|
421
|
+
mode = stat.S_IMODE(state.original_identity[2]) & 0o777
|
|
422
|
+
backup_fd = os.open(
|
|
423
|
+
backup_name,
|
|
424
|
+
backup_flags,
|
|
425
|
+
mode,
|
|
426
|
+
dir_fd=state.parent_fd,
|
|
427
|
+
)
|
|
428
|
+
os.fchmod(backup_fd, mode)
|
|
429
|
+
_during_backup_copy(state)
|
|
430
|
+
while chunk := os.read(source_fd, 128 * 1024):
|
|
431
|
+
_write_all(backup_fd, chunk)
|
|
432
|
+
_fsync_staged_file(backup_fd)
|
|
433
|
+
|
|
434
|
+
source_identity = _file_identity(os.fstat(source_fd))
|
|
435
|
+
backup_identity = _target_identity(state.parent_fd, backup_name)
|
|
436
|
+
backup_descriptor_identity = _file_identity(os.fstat(backup_fd))
|
|
437
|
+
if (
|
|
438
|
+
source_identity != state.original_identity
|
|
439
|
+
or _target_identity(state.parent_fd, state.target_name)
|
|
440
|
+
!= state.original_identity
|
|
441
|
+
or backup_identity is None
|
|
442
|
+
or backup_identity != backup_descriptor_identity
|
|
443
|
+
or not stat.S_ISREG(backup_identity[2])
|
|
444
|
+
or backup_identity[:2] == source_identity[:2]
|
|
445
|
+
or backup_identity[3] != state.original_identity[3]
|
|
446
|
+
):
|
|
447
|
+
raise OSError(
|
|
448
|
+
f"Agent artifact changed during agent artifact write: "
|
|
449
|
+
f"{state.relative_path}"
|
|
450
|
+
)
|
|
451
|
+
return backup_name
|
|
452
|
+
except BaseException:
|
|
453
|
+
_unlink_at(state.parent_fd, backup_name)
|
|
454
|
+
raise
|
|
455
|
+
finally:
|
|
456
|
+
if source_fd >= 0:
|
|
457
|
+
os.close(source_fd)
|
|
458
|
+
if backup_fd >= 0:
|
|
459
|
+
os.close(backup_fd)
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
def _before_artifact_commit() -> None:
|
|
463
|
+
"""Test seam immediately before stable-parent and target revalidation."""
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
def _cleanup_transaction(states: list[_ArtifactState]) -> None:
|
|
467
|
+
for state in states:
|
|
468
|
+
if not state.retain_temporary_for_recovery:
|
|
469
|
+
_unlink_at(state.parent_fd, state.temporary_name)
|
|
470
|
+
if not state.retain_backup_for_recovery:
|
|
471
|
+
_unlink_at(state.parent_fd, state.backup_name)
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
def _state_recovery_paths(state: _ArtifactState) -> list[Path]:
|
|
475
|
+
names = []
|
|
476
|
+
if state.retain_backup_for_recovery and state.backup_name is not None:
|
|
477
|
+
names.append(state.backup_name)
|
|
478
|
+
if state.retain_temporary_for_recovery and state.temporary_name is not None:
|
|
479
|
+
names.append(state.temporary_name)
|
|
480
|
+
if state.rollback_recovery_name is not None:
|
|
481
|
+
names.append(state.rollback_recovery_name)
|
|
482
|
+
return [state.relative_path.parent / name for name in names]
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
def _rollback_transaction(states: list[_ArtifactState]) -> None:
|
|
486
|
+
rollback_error: OSError | None = None
|
|
487
|
+
conflict_paths: list[Path] = [
|
|
488
|
+
state.relative_path for state in states if state.conflict_detected
|
|
489
|
+
]
|
|
490
|
+
for state in reversed(states):
|
|
491
|
+
if not state.committed:
|
|
492
|
+
continue
|
|
493
|
+
if state.original_identity is not None and state.backup_name is not None:
|
|
494
|
+
try:
|
|
495
|
+
_atomic_exchange(
|
|
496
|
+
state.backup_name,
|
|
497
|
+
state.target_name,
|
|
498
|
+
state.parent_fd,
|
|
499
|
+
)
|
|
500
|
+
except OSError as error:
|
|
501
|
+
state.retain_backup_for_recovery = True
|
|
502
|
+
rollback_error = rollback_error or error
|
|
503
|
+
continue
|
|
504
|
+
displaced_identity = _target_identity(
|
|
505
|
+
state.parent_fd,
|
|
506
|
+
state.backup_name,
|
|
507
|
+
)
|
|
508
|
+
if _same_file_version(displaced_identity, state.installed_identity):
|
|
509
|
+
_unlink_at(state.parent_fd, state.backup_name)
|
|
510
|
+
state.backup_name = None
|
|
511
|
+
_fsync_directory(state.parent_fd)
|
|
512
|
+
continue
|
|
513
|
+
try:
|
|
514
|
+
_atomic_exchange(
|
|
515
|
+
state.backup_name,
|
|
516
|
+
state.target_name,
|
|
517
|
+
state.parent_fd,
|
|
518
|
+
)
|
|
519
|
+
except OSError as error:
|
|
520
|
+
state.rollback_recovery_name = state.backup_name
|
|
521
|
+
state.backup_name = None
|
|
522
|
+
rollback_error = rollback_error or error
|
|
523
|
+
else:
|
|
524
|
+
state.retain_backup_for_recovery = True
|
|
525
|
+
state.conflict_detected = True
|
|
526
|
+
if state.relative_path not in conflict_paths:
|
|
527
|
+
conflict_paths.append(state.relative_path)
|
|
528
|
+
continue
|
|
529
|
+
|
|
530
|
+
tombstone = _unique_transaction_name("rollback", state.target_name)
|
|
531
|
+
try:
|
|
532
|
+
_atomic_noreplace(state.target_name, tombstone, state.parent_fd)
|
|
533
|
+
except FileNotFoundError:
|
|
534
|
+
state.conflict_detected = True
|
|
535
|
+
if state.relative_path not in conflict_paths:
|
|
536
|
+
conflict_paths.append(state.relative_path)
|
|
537
|
+
continue
|
|
538
|
+
except OSError as error:
|
|
539
|
+
rollback_error = rollback_error or error
|
|
540
|
+
continue
|
|
541
|
+
displaced_identity = _target_identity(state.parent_fd, tombstone)
|
|
542
|
+
if _same_file_version(displaced_identity, state.installed_identity):
|
|
543
|
+
_unlink_at(state.parent_fd, tombstone)
|
|
544
|
+
_fsync_directory(state.parent_fd)
|
|
545
|
+
continue
|
|
546
|
+
try:
|
|
547
|
+
_atomic_noreplace(tombstone, state.target_name, state.parent_fd)
|
|
548
|
+
except OSError as error:
|
|
549
|
+
state.rollback_recovery_name = tombstone
|
|
550
|
+
rollback_error = rollback_error or error
|
|
551
|
+
state.conflict_detected = True
|
|
552
|
+
if state.relative_path not in conflict_paths:
|
|
553
|
+
conflict_paths.append(state.relative_path)
|
|
554
|
+
_cleanup_transaction(states)
|
|
555
|
+
for state in states:
|
|
556
|
+
try:
|
|
557
|
+
_fsync_directory(state.parent_fd)
|
|
558
|
+
except OSError as error:
|
|
559
|
+
rollback_error = rollback_error or error
|
|
560
|
+
recovery_paths = tuple(
|
|
561
|
+
path for state in states for path in _state_recovery_paths(state)
|
|
562
|
+
)
|
|
563
|
+
ordered_conflicts = tuple(dict.fromkeys(conflict_paths))
|
|
564
|
+
if recovery_paths or ordered_conflicts:
|
|
565
|
+
raise ArtifactRecoveryError(
|
|
566
|
+
recovery_paths,
|
|
567
|
+
ordered_conflicts,
|
|
568
|
+
) from rollback_error
|
|
569
|
+
if rollback_error is not None:
|
|
570
|
+
raise OSError(
|
|
571
|
+
"Could not roll back agent artifact transaction."
|
|
572
|
+
) from rollback_error
|
|
573
|
+
|
|
574
|
+
|
|
575
|
+
def _close_parents(states: list[_ArtifactState]) -> None:
|
|
576
|
+
for state in states:
|
|
577
|
+
os.close(state.parent_fd)
|
|
578
|
+
|
|
579
|
+
|
|
580
|
+
def write_agent_artifacts(root: Path) -> None:
|
|
581
|
+
"""Transactionally write artifacts using POSIX no-follow directory handles."""
|
|
582
|
+
if os.name != "posix":
|
|
583
|
+
raise OSError("Agent artifact writes require a POSIX filesystem.")
|
|
584
|
+
repository = root.resolve(strict=True)
|
|
585
|
+
rendered = {
|
|
586
|
+
CLAUDE_SKILL_PATH: render_surface(ArtifactSurface.CLAUDE_SKILL),
|
|
587
|
+
CURSOR_RULE_PATH: render_surface(ArtifactSurface.CURSOR_RULE),
|
|
588
|
+
}
|
|
589
|
+
states: list[_ArtifactState] = []
|
|
590
|
+
commit_started = False
|
|
591
|
+
try:
|
|
592
|
+
parent_fd, parent_path, parent_identity = _open_artifact_parent(
|
|
593
|
+
repository, AGENTS_PATH
|
|
594
|
+
)
|
|
595
|
+
agents_state = _ArtifactState(
|
|
596
|
+
relative_path=AGENTS_PATH,
|
|
597
|
+
parent_path=parent_path,
|
|
598
|
+
parent_fd=parent_fd,
|
|
599
|
+
parent_identity=parent_identity,
|
|
600
|
+
target_name=AGENTS_PATH.name,
|
|
601
|
+
original_identity=_target_identity(parent_fd, AGENTS_PATH.name),
|
|
602
|
+
content="",
|
|
603
|
+
)
|
|
604
|
+
states.append(agents_state)
|
|
605
|
+
_validate_initial_target(agents_state)
|
|
606
|
+
agents_state.content = _merge_agents(
|
|
607
|
+
_read_agents(agents_state),
|
|
608
|
+
render_surface(ArtifactSurface.AGENTS_SECTION),
|
|
609
|
+
)
|
|
610
|
+
|
|
611
|
+
for relative_path in (CLAUDE_SKILL_PATH, CURSOR_RULE_PATH):
|
|
612
|
+
parent_fd, parent_path, parent_identity = _open_artifact_parent(
|
|
613
|
+
repository, relative_path
|
|
614
|
+
)
|
|
615
|
+
state = _ArtifactState(
|
|
616
|
+
relative_path=relative_path,
|
|
617
|
+
parent_path=parent_path,
|
|
618
|
+
parent_fd=parent_fd,
|
|
619
|
+
parent_identity=parent_identity,
|
|
620
|
+
target_name=relative_path.name,
|
|
621
|
+
original_identity=_target_identity(parent_fd, relative_path.name),
|
|
622
|
+
content=rendered.get(relative_path, ""),
|
|
623
|
+
)
|
|
624
|
+
states.append(state)
|
|
625
|
+
_validate_initial_target(state)
|
|
626
|
+
states = [states[1], states[2], states[0]]
|
|
627
|
+
|
|
628
|
+
for state in states:
|
|
629
|
+
state.temporary_name = _stage_artifact(state)
|
|
630
|
+
for state in states:
|
|
631
|
+
_fsync_directory(state.parent_fd)
|
|
632
|
+
|
|
633
|
+
_before_artifact_commit()
|
|
634
|
+
for state in states:
|
|
635
|
+
_assert_parent_stable(state)
|
|
636
|
+
_assert_target_stable(state)
|
|
637
|
+
state.backup_name = _backup_artifact(state)
|
|
638
|
+
for state in states:
|
|
639
|
+
_fsync_directory(state.parent_fd)
|
|
640
|
+
for state in states:
|
|
641
|
+
_assert_parent_stable(state)
|
|
642
|
+
_assert_target_stable(state)
|
|
643
|
+
for state in states:
|
|
644
|
+
commit_started = True
|
|
645
|
+
if state.temporary_name is None:
|
|
646
|
+
raise OSError("Agent artifact stage is missing.")
|
|
647
|
+
staged_identity = _target_identity(
|
|
648
|
+
state.parent_fd,
|
|
649
|
+
state.temporary_name,
|
|
650
|
+
)
|
|
651
|
+
if staged_identity is None:
|
|
652
|
+
raise OSError("Agent artifact stage is missing.")
|
|
653
|
+
state.installed_identity = staged_identity
|
|
654
|
+
if state.original_identity is None:
|
|
655
|
+
try:
|
|
656
|
+
_atomic_noreplace(
|
|
657
|
+
state.temporary_name,
|
|
658
|
+
state.target_name,
|
|
659
|
+
state.parent_fd,
|
|
660
|
+
)
|
|
661
|
+
except FileExistsError:
|
|
662
|
+
state.conflict_detected = True
|
|
663
|
+
raise OSError(
|
|
664
|
+
f"Agent artifact changed during agent artifact write: "
|
|
665
|
+
f"{state.relative_path}"
|
|
666
|
+
) from None
|
|
667
|
+
state.temporary_name = None
|
|
668
|
+
state.committed = True
|
|
669
|
+
else:
|
|
670
|
+
_atomic_exchange(
|
|
671
|
+
state.temporary_name,
|
|
672
|
+
state.target_name,
|
|
673
|
+
state.parent_fd,
|
|
674
|
+
)
|
|
675
|
+
state.committed = True
|
|
676
|
+
displaced_identity = _target_identity(
|
|
677
|
+
state.parent_fd,
|
|
678
|
+
state.temporary_name,
|
|
679
|
+
)
|
|
680
|
+
if not _same_file_version(
|
|
681
|
+
displaced_identity,
|
|
682
|
+
state.original_identity,
|
|
683
|
+
):
|
|
684
|
+
state.conflict_detected = True
|
|
685
|
+
state.retain_backup_for_recovery = True
|
|
686
|
+
try:
|
|
687
|
+
_atomic_exchange(
|
|
688
|
+
state.temporary_name,
|
|
689
|
+
state.target_name,
|
|
690
|
+
state.parent_fd,
|
|
691
|
+
)
|
|
692
|
+
except OSError:
|
|
693
|
+
state.retain_temporary_for_recovery = True
|
|
694
|
+
else:
|
|
695
|
+
state.committed = False
|
|
696
|
+
raise OSError(
|
|
697
|
+
f"Agent artifact changed during agent artifact write: "
|
|
698
|
+
f"{state.relative_path}"
|
|
699
|
+
)
|
|
700
|
+
installed_identity = _target_identity(
|
|
701
|
+
state.parent_fd,
|
|
702
|
+
state.target_name,
|
|
703
|
+
)
|
|
704
|
+
if installed_identity is None or not _same_file_version(
|
|
705
|
+
installed_identity, staged_identity
|
|
706
|
+
):
|
|
707
|
+
raise OSError(
|
|
708
|
+
f"Agent artifact changed during agent artifact write: "
|
|
709
|
+
f"{state.relative_path}"
|
|
710
|
+
)
|
|
711
|
+
state.installed_identity = installed_identity
|
|
712
|
+
if state.temporary_name is not None:
|
|
713
|
+
_unlink_at(state.parent_fd, state.temporary_name)
|
|
714
|
+
state.temporary_name = None
|
|
715
|
+
_fsync_directory(state.parent_fd)
|
|
716
|
+
_assert_parent_stable(state)
|
|
717
|
+
_cleanup_transaction(states)
|
|
718
|
+
for state in states:
|
|
719
|
+
_fsync_directory(state.parent_fd)
|
|
720
|
+
except BaseException:
|
|
721
|
+
if commit_started:
|
|
722
|
+
_rollback_transaction(states)
|
|
723
|
+
else:
|
|
724
|
+
_cleanup_transaction(states)
|
|
725
|
+
raise
|
|
726
|
+
finally:
|
|
727
|
+
_close_parents(states)
|