syncmill 0.2.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.
- syncmill/__init__.py +11 -0
- syncmill/agents.py +2064 -0
- syncmill/artifact_io.py +201 -0
- syncmill/artifacts.py +1946 -0
- syncmill/board.py +2060 -0
- syncmill/breaker.py +327 -0
- syncmill/cli.py +4986 -0
- syncmill/command_errors.py +32 -0
- syncmill/config.py +1118 -0
- syncmill/dashboard.py +935 -0
- syncmill/gates.py +179 -0
- syncmill/gateway.py +234 -0
- syncmill/git_env.py +99 -0
- syncmill/isolated_session.py +431 -0
- syncmill/matcher.py +127 -0
- syncmill/memory.py +1067 -0
- syncmill/memory_backend.py +1907 -0
- syncmill/memory_migration.py +962 -0
- syncmill/model_routing.py +482 -0
- syncmill/models.py +244 -0
- syncmill/monitor.py +775 -0
- syncmill/phoenix_exporter.py +339 -0
- syncmill/planning.py +512 -0
- syncmill/platform_support.py +14 -0
- syncmill/preflight.py +1129 -0
- syncmill/project.py +1067 -0
- syncmill/report.py +502 -0
- syncmill/review_candidates.py +107 -0
- syncmill/room_compare.py +2380 -0
- syncmill/room_raw.py +484 -0
- syncmill/room_viewer.py +604 -0
- syncmill/rooms.py +4044 -0
- syncmill/session.py +444 -0
- syncmill/strategies/__init__.py +82 -0
- syncmill/strategies/base.py +910 -0
- syncmill/strategies/compete.py +233 -0
- syncmill/strategies/council.py +429 -0
- syncmill/strategies/decompose.py +570 -0
- syncmill/strategies/pipeline.py +221 -0
- syncmill/strategies/route.py +130 -0
- syncmill/supervisor.py +1423 -0
- syncmill/teamfile.py +571 -0
- syncmill/telemetry_hook.py +231 -0
- syncmill/terminal.py +245 -0
- syncmill/tracer.py +548 -0
- syncmill/tui.py +3537 -0
- syncmill/tui_collaboration.py +499 -0
- syncmill/tui_jobs.py +1407 -0
- syncmill/tui_render.py +156 -0
- syncmill/workflow.py +3746 -0
- syncmill/worktree.py +1045 -0
- syncmill-0.2.0.dist-info/METADATA +853 -0
- syncmill-0.2.0.dist-info/RECORD +56 -0
- syncmill-0.2.0.dist-info/WHEEL +4 -0
- syncmill-0.2.0.dist-info/entry_points.txt +2 -0
- syncmill-0.2.0.dist-info/licenses/LICENSE +191 -0
syncmill/artifact_io.py
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
"""Descriptor-relative artifact I/O beneath a trusted, canonical state root.
|
|
2
|
+
|
|
3
|
+
This prevents path traversal and symlink redirection, not arbitrary tampering by
|
|
4
|
+
another process with the same OS credentials. Directory handles live for one
|
|
5
|
+
operation and are checked against their names before use.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import errno
|
|
11
|
+
import os
|
|
12
|
+
import shutil
|
|
13
|
+
import stat
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from uuid import uuid4
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
Signature = tuple[int, int, int, int, int]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def file_signature(info: os.stat_result) -> Signature:
|
|
22
|
+
"""Change signature of an artifact file: a rewrite through ``write``
|
|
23
|
+
lands as a new inode (temp + replace), an in-place edit moves size or
|
|
24
|
+
mtime, so an equal tuple means the bytes are the ones last parsed."""
|
|
25
|
+
return (info.st_dev, info.st_ino, info.st_size, info.st_mtime_ns, info.st_ctime_ns)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def validate_id(value: str) -> None:
|
|
29
|
+
if (
|
|
30
|
+
not isinstance(value, str)
|
|
31
|
+
or not value
|
|
32
|
+
or value in {".", ".."}
|
|
33
|
+
or any(c in value for c in ("/", "\\", "\0"))
|
|
34
|
+
):
|
|
35
|
+
raise ValueError("artifact ID must be a single non-empty path component")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class ArtifactIO:
|
|
39
|
+
def __init__(self, base: Path):
|
|
40
|
+
self.base = base
|
|
41
|
+
self.fds: dict[Path, int] = {}
|
|
42
|
+
self.fds[base] = os.open(base, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW)
|
|
43
|
+
|
|
44
|
+
def close(self) -> None:
|
|
45
|
+
for fd in reversed(list(self.fds.values())):
|
|
46
|
+
os.close(fd)
|
|
47
|
+
self.fds.clear()
|
|
48
|
+
|
|
49
|
+
def release(self, path: Path) -> None:
|
|
50
|
+
"""Bound descriptor use when listing many runs."""
|
|
51
|
+
for opened in reversed(list(self.fds)):
|
|
52
|
+
if opened == path or path in opened.parents:
|
|
53
|
+
os.close(self.fds.pop(opened))
|
|
54
|
+
|
|
55
|
+
def directory(self, path: Path, *, create: bool = False) -> int:
|
|
56
|
+
if path == self.base:
|
|
57
|
+
return self.fds[path]
|
|
58
|
+
# Reject lexical traversal before any OS operation (Path normalizes '.').
|
|
59
|
+
parts = path.relative_to(self.base).parts
|
|
60
|
+
for part in parts:
|
|
61
|
+
validate_id(part)
|
|
62
|
+
parent = self.directory(path.parent, create=create)
|
|
63
|
+
if path in self.fds:
|
|
64
|
+
current = os.stat(path.name, dir_fd=parent, follow_symlinks=False)
|
|
65
|
+
opened = os.fstat(self.fds[path])
|
|
66
|
+
if (current.st_dev, current.st_ino) != (opened.st_dev, opened.st_ino):
|
|
67
|
+
raise OSError(errno.ESTALE, "artifact directory changed", str(path))
|
|
68
|
+
return self.fds[path]
|
|
69
|
+
if create:
|
|
70
|
+
try:
|
|
71
|
+
os.mkdir(path.name, 0o700, dir_fd=parent)
|
|
72
|
+
except FileExistsError:
|
|
73
|
+
pass
|
|
74
|
+
fd = os.open(
|
|
75
|
+
path.name, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW, dir_fd=parent
|
|
76
|
+
)
|
|
77
|
+
self.fds[path] = fd
|
|
78
|
+
return fd
|
|
79
|
+
|
|
80
|
+
def mkdir(self, path: Path, *, exist_ok: bool = True) -> None:
|
|
81
|
+
parent = self.directory(path.parent)
|
|
82
|
+
try:
|
|
83
|
+
os.mkdir(path.name, 0o700, dir_fd=parent)
|
|
84
|
+
except FileExistsError:
|
|
85
|
+
if not exist_ok:
|
|
86
|
+
raise
|
|
87
|
+
self.directory(path)
|
|
88
|
+
|
|
89
|
+
def open_file(self, path: Path, flags: int = os.O_RDONLY) -> int:
|
|
90
|
+
parent = self.directory(path.parent)
|
|
91
|
+
fd = os.open(
|
|
92
|
+
path.name, flags | os.O_NOFOLLOW | os.O_NONBLOCK, 0o600, dir_fd=parent
|
|
93
|
+
)
|
|
94
|
+
try:
|
|
95
|
+
info = os.fstat(fd)
|
|
96
|
+
if not stat.S_ISREG(info.st_mode) or info.st_nlink != 1:
|
|
97
|
+
raise OSError(
|
|
98
|
+
errno.EINVAL,
|
|
99
|
+
"artifact file must be a singly linked regular file",
|
|
100
|
+
str(path),
|
|
101
|
+
)
|
|
102
|
+
except BaseException:
|
|
103
|
+
os.close(fd)
|
|
104
|
+
raise
|
|
105
|
+
return fd
|
|
106
|
+
|
|
107
|
+
def read_if_changed(
|
|
108
|
+
self, path: Path, signature: Signature | None, limit: int = -1
|
|
109
|
+
) -> tuple[Signature, bytes | None]:
|
|
110
|
+
"""Open once, fstat, and read the content ONLY if its signature
|
|
111
|
+
differs from ``signature`` (``None`` content means unchanged). Every
|
|
112
|
+
content read in this module goes through here, so one seam observes
|
|
113
|
+
them all (#168)."""
|
|
114
|
+
fd = self.open_file(path)
|
|
115
|
+
try:
|
|
116
|
+
current = file_signature(os.fstat(fd))
|
|
117
|
+
if signature is not None and current == signature:
|
|
118
|
+
os.close(fd)
|
|
119
|
+
return current, None
|
|
120
|
+
stream = os.fdopen(fd, "rb")
|
|
121
|
+
except BaseException:
|
|
122
|
+
os.close(fd)
|
|
123
|
+
raise
|
|
124
|
+
with stream:
|
|
125
|
+
return current, stream.read(limit)
|
|
126
|
+
|
|
127
|
+
def read(self, path: Path, limit: int = -1) -> bytes:
|
|
128
|
+
data = self.read_if_changed(path, None, limit)[1]
|
|
129
|
+
assert data is not None
|
|
130
|
+
return data
|
|
131
|
+
|
|
132
|
+
def exists(self, path: Path) -> bool:
|
|
133
|
+
try:
|
|
134
|
+
fd = self.open_file(path)
|
|
135
|
+
except FileNotFoundError:
|
|
136
|
+
return False
|
|
137
|
+
os.close(fd)
|
|
138
|
+
return True
|
|
139
|
+
|
|
140
|
+
def write(self, path: Path, data: bytes) -> None:
|
|
141
|
+
parent = self.directory(path.parent)
|
|
142
|
+
# Refuse existing links/special files even though rename would replace
|
|
143
|
+
# rather than follow the final component.
|
|
144
|
+
self.exists(path)
|
|
145
|
+
name = f".{path.name}.{uuid4().hex}.tmp"
|
|
146
|
+
fd = os.open(
|
|
147
|
+
name,
|
|
148
|
+
os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW,
|
|
149
|
+
0o600,
|
|
150
|
+
dir_fd=parent,
|
|
151
|
+
)
|
|
152
|
+
try:
|
|
153
|
+
try:
|
|
154
|
+
stream = os.fdopen(fd, "wb")
|
|
155
|
+
except BaseException:
|
|
156
|
+
os.close(fd)
|
|
157
|
+
raise
|
|
158
|
+
with stream:
|
|
159
|
+
stream.write(data)
|
|
160
|
+
stream.flush()
|
|
161
|
+
os.fsync(stream.fileno())
|
|
162
|
+
self.directory(path.parent) # detect a renamed/replaced run
|
|
163
|
+
os.replace(name, path.name, src_dir_fd=parent, dst_dir_fd=parent)
|
|
164
|
+
os.fsync(parent)
|
|
165
|
+
finally:
|
|
166
|
+
try:
|
|
167
|
+
os.unlink(name, dir_fd=parent)
|
|
168
|
+
except FileNotFoundError:
|
|
169
|
+
pass
|
|
170
|
+
|
|
171
|
+
def names(self, path: Path) -> list[str]:
|
|
172
|
+
return os.listdir(self.directory(path))
|
|
173
|
+
|
|
174
|
+
def entry_mode(self, path: Path) -> int:
|
|
175
|
+
"""``st_mode`` of a directory entry without following a symlink — one
|
|
176
|
+
stat answers both the symlink and the directory question."""
|
|
177
|
+
return os.stat(
|
|
178
|
+
path.name, dir_fd=self.directory(path.parent), follow_symlinks=False
|
|
179
|
+
).st_mode
|
|
180
|
+
|
|
181
|
+
def is_directory(self, path: Path) -> bool:
|
|
182
|
+
return stat.S_ISDIR(self.entry_mode(path))
|
|
183
|
+
|
|
184
|
+
def is_symlink(self, path: Path) -> bool:
|
|
185
|
+
return stat.S_ISLNK(self.entry_mode(path))
|
|
186
|
+
|
|
187
|
+
def unlink(self, path: Path) -> None:
|
|
188
|
+
parent = self.directory(path.parent)
|
|
189
|
+
try:
|
|
190
|
+
os.unlink(path.name, dir_fd=parent)
|
|
191
|
+
except FileNotFoundError:
|
|
192
|
+
pass
|
|
193
|
+
|
|
194
|
+
def rmtree(self, path: Path) -> None:
|
|
195
|
+
self.directory(path)
|
|
196
|
+
if not shutil.rmtree.avoids_symlink_attacks:
|
|
197
|
+
raise OSError("descriptor-relative directory removal unavailable")
|
|
198
|
+
try:
|
|
199
|
+
shutil.rmtree(path.name, dir_fd=self.directory(path.parent))
|
|
200
|
+
finally:
|
|
201
|
+
self.release(path)
|