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.
Files changed (92) hide show
  1. aisc/VERSION +1 -0
  2. aisc/__init__.py +49 -0
  3. aisc/__main__.py +6 -0
  4. aisc/adapters/__init__.py +1 -0
  5. aisc/adapters/config_reader.py +215 -0
  6. aisc/adapters/container_registry.py +718 -0
  7. aisc/adapters/data_root_store.py +296 -0
  8. aisc/adapters/docker_.py +1183 -0
  9. aisc/adapters/docker_gateway.py +1244 -0
  10. aisc/adapters/docker_sdk_backed.py +274 -0
  11. aisc/adapters/maintenance_lock.py +97 -0
  12. aisc/adapters/state_file.py +184 -0
  13. aisc/adapters/system.py +101 -0
  14. aisc/adapters/windows_config_reader.py +249 -0
  15. aisc/adapters/workspace_lease_store.py +189 -0
  16. aisc/application/__init__.py +1 -0
  17. aisc/application/artifact.py +265 -0
  18. aisc/application/bundle_fetch.py +836 -0
  19. aisc/application/cc_switch_provider.py +245 -0
  20. aisc/application/cc_switch_resolver.py +363 -0
  21. aisc/application/config_service.py +452 -0
  22. aisc/application/conversation.py +516 -0
  23. aisc/application/data_migration.py +440 -0
  24. aisc/application/data_root.py +275 -0
  25. aisc/application/docker_lifecycle.py +659 -0
  26. aisc/application/doctor.py +821 -0
  27. aisc/application/legacy_scan.py +252 -0
  28. aisc/application/network_subscription.py +642 -0
  29. aisc/application/profile_service.py +105 -0
  30. aisc/application/provider.py +92 -0
  31. aisc/application/repair.py +387 -0
  32. aisc/application/resources.py +270 -0
  33. aisc/application/runtime.py +1826 -0
  34. aisc/application/session.py +320 -0
  35. aisc/application/tool_versions.py +210 -0
  36. aisc/application/toolchain.py +91 -0
  37. aisc/application/update.py +286 -0
  38. aisc/application/usage.py +305 -0
  39. aisc/application/version.py +133 -0
  40. aisc/application/web_gateway.py +459 -0
  41. aisc/application/workspace_reconcile.py +318 -0
  42. aisc/applog.py +103 -0
  43. aisc/cli/__init__.py +1 -0
  44. aisc/cli/build_progress_parser.py +217 -0
  45. aisc/cli/commands/__init__.py +1 -0
  46. aisc/cli/commands/agents.py +117 -0
  47. aisc/cli/commands/artifact.py +210 -0
  48. aisc/cli/commands/build.py +566 -0
  49. aisc/cli/commands/bundle.py +107 -0
  50. aisc/cli/commands/cc_switch.py +158 -0
  51. aisc/cli/commands/config.py +104 -0
  52. aisc/cli/commands/container.py +787 -0
  53. aisc/cli/commands/conversation.py +72 -0
  54. aisc/cli/commands/data_root.py +84 -0
  55. aisc/cli/commands/logs.py +89 -0
  56. aisc/cli/commands/network.py +132 -0
  57. aisc/cli/commands/profile.py +52 -0
  58. aisc/cli/commands/provider.py +49 -0
  59. aisc/cli/commands/run.py +494 -0
  60. aisc/cli/commands/runs.py +125 -0
  61. aisc/cli/commands/runtime.py +450 -0
  62. aisc/cli/commands/serve.py +550 -0
  63. aisc/cli/commands/serve_fs.py +369 -0
  64. aisc/cli/commands/session.py +167 -0
  65. aisc/cli/commands/update.py +145 -0
  66. aisc/cli/commands/usage.py +74 -0
  67. aisc/cli/commands/wizard.py +130 -0
  68. aisc/cli/commands/workspaces.py +162 -0
  69. aisc/cli/help_i18n.py +385 -0
  70. aisc/cli/main.py +2926 -0
  71. aisc/cli/output.py +239 -0
  72. aisc/cli/tutorial.py +109 -0
  73. aisc/domain/__init__.py +1 -0
  74. aisc/domain/artifacts.py +279 -0
  75. aisc/domain/cc_switch_release.py +303 -0
  76. aisc/domain/config.py +64 -0
  77. aisc/domain/data_migration.py +259 -0
  78. aisc/domain/data_root.py +124 -0
  79. aisc/domain/docker_ownership.py +148 -0
  80. aisc/domain/gateway.py +168 -0
  81. aisc/domain/models.py +674 -0
  82. aisc/domain/toolchain.py +72 -0
  83. aisc/domain/web_services.py +355 -0
  84. aisc/domain/workspace_lease.py +139 -0
  85. aisc/schemas/__init__.py +1 -0
  86. aisc/schemas/config_schema.py +145 -0
  87. aisc_cli-0.1.0.dist-info/METADATA +420 -0
  88. aisc_cli-0.1.0.dist-info/RECORD +92 -0
  89. aisc_cli-0.1.0.dist-info/WHEEL +5 -0
  90. aisc_cli-0.1.0.dist-info/entry_points.txt +2 -0
  91. aisc_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
  92. 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."""