aisc-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.
- aisc/VERSION +1 -0
- aisc/__init__.py +49 -0
- aisc/__main__.py +6 -0
- aisc/adapters/__init__.py +1 -0
- aisc/adapters/config_reader.py +215 -0
- aisc/adapters/container_registry.py +718 -0
- aisc/adapters/data_root_store.py +296 -0
- aisc/adapters/docker_.py +1183 -0
- aisc/adapters/docker_gateway.py +1244 -0
- aisc/adapters/docker_sdk_backed.py +274 -0
- aisc/adapters/maintenance_lock.py +97 -0
- aisc/adapters/state_file.py +184 -0
- aisc/adapters/system.py +101 -0
- aisc/adapters/windows_config_reader.py +249 -0
- aisc/adapters/workspace_lease_store.py +189 -0
- aisc/application/__init__.py +1 -0
- aisc/application/artifact.py +265 -0
- aisc/application/bundle_fetch.py +836 -0
- aisc/application/cc_switch_provider.py +245 -0
- aisc/application/cc_switch_resolver.py +363 -0
- aisc/application/config_service.py +452 -0
- aisc/application/conversation.py +516 -0
- aisc/application/data_migration.py +440 -0
- aisc/application/data_root.py +275 -0
- aisc/application/docker_lifecycle.py +659 -0
- aisc/application/doctor.py +821 -0
- aisc/application/legacy_scan.py +252 -0
- aisc/application/network_subscription.py +642 -0
- aisc/application/profile_service.py +105 -0
- aisc/application/provider.py +92 -0
- aisc/application/repair.py +387 -0
- aisc/application/resources.py +270 -0
- aisc/application/runtime.py +1826 -0
- aisc/application/session.py +320 -0
- aisc/application/tool_versions.py +210 -0
- aisc/application/toolchain.py +91 -0
- aisc/application/update.py +286 -0
- aisc/application/usage.py +305 -0
- aisc/application/version.py +133 -0
- aisc/application/web_gateway.py +459 -0
- aisc/application/workspace_reconcile.py +318 -0
- aisc/applog.py +103 -0
- aisc/cli/__init__.py +1 -0
- aisc/cli/build_progress_parser.py +217 -0
- aisc/cli/commands/__init__.py +1 -0
- aisc/cli/commands/agents.py +117 -0
- aisc/cli/commands/artifact.py +210 -0
- aisc/cli/commands/build.py +566 -0
- aisc/cli/commands/bundle.py +107 -0
- aisc/cli/commands/cc_switch.py +158 -0
- aisc/cli/commands/config.py +104 -0
- aisc/cli/commands/container.py +787 -0
- aisc/cli/commands/conversation.py +72 -0
- aisc/cli/commands/data_root.py +84 -0
- aisc/cli/commands/logs.py +89 -0
- aisc/cli/commands/network.py +132 -0
- aisc/cli/commands/profile.py +52 -0
- aisc/cli/commands/provider.py +49 -0
- aisc/cli/commands/run.py +494 -0
- aisc/cli/commands/runs.py +125 -0
- aisc/cli/commands/runtime.py +450 -0
- aisc/cli/commands/serve.py +550 -0
- aisc/cli/commands/serve_fs.py +369 -0
- aisc/cli/commands/session.py +167 -0
- aisc/cli/commands/update.py +145 -0
- aisc/cli/commands/usage.py +74 -0
- aisc/cli/commands/wizard.py +130 -0
- aisc/cli/commands/workspaces.py +162 -0
- aisc/cli/help_i18n.py +385 -0
- aisc/cli/main.py +2926 -0
- aisc/cli/output.py +239 -0
- aisc/cli/tutorial.py +109 -0
- aisc/domain/__init__.py +1 -0
- aisc/domain/artifacts.py +279 -0
- aisc/domain/cc_switch_release.py +303 -0
- aisc/domain/config.py +64 -0
- aisc/domain/data_migration.py +259 -0
- aisc/domain/data_root.py +124 -0
- aisc/domain/docker_ownership.py +148 -0
- aisc/domain/gateway.py +168 -0
- aisc/domain/models.py +674 -0
- aisc/domain/toolchain.py +72 -0
- aisc/domain/web_services.py +355 -0
- aisc/domain/workspace_lease.py +139 -0
- aisc/schemas/__init__.py +1 -0
- aisc/schemas/config_schema.py +145 -0
- aisc_cli-0.1.0.dist-info/METADATA +420 -0
- aisc_cli-0.1.0.dist-info/RECORD +92 -0
- aisc_cli-0.1.0.dist-info/WHEEL +5 -0
- aisc_cli-0.1.0.dist-info/entry_points.txt +2 -0
- aisc_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
- aisc_cli-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
"""Unified data-root storage adapter (Stage 7, 7b).
|
|
2
|
+
|
|
3
|
+
One directory/lock/atomic API for every writer that moves under the data
|
|
4
|
+
root (state, config, artifact metadata, diagnostics; wiring happens in 7e).
|
|
5
|
+
Semantics come from the proven predecessors and are unified here:
|
|
6
|
+
|
|
7
|
+
- directories: idempotent ``prepare`` creates the contract skeleton exactly
|
|
8
|
+
(no invented siblings), every write re-checks the target chain for
|
|
9
|
+
reparse points (TOCTOU defense on top of the resolver's resolve-time
|
|
10
|
+
validation);
|
|
11
|
+
- locks: cross-process fail-closed byte lock (``msvcrt``/``fcntl``,
|
|
12
|
+
``container_registry._registry_lock`` semantics) parked under
|
|
13
|
+
``state/locks/`` per the domain contract;
|
|
14
|
+
- atomic writes: UTF-8, temp file in the target directory, flush + fsync,
|
|
15
|
+
``os.replace`` (``_write_registry_unlocked`` semantics) — readers never
|
|
16
|
+
see half-written files;
|
|
17
|
+
- corruption: a failing JSON read is isolated to ``*.corrupt`` (Rust
|
|
18
|
+
``storage.rs``/``settings.rs`` precedent), never truncated in place.
|
|
19
|
+
|
|
20
|
+
The store never decides WHAT to store — schema/version handling stays with
|
|
21
|
+
each feature module.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import json
|
|
27
|
+
import os
|
|
28
|
+
import stat
|
|
29
|
+
import sys
|
|
30
|
+
import tempfile
|
|
31
|
+
from contextlib import contextmanager
|
|
32
|
+
from pathlib import Path
|
|
33
|
+
from typing import Any, Iterator, Optional
|
|
34
|
+
|
|
35
|
+
from aisc.domain.artifacts import validate_relative_path
|
|
36
|
+
from aisc.domain.data_root import (
|
|
37
|
+
ERR_LOCK_TIMEOUT,
|
|
38
|
+
ERR_REPARSE_POINT,
|
|
39
|
+
LOCKS_SUBDIR,
|
|
40
|
+
SHARED_SUBDIRS,
|
|
41
|
+
WORKSPACE_SUBDIRS,
|
|
42
|
+
ResolvedDataRoot,
|
|
43
|
+
)
|
|
44
|
+
from aisc.domain.models import CliError
|
|
45
|
+
|
|
46
|
+
SCOPE_SHARED = "shared"
|
|
47
|
+
SCOPE_WORKSPACE = "workspace"
|
|
48
|
+
|
|
49
|
+
_FILE_ATTRIBUTE_REPARSE_POINT = 0x0400 # windows.h
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _is_reparse_point(path: Path) -> bool:
|
|
53
|
+
try:
|
|
54
|
+
st = os.lstat(path)
|
|
55
|
+
except OSError:
|
|
56
|
+
return False
|
|
57
|
+
if stat.S_ISLNK(st.st_mode):
|
|
58
|
+
return True
|
|
59
|
+
return bool(getattr(st, "st_file_attributes", 0) & _FILE_ATTRIBUTE_REPARSE_POINT)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _check_reparse_chain(root: Path, target: Path) -> None:
|
|
63
|
+
"""Reject reparse points on any EXISTING segment between *root* and
|
|
64
|
+
*target* (defense-in-depth: the resolver validated at resolve time)."""
|
|
65
|
+
cur = target
|
|
66
|
+
stop = root
|
|
67
|
+
while True:
|
|
68
|
+
if _is_reparse_point(cur):
|
|
69
|
+
raise CliError(
|
|
70
|
+
f"data root path component is a reparse point/symlink: {cur}",
|
|
71
|
+
exit_code=1,
|
|
72
|
+
error_code=ERR_REPARSE_POINT,
|
|
73
|
+
)
|
|
74
|
+
if cur == stop:
|
|
75
|
+
return
|
|
76
|
+
parent = cur.parent
|
|
77
|
+
if parent == cur:
|
|
78
|
+
return # target outside root: reject via containment first
|
|
79
|
+
cur = parent
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@contextmanager
|
|
83
|
+
def file_lock(
|
|
84
|
+
lock_path: "Path | str",
|
|
85
|
+
timeout: float = 10.0,
|
|
86
|
+
*,
|
|
87
|
+
error_code: str = ERR_LOCK_TIMEOUT,
|
|
88
|
+
) -> Iterator[None]:
|
|
89
|
+
"""Hold a cross-process lock across a complete read-modify-write cycle.
|
|
90
|
+
|
|
91
|
+
Fail-closed (bounded retry on Windows ``msvcrt.locking``; ``fcntl.flock``
|
|
92
|
+
with SIGALRM timeout on POSIX). Raises CliError(*error_code*) on timeout
|
|
93
|
+
— callers with legacy code contracts (e.g. STATE_LOCK_TIMEOUT at 7e
|
|
94
|
+
wiring time) pass their own ``error_code``.
|
|
95
|
+
"""
|
|
96
|
+
import time as time_module
|
|
97
|
+
|
|
98
|
+
lock_path = Path(lock_path)
|
|
99
|
+
lock_path.parent.mkdir(parents=True, exist_ok=True)
|
|
100
|
+
lock_fd = None
|
|
101
|
+
locked = False
|
|
102
|
+
|
|
103
|
+
try:
|
|
104
|
+
lock_fd = os.open(str(lock_path), os.O_CREAT | os.O_RDWR, 0o644)
|
|
105
|
+
|
|
106
|
+
if sys.platform == "win32":
|
|
107
|
+
import msvcrt
|
|
108
|
+
|
|
109
|
+
start_time = time_module.time()
|
|
110
|
+
while True:
|
|
111
|
+
try:
|
|
112
|
+
msvcrt.locking(lock_fd, msvcrt.LK_NBLCK, 1)
|
|
113
|
+
locked = True
|
|
114
|
+
break
|
|
115
|
+
except OSError as exc:
|
|
116
|
+
if time_module.time() - start_time >= timeout:
|
|
117
|
+
raise CliError(
|
|
118
|
+
f"lock timeout after {timeout}s: {lock_path}",
|
|
119
|
+
exit_code=1,
|
|
120
|
+
error_code=error_code,
|
|
121
|
+
) from exc
|
|
122
|
+
time_module.sleep(0.1)
|
|
123
|
+
else:
|
|
124
|
+
import fcntl
|
|
125
|
+
import signal
|
|
126
|
+
|
|
127
|
+
def _timeout(signum: Any, frame: Any) -> None:
|
|
128
|
+
raise CliError(
|
|
129
|
+
f"lock timeout after {timeout}s: {lock_path}",
|
|
130
|
+
exit_code=1,
|
|
131
|
+
error_code=error_code,
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
alarm_set = False
|
|
135
|
+
old_handler = None
|
|
136
|
+
try:
|
|
137
|
+
old_handler = signal.signal(signal.SIGALRM, _timeout)
|
|
138
|
+
signal.alarm(int(timeout) + 1)
|
|
139
|
+
alarm_set = True
|
|
140
|
+
except ValueError:
|
|
141
|
+
pass # not in main thread: fall back to blocking lock
|
|
142
|
+
|
|
143
|
+
try:
|
|
144
|
+
fcntl.flock(lock_fd, fcntl.LOCK_EX)
|
|
145
|
+
locked = True
|
|
146
|
+
finally:
|
|
147
|
+
if alarm_set:
|
|
148
|
+
signal.alarm(0)
|
|
149
|
+
signal.signal(signal.SIGALRM, old_handler)
|
|
150
|
+
|
|
151
|
+
yield
|
|
152
|
+
finally:
|
|
153
|
+
if locked and lock_fd is not None:
|
|
154
|
+
try:
|
|
155
|
+
if sys.platform == "win32":
|
|
156
|
+
import msvcrt
|
|
157
|
+
|
|
158
|
+
msvcrt.locking(lock_fd, msvcrt.LK_UNLCK, 1)
|
|
159
|
+
else:
|
|
160
|
+
import fcntl
|
|
161
|
+
|
|
162
|
+
fcntl.flock(lock_fd, fcntl.LOCK_UN)
|
|
163
|
+
except OSError:
|
|
164
|
+
pass
|
|
165
|
+
if lock_fd is not None:
|
|
166
|
+
try:
|
|
167
|
+
os.close(lock_fd)
|
|
168
|
+
except OSError:
|
|
169
|
+
pass
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
class DataRootStore:
|
|
173
|
+
"""Directory + lock + atomic-write API over one resolved data root."""
|
|
174
|
+
|
|
175
|
+
def __init__(self, resolved: ResolvedDataRoot) -> None:
|
|
176
|
+
self._resolved = resolved
|
|
177
|
+
|
|
178
|
+
# -- paths ------------------------------------------------------------
|
|
179
|
+
|
|
180
|
+
def _scope_base(self, scope: str) -> Path:
|
|
181
|
+
if scope == SCOPE_SHARED:
|
|
182
|
+
return self._resolved.root
|
|
183
|
+
if scope == SCOPE_WORKSPACE:
|
|
184
|
+
return self._resolved.workspace_dir
|
|
185
|
+
raise ValueError(f"unknown scope: {scope!r}")
|
|
186
|
+
|
|
187
|
+
def path_for(self, scope: str, rel: str) -> Path:
|
|
188
|
+
"""Validated absolute path: *rel* is a forward-slash relative path
|
|
189
|
+
(no ``..``/absolute/backslash — ``domain.artifacts`` validator)."""
|
|
190
|
+
normalized = validate_relative_path(rel)
|
|
191
|
+
base = self._scope_base(scope)
|
|
192
|
+
target = base / Path(*normalized.split("/"))
|
|
193
|
+
if not (target == base or base in target.parents):
|
|
194
|
+
raise ValueError(f"path escapes the {scope} tree: {rel!r}")
|
|
195
|
+
return target
|
|
196
|
+
|
|
197
|
+
def locks_dir(self) -> Path:
|
|
198
|
+
return self._resolved.root.joinpath(*LOCKS_SUBDIR.split("/"))
|
|
199
|
+
|
|
200
|
+
def lock_path_for(self, name: str, *, scope: str = SCOPE_SHARED) -> Path:
|
|
201
|
+
"""Lock file under ``state/locks/``; workspace-scoped locks get the
|
|
202
|
+
workspace-hash prefix so both live in the contract's locks dir."""
|
|
203
|
+
normalized = validate_relative_path(name)
|
|
204
|
+
if scope == SCOPE_WORKSPACE:
|
|
205
|
+
hash_dir = self._resolved.workspace_hash.replace(":", "-", 1)
|
|
206
|
+
normalized = f"{hash_dir}-{normalized}"
|
|
207
|
+
elif scope != SCOPE_SHARED:
|
|
208
|
+
raise ValueError(f"unknown scope: {scope!r}")
|
|
209
|
+
return self.locks_dir() / (normalized + ".lock")
|
|
210
|
+
|
|
211
|
+
# -- lifecycle ----------------------------------------------------------
|
|
212
|
+
|
|
213
|
+
def prepare(self) -> None:
|
|
214
|
+
"""Idempotently create the contract skeleton (shared + workspace
|
|
215
|
+
subtrees + ``state/locks``); the resolver stays read-only, this is
|
|
216
|
+
the lifecycle 'prepare' step."""
|
|
217
|
+
targets = list(self._resolved.shared_dirs.values())
|
|
218
|
+
targets.append(self._resolved.workspace_dir)
|
|
219
|
+
targets.extend(self._resolved.workspace_dirs.values())
|
|
220
|
+
targets.append(self.locks_dir())
|
|
221
|
+
for target in targets:
|
|
222
|
+
_check_reparse_chain(self._resolved.root, target)
|
|
223
|
+
target.mkdir(parents=True, exist_ok=True)
|
|
224
|
+
|
|
225
|
+
# -- locks ---------------------------------------------------------------
|
|
226
|
+
|
|
227
|
+
@contextmanager
|
|
228
|
+
def lock(
|
|
229
|
+
self,
|
|
230
|
+
name: str,
|
|
231
|
+
timeout: float = 10.0,
|
|
232
|
+
*,
|
|
233
|
+
scope: str = SCOPE_SHARED,
|
|
234
|
+
error_code: str = ERR_LOCK_TIMEOUT,
|
|
235
|
+
) -> Iterator[None]:
|
|
236
|
+
with file_lock(self.lock_path_for(name, scope=scope), timeout, error_code=error_code):
|
|
237
|
+
yield
|
|
238
|
+
|
|
239
|
+
# -- atomic writes ---------------------------------------------------------
|
|
240
|
+
|
|
241
|
+
def write_text(self, scope: str, rel: str, text: str, *, lock: Optional[str] = None) -> None:
|
|
242
|
+
def _do_write() -> None:
|
|
243
|
+
path = self.path_for(scope, rel)
|
|
244
|
+
_check_reparse_chain(self._resolved.root, path.parent)
|
|
245
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
246
|
+
fd, tmp = tempfile.mkstemp(prefix=f".{path.name}_", dir=str(path.parent), text=True)
|
|
247
|
+
try:
|
|
248
|
+
with os.fdopen(fd, "w", encoding="utf-8", newline="") as f:
|
|
249
|
+
f.write(text)
|
|
250
|
+
f.flush()
|
|
251
|
+
os.fsync(f.fileno())
|
|
252
|
+
os.replace(tmp, str(path))
|
|
253
|
+
except Exception:
|
|
254
|
+
try:
|
|
255
|
+
os.unlink(tmp)
|
|
256
|
+
except OSError:
|
|
257
|
+
pass
|
|
258
|
+
raise
|
|
259
|
+
|
|
260
|
+
if lock is not None:
|
|
261
|
+
with self.lock(lock):
|
|
262
|
+
_do_write()
|
|
263
|
+
else:
|
|
264
|
+
_do_write()
|
|
265
|
+
|
|
266
|
+
def write_json(
|
|
267
|
+
self, scope: str, rel: str, data: Any, *, lock: Optional[str] = None
|
|
268
|
+
) -> None:
|
|
269
|
+
payload = json.dumps(data, indent=2, ensure_ascii=False) + "\n"
|
|
270
|
+
self.write_text(scope, rel, payload, lock=lock)
|
|
271
|
+
|
|
272
|
+
# -- reads (missing → None; corrupt → isolate, never truncate) -------------
|
|
273
|
+
|
|
274
|
+
def read_text(self, scope: str, rel: str) -> Optional[str]:
|
|
275
|
+
path = self.path_for(scope, rel)
|
|
276
|
+
try:
|
|
277
|
+
return path.read_text(encoding="utf-8")
|
|
278
|
+
except FileNotFoundError:
|
|
279
|
+
return None
|
|
280
|
+
|
|
281
|
+
def read_json(self, scope: str, rel: str) -> Optional[Any]:
|
|
282
|
+
path = self.path_for(scope, rel)
|
|
283
|
+
text = self.read_text(scope, rel)
|
|
284
|
+
if text is None:
|
|
285
|
+
return None
|
|
286
|
+
try:
|
|
287
|
+
return json.loads(text)
|
|
288
|
+
except json.JSONDecodeError:
|
|
289
|
+
# Isolate (rename preserves bytes, nothing truncated) and fail
|
|
290
|
+
# closed: the caller sees "absent", never a guessed value.
|
|
291
|
+
corrupt = path.with_name(path.name + ".corrupt")
|
|
292
|
+
try:
|
|
293
|
+
os.replace(path, corrupt)
|
|
294
|
+
except OSError:
|
|
295
|
+
pass
|
|
296
|
+
return None
|