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,249 @@
|
|
|
1
|
+
"""Windows config reader — ctypes kernel32, injectable backend."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import ctypes as _ct
|
|
6
|
+
import ctypes.wintypes as _wt
|
|
7
|
+
import os as _os
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from aisc.adapters.config_reader import MAX_FILE_BYTES, ReadError
|
|
11
|
+
|
|
12
|
+
# ---------------------------------------------------------------------------
|
|
13
|
+
# Constants
|
|
14
|
+
# ---------------------------------------------------------------------------
|
|
15
|
+
GENERIC_READ = 0x80000000
|
|
16
|
+
FILE_READ_ATTRIBUTES = 0x0080
|
|
17
|
+
SHARE_RWD = 7
|
|
18
|
+
OPEN_EXISTING = 3
|
|
19
|
+
FILE_FLAG_OPEN_REPARSE_POINT = 0x00200000
|
|
20
|
+
FILE_FLAG_BACKUP_SEMANTICS = 0x02000000
|
|
21
|
+
FILE_ATTRIBUTE_REPARSE_POINT = 0x0400
|
|
22
|
+
FILE_ATTRIBUTE_DIRECTORY = 0x0010
|
|
23
|
+
FILE_TYPE_UNKNOWN = 0x0000
|
|
24
|
+
FILE_TYPE_DISK = 0x0001
|
|
25
|
+
FILE_TYPE_CHAR = 0x0002
|
|
26
|
+
FILE_TYPE_PIPE = 0x0003
|
|
27
|
+
|
|
28
|
+
ERROR_FILE_NOT_FOUND = 2
|
|
29
|
+
ERROR_PATH_NOT_FOUND = 3
|
|
30
|
+
ERROR_ACCESS_DENIED = 5
|
|
31
|
+
ERROR_SHARING_VIOLATION = 32
|
|
32
|
+
|
|
33
|
+
# ---------------------------------------------------------------------------
|
|
34
|
+
# Custom ABI — _FILETIME + _BY_HANDLE_FILE_INFO
|
|
35
|
+
# ---------------------------------------------------------------------------
|
|
36
|
+
|
|
37
|
+
class _FILETIME(_ct.Structure):
|
|
38
|
+
_fields_ = [("dwLowDateTime", _ct.c_uint32),
|
|
39
|
+
("dwHighDateTime", _ct.c_uint32)]
|
|
40
|
+
|
|
41
|
+
class _BY_HANDLE_FILE_INFO(_ct.Structure):
|
|
42
|
+
_fields_ = [
|
|
43
|
+
("dwFileAttributes", _ct.c_uint32), # offset 0
|
|
44
|
+
("ftCreationTime", _FILETIME), # offset 4
|
|
45
|
+
("ftLastAccessTime", _FILETIME), # offset 12
|
|
46
|
+
("ftLastWriteTime", _FILETIME), # offset 20
|
|
47
|
+
("dwVolumeSerialNumber", _ct.c_uint32), # offset 28
|
|
48
|
+
("nFileSizeHigh", _ct.c_uint32), # offset 32
|
|
49
|
+
("nFileSizeLow", _ct.c_uint32), # offset 36
|
|
50
|
+
("nNumberOfLinks", _ct.c_uint32), # offset 40
|
|
51
|
+
("nFileIndexHigh", _ct.c_uint32), # offset 44
|
|
52
|
+
("nFileIndexLow", _ct.c_uint32), # offset 48
|
|
53
|
+
] # total sizeof = 52
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
# ---------------------------------------------------------------------------
|
|
57
|
+
# Unified error mapping
|
|
58
|
+
# ---------------------------------------------------------------------------
|
|
59
|
+
|
|
60
|
+
def _raise_win_error(code: int, operation: str):
|
|
61
|
+
if code in (ERROR_FILE_NOT_FOUND, ERROR_PATH_NOT_FOUND):
|
|
62
|
+
raise FileNotFoundError(f"{operation} failed")
|
|
63
|
+
if code in (ERROR_ACCESS_DENIED, ERROR_SHARING_VIOLATION):
|
|
64
|
+
raise PermissionError(f"{operation} failed")
|
|
65
|
+
raise OSError(f"{operation} failed")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
# ---------------------------------------------------------------------------
|
|
69
|
+
# Production backend
|
|
70
|
+
# ---------------------------------------------------------------------------
|
|
71
|
+
|
|
72
|
+
class _RealBackend:
|
|
73
|
+
def __init__(self):
|
|
74
|
+
k = _ct.WinDLL("kernel32", use_last_error=True)
|
|
75
|
+
k.CreateFileW.restype = _wt.HANDLE
|
|
76
|
+
k.CreateFileW.argtypes = [_wt.LPCWSTR, _wt.DWORD, _wt.DWORD,
|
|
77
|
+
_wt.LPVOID, _wt.DWORD, _wt.DWORD, _wt.HANDLE]
|
|
78
|
+
k.CloseHandle.restype = _wt.BOOL
|
|
79
|
+
k.CloseHandle.argtypes = [_wt.HANDLE]
|
|
80
|
+
k.ReadFile.restype = _wt.BOOL
|
|
81
|
+
k.ReadFile.argtypes = [_wt.HANDLE, _wt.LPVOID, _wt.DWORD,
|
|
82
|
+
_ct.POINTER(_wt.DWORD), _wt.LPVOID]
|
|
83
|
+
k.GetFileType.restype = _wt.DWORD
|
|
84
|
+
k.GetFileType.argtypes = [_wt.HANDLE]
|
|
85
|
+
k.GetFileInformationByHandle.restype = _wt.BOOL
|
|
86
|
+
k.GetFileInformationByHandle.argtypes = [_wt.HANDLE, _ct.POINTER(_BY_HANDLE_FILE_INFO)]
|
|
87
|
+
self._k = k
|
|
88
|
+
self._invalid = _ct.c_void_p(-1).value
|
|
89
|
+
|
|
90
|
+
def CreateFileW(self, path: str, access: int, share: int,
|
|
91
|
+
security: int, creation: int, flags: int, template: int) -> int:
|
|
92
|
+
return self._k.CreateFileW(path, access, share, None, creation, flags, None)
|
|
93
|
+
|
|
94
|
+
def CloseHandle(self, handle: int) -> bool:
|
|
95
|
+
return bool(self._k.CloseHandle(handle))
|
|
96
|
+
|
|
97
|
+
def ReadFile(self, handle: int, size: int) -> bytes:
|
|
98
|
+
buf = _ct.create_string_buffer(size)
|
|
99
|
+
nread = _wt.DWORD(0)
|
|
100
|
+
if not self._k.ReadFile(handle, buf, size, _ct.byref(nread), None):
|
|
101
|
+
_raise_win_error(_ct.get_last_error(), "ReadFile")
|
|
102
|
+
return buf.raw[:nread.value]
|
|
103
|
+
|
|
104
|
+
def GetFileType(self, handle: int) -> int:
|
|
105
|
+
return self._k.GetFileType(handle)
|
|
106
|
+
|
|
107
|
+
def GetFileAttributes(self, handle: int) -> int:
|
|
108
|
+
info = _BY_HANDLE_FILE_INFO()
|
|
109
|
+
if not self._k.GetFileInformationByHandle(handle, _ct.byref(info)):
|
|
110
|
+
_raise_win_error(_ct.get_last_error(), "GetFileInformationByHandle")
|
|
111
|
+
return info.dwFileAttributes
|
|
112
|
+
|
|
113
|
+
def GetLastError(self) -> int:
|
|
114
|
+
return _ct.get_last_error()
|
|
115
|
+
|
|
116
|
+
@property
|
|
117
|
+
def INVALID_HANDLE_VALUE(self) -> int:
|
|
118
|
+
return self._invalid
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
# ---------------------------------------------------------------------------
|
|
122
|
+
# Injectable backend
|
|
123
|
+
# ---------------------------------------------------------------------------
|
|
124
|
+
|
|
125
|
+
_backend = None
|
|
126
|
+
|
|
127
|
+
def _get_backend():
|
|
128
|
+
global _backend
|
|
129
|
+
if _backend is None:
|
|
130
|
+
_backend = _RealBackend()
|
|
131
|
+
return _backend
|
|
132
|
+
|
|
133
|
+
def set_backend(b) -> None:
|
|
134
|
+
global _backend
|
|
135
|
+
_backend = b
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
# ---------------------------------------------------------------------------
|
|
139
|
+
# Helpers
|
|
140
|
+
# ---------------------------------------------------------------------------
|
|
141
|
+
|
|
142
|
+
def _is_invalid(h: int, back) -> bool:
|
|
143
|
+
return h == back.INVALID_HANDLE_VALUE
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _win_open_component(path: str, back, access: int, share: int, flags: int) -> None:
|
|
147
|
+
h = back.CreateFileW(path, access, share, 0, OPEN_EXISTING, flags, 0)
|
|
148
|
+
if _is_invalid(h, back):
|
|
149
|
+
_raise_win_error(back.GetLastError(), "CreateFileW")
|
|
150
|
+
primary = None
|
|
151
|
+
try:
|
|
152
|
+
attrs = back.GetFileAttributes(h)
|
|
153
|
+
if attrs & FILE_ATTRIBUTE_REPARSE_POINT:
|
|
154
|
+
raise ReadError("structural_error", "Config component is a reparse point")
|
|
155
|
+
if not (attrs & FILE_ATTRIBUTE_DIRECTORY):
|
|
156
|
+
raise ReadError("structural_error", "Config component is not a directory")
|
|
157
|
+
except Exception as exc:
|
|
158
|
+
primary = exc
|
|
159
|
+
raise
|
|
160
|
+
finally:
|
|
161
|
+
ok = back.CloseHandle(h)
|
|
162
|
+
if not ok and primary is None:
|
|
163
|
+
_raise_win_error(back.GetLastError(), "CloseHandle")
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _win_open_final(path: str, back) -> int:
|
|
167
|
+
h = back.CreateFileW(path, GENERIC_READ | FILE_READ_ATTRIBUTES, SHARE_RWD,
|
|
168
|
+
0, OPEN_EXISTING, FILE_FLAG_OPEN_REPARSE_POINT, 0)
|
|
169
|
+
if _is_invalid(h, back):
|
|
170
|
+
_raise_win_error(back.GetLastError(), "CreateFileW")
|
|
171
|
+
return h
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _split_parents(fp: str) -> list:
|
|
175
|
+
fp = fp.replace("/", "\\")
|
|
176
|
+
for comp in fp.split("\\"):
|
|
177
|
+
if comp in (".", ".."):
|
|
178
|
+
raise ReadError("structural_error", "Path contains . or .. component")
|
|
179
|
+
if fp.startswith("\\\\"):
|
|
180
|
+
parts = fp.split("\\")
|
|
181
|
+
if len(parts) < 5:
|
|
182
|
+
raise ReadError("structural_error", "Invalid UNC path")
|
|
183
|
+
root = f"\\\\{parts[2]}\\{parts[3]}"
|
|
184
|
+
rest = parts[4:]
|
|
185
|
+
elif len(fp) >= 2 and fp[1] == ":":
|
|
186
|
+
if len(fp) == 2 or (len(fp) > 2 and fp[2] != "\\"):
|
|
187
|
+
raise ReadError("structural_error", "Drive-relative path not allowed")
|
|
188
|
+
root = fp[:2] + "\\"
|
|
189
|
+
rest = fp[3:].split("\\") if len(fp) > 3 else []
|
|
190
|
+
else:
|
|
191
|
+
raise ReadError("structural_error", "Unsupported path format")
|
|
192
|
+
if not rest:
|
|
193
|
+
raise ReadError("structural_error", "Path has no file component")
|
|
194
|
+
result = [root]
|
|
195
|
+
accum = root
|
|
196
|
+
for comp in rest:
|
|
197
|
+
if not comp:
|
|
198
|
+
continue
|
|
199
|
+
accum = (accum + comp) if accum.endswith("\\") else (accum + "\\" + comp)
|
|
200
|
+
result.append(accum)
|
|
201
|
+
if result:
|
|
202
|
+
result.pop()
|
|
203
|
+
return result
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _win_safe_read(file_path: Path) -> bytes:
|
|
207
|
+
back = _get_backend()
|
|
208
|
+
fp = str(file_path)
|
|
209
|
+
if not _os.path.isabs(fp) and not (len(fp) >= 2 and fp[1] == ":"):
|
|
210
|
+
raise ReadError("structural_error", "Path must be absolute")
|
|
211
|
+
|
|
212
|
+
parents = _split_parents(fp)
|
|
213
|
+
for parent in parents:
|
|
214
|
+
_win_open_component(parent, back, FILE_READ_ATTRIBUTES, SHARE_RWD,
|
|
215
|
+
FILE_FLAG_OPEN_REPARSE_POINT | FILE_FLAG_BACKUP_SEMANTICS)
|
|
216
|
+
|
|
217
|
+
h = _win_open_final(fp, back)
|
|
218
|
+
primary = None
|
|
219
|
+
try:
|
|
220
|
+
attrs = back.GetFileAttributes(h)
|
|
221
|
+
if attrs & FILE_ATTRIBUTE_REPARSE_POINT:
|
|
222
|
+
raise ReadError("structural_error", "Config file is a reparse point")
|
|
223
|
+
if attrs & FILE_ATTRIBUTE_DIRECTORY:
|
|
224
|
+
raise ReadError("structural_error", "Config file is a directory")
|
|
225
|
+
ftype = back.GetFileType(h)
|
|
226
|
+
if ftype == FILE_TYPE_UNKNOWN:
|
|
227
|
+
if back.GetLastError() != 0:
|
|
228
|
+
_raise_win_error(back.GetLastError(), "GetFileType")
|
|
229
|
+
raise ReadError("structural_error", "Config file is not a disk file")
|
|
230
|
+
if ftype != FILE_TYPE_DISK:
|
|
231
|
+
raise ReadError("structural_error", "Config file is not a disk file")
|
|
232
|
+
chunks = []
|
|
233
|
+
total = 0
|
|
234
|
+
while total <= MAX_FILE_BYTES:
|
|
235
|
+
chunk = back.ReadFile(h, 4096)
|
|
236
|
+
if not chunk:
|
|
237
|
+
break
|
|
238
|
+
chunks.append(chunk)
|
|
239
|
+
total += len(chunk)
|
|
240
|
+
if total > MAX_FILE_BYTES:
|
|
241
|
+
raise ReadError("structural_error", "Config file exceeds size limit")
|
|
242
|
+
return b"".join(chunks)
|
|
243
|
+
except Exception as exc:
|
|
244
|
+
primary = exc
|
|
245
|
+
raise
|
|
246
|
+
finally:
|
|
247
|
+
ok = back.CloseHandle(h)
|
|
248
|
+
if not ok and primary is None:
|
|
249
|
+
_raise_win_error(back.GetLastError(), "CloseHandle")
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
"""Workspace lease storage (runtime-lifecycle-ux Stage 1, 02 §2.3).
|
|
2
|
+
|
|
3
|
+
The lease file rides the frozen DataRootStore layout:
|
|
4
|
+
|
|
5
|
+
- record: ``<data-root>/workspaces/<hash>/runtime-lease.json``
|
|
6
|
+
(workspace scope, atomic temp+replace via ``DataRootStore.write_json``);
|
|
7
|
+
- cross-process lock: ``state/locks/<hash>-runtime-lease.lock``
|
|
8
|
+
(``DataRootStore.lock`` — msvcrt/fcntl, fail-closed timeout);
|
|
9
|
+
- release deletes the record — an absent file IS "no lease".
|
|
10
|
+
|
|
11
|
+
No secrets: ids and timestamps only. Claim/heartbeat/release/inspect are
|
|
12
|
+
the whole API; the Rust heartbeat task (D-RUNTIME-12) calls ``heartbeat``
|
|
13
|
+
through the CLI on its interval — never via frontend timers.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import uuid
|
|
19
|
+
from typing import Optional, Tuple
|
|
20
|
+
|
|
21
|
+
from aisc.adapters.data_root_store import DataRootStore
|
|
22
|
+
from aisc.domain.models import CliError, RuntimeErrorCode, RuntimeExitCode
|
|
23
|
+
from aisc.domain.workspace_lease import (
|
|
24
|
+
WorkspaceLease,
|
|
25
|
+
lease_from_dict,
|
|
26
|
+
now_iso,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
#: Lease record path (workspace scope) and its lock name.
|
|
30
|
+
LEASE_REL = "runtime-lease.json"
|
|
31
|
+
LEASE_LOCK_NAME = "runtime-lease"
|
|
32
|
+
|
|
33
|
+
#: Lock acquisition timeout — lease ops are tiny; contention means another
|
|
34
|
+
#: process is mid-claim, which resolves in milliseconds.
|
|
35
|
+
LEASE_LOCK_TIMEOUT = 10.0
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class WorkspaceLeaseStore:
|
|
39
|
+
"""Claim/heartbeat/release/inspect over one workspace's lease file."""
|
|
40
|
+
|
|
41
|
+
def __init__(self, store: DataRootStore, workspace_key: str) -> None:
|
|
42
|
+
self._store = store
|
|
43
|
+
self._workspace_key = workspace_key
|
|
44
|
+
|
|
45
|
+
# -- read -------------------------------------------------------------
|
|
46
|
+
|
|
47
|
+
def inspect(self) -> Optional[WorkspaceLease]:
|
|
48
|
+
"""Current lease, or ``None`` when absent/corrupt (read-only).
|
|
49
|
+
|
|
50
|
+
Corrupt JSON is isolated by ``DataRootStore.read_json`` (renamed
|
|
51
|
+
``.corrupt``) and reads as absent — the next claim overwrites.
|
|
52
|
+
"""
|
|
53
|
+
data = self._store.read_json("workspace", LEASE_REL)
|
|
54
|
+
return lease_from_dict(data)
|
|
55
|
+
|
|
56
|
+
# -- claim ------------------------------------------------------------
|
|
57
|
+
|
|
58
|
+
def claim(
|
|
59
|
+
self,
|
|
60
|
+
instance_id: str,
|
|
61
|
+
*,
|
|
62
|
+
lease_id: Optional[str] = None,
|
|
63
|
+
now: Optional[str] = None,
|
|
64
|
+
) -> Tuple[WorkspaceLease, str]:
|
|
65
|
+
"""Claim the workspace lease.
|
|
66
|
+
|
|
67
|
+
Returns ``(lease, outcome)`` where outcome is one of:
|
|
68
|
+
|
|
69
|
+
- ``"claimed"`` — no prior lease; fresh claim written;
|
|
70
|
+
- ``"claimed_stale"`` — prior lease expired/garbage; taken over;
|
|
71
|
+
- ``"reclaimed"`` — fresh lease already held by this
|
|
72
|
+
instance; heartbeat folded in, lease_id
|
|
73
|
+
preserved (idempotent re-materialize).
|
|
74
|
+
|
|
75
|
+
Raises ``CliError(AISC_ERR_ACTIVE_WORKSPACE_LEASE)`` when a fresh
|
|
76
|
+
lease belongs to another instance — the caller must NOT touch that
|
|
77
|
+
workspace's runtime resources.
|
|
78
|
+
"""
|
|
79
|
+
if not instance_id:
|
|
80
|
+
raise CliError(
|
|
81
|
+
"workspace lease claim requires an instance id",
|
|
82
|
+
exit_code=2,
|
|
83
|
+
error_code=RuntimeErrorCode.RUNTIME_LEASE_CONFLICT,
|
|
84
|
+
)
|
|
85
|
+
stamp = now or now_iso()
|
|
86
|
+
with self._store.lock(LEASE_LOCK_NAME, LEASE_LOCK_TIMEOUT, scope="workspace"):
|
|
87
|
+
existing = self.inspect()
|
|
88
|
+
if existing is not None and not existing.is_expired():
|
|
89
|
+
if existing.held_by(instance_id):
|
|
90
|
+
refreshed = WorkspaceLease(
|
|
91
|
+
workspace_key=existing.workspace_key or self._workspace_key,
|
|
92
|
+
lease_id=existing.lease_id,
|
|
93
|
+
workbench_instance_id=instance_id,
|
|
94
|
+
claimed_at=existing.claimed_at,
|
|
95
|
+
lease_last_seen_at=stamp,
|
|
96
|
+
)
|
|
97
|
+
self._write(refreshed)
|
|
98
|
+
return refreshed, "reclaimed"
|
|
99
|
+
raise CliError(
|
|
100
|
+
"workspace is leased by another Workbench instance",
|
|
101
|
+
exit_code=RuntimeExitCode.ACTIVE_WORKSPACE_LEASE,
|
|
102
|
+
error_code=RuntimeErrorCode.ACTIVE_WORKSPACE_LEASE,
|
|
103
|
+
data={
|
|
104
|
+
"workspace_key": self._workspace_key,
|
|
105
|
+
"holder_instance_id": existing.workbench_instance_id,
|
|
106
|
+
"lease_id": existing.lease_id,
|
|
107
|
+
"expires_at": existing.expires_at_iso(),
|
|
108
|
+
},
|
|
109
|
+
)
|
|
110
|
+
# Absent, expired or unparseable → take over.
|
|
111
|
+
lease = WorkspaceLease(
|
|
112
|
+
workspace_key=self._workspace_key,
|
|
113
|
+
lease_id=lease_id or str(uuid.uuid4()),
|
|
114
|
+
workbench_instance_id=instance_id,
|
|
115
|
+
claimed_at=stamp,
|
|
116
|
+
lease_last_seen_at=stamp,
|
|
117
|
+
)
|
|
118
|
+
self._write(lease)
|
|
119
|
+
return lease, ("claimed_stale" if existing is not None else "claimed")
|
|
120
|
+
|
|
121
|
+
# -- heartbeat ----------------------------------------------------------
|
|
122
|
+
|
|
123
|
+
def heartbeat(
|
|
124
|
+
self,
|
|
125
|
+
instance_id: str,
|
|
126
|
+
lease_id: str,
|
|
127
|
+
*,
|
|
128
|
+
now: Optional[str] = None,
|
|
129
|
+
) -> Optional[WorkspaceLease]:
|
|
130
|
+
"""Refresh ``lease_last_seen_at`` for the matching lease.
|
|
131
|
+
|
|
132
|
+
Returns the refreshed lease, or ``None`` when the record is absent
|
|
133
|
+
(released/never claimed — the caller re-claims via :meth:`claim`).
|
|
134
|
+
Raises ``AISC_ERR_RUNTIME_LEASE_CONFLICT`` when a DIFFERENT instance
|
|
135
|
+
or lease now owns the file (taken over after expiry) — the caller
|
|
136
|
+
must stop heartbeating and re-run reconcile.
|
|
137
|
+
"""
|
|
138
|
+
stamp = now or now_iso()
|
|
139
|
+
with self._store.lock(LEASE_LOCK_NAME, LEASE_LOCK_TIMEOUT, scope="workspace"):
|
|
140
|
+
existing = self.inspect()
|
|
141
|
+
if existing is None:
|
|
142
|
+
return None
|
|
143
|
+
if existing.workbench_instance_id != instance_id or (
|
|
144
|
+
lease_id and existing.lease_id != lease_id
|
|
145
|
+
):
|
|
146
|
+
raise CliError(
|
|
147
|
+
"workspace lease now belongs to another instance",
|
|
148
|
+
exit_code=RuntimeExitCode.ACTIVE_WORKSPACE_LEASE,
|
|
149
|
+
error_code=RuntimeErrorCode.RUNTIME_LEASE_CONFLICT,
|
|
150
|
+
data={
|
|
151
|
+
"workspace_key": self._workspace_key,
|
|
152
|
+
"holder_instance_id": existing.workbench_instance_id,
|
|
153
|
+
"lease_id": existing.lease_id,
|
|
154
|
+
},
|
|
155
|
+
)
|
|
156
|
+
refreshed = WorkspaceLease(
|
|
157
|
+
workspace_key=existing.workspace_key,
|
|
158
|
+
lease_id=existing.lease_id,
|
|
159
|
+
workbench_instance_id=existing.workbench_instance_id,
|
|
160
|
+
claimed_at=existing.claimed_at,
|
|
161
|
+
lease_last_seen_at=stamp,
|
|
162
|
+
)
|
|
163
|
+
self._write(refreshed)
|
|
164
|
+
return refreshed
|
|
165
|
+
|
|
166
|
+
# -- release ------------------------------------------------------------
|
|
167
|
+
|
|
168
|
+
def release(self, instance_id: str, lease_id: str) -> bool:
|
|
169
|
+
"""Release the lease — only when both ids match (never another
|
|
170
|
+
instance's). Returns whether a matching lease was removed."""
|
|
171
|
+
with self._store.lock(LEASE_LOCK_NAME, LEASE_LOCK_TIMEOUT, scope="workspace"):
|
|
172
|
+
existing = self.inspect()
|
|
173
|
+
if existing is None:
|
|
174
|
+
return False
|
|
175
|
+
if existing.workbench_instance_id != instance_id or existing.lease_id != lease_id:
|
|
176
|
+
return False
|
|
177
|
+
path = self._store.path_for("workspace", LEASE_REL)
|
|
178
|
+
try:
|
|
179
|
+
path.unlink()
|
|
180
|
+
except FileNotFoundError:
|
|
181
|
+
return False
|
|
182
|
+
return True
|
|
183
|
+
|
|
184
|
+
# -- internals ----------------------------------------------------------
|
|
185
|
+
|
|
186
|
+
def _write(self, lease: WorkspaceLease) -> None:
|
|
187
|
+
self._store.write_json(
|
|
188
|
+
"workspace", LEASE_REL, lease.to_dict(), lock=None,
|
|
189
|
+
)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Application layer — business logic, no direct I/O."""
|