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,72 @@
1
+ """Persistent project toolchain contract (runtime-lifecycle-ux 02 §8).
2
+
3
+ Scope-derived policy (D-RUNTIME-09):
4
+ - scope=project -> dependency_policy=persistent_toolchain
5
+ -> a host-side toolchain dir mounts at
6
+ ``/opt/aisc/toolchain`` (host_bind backend — the
7
+ 2026-08-26 Windows spike passed the frozen decision
8
+ gate, see spike-toolchain-windows.md);
9
+ - scope=temporary -> dependency_policy=ephemeral_toolchain
10
+ -> no mount; the entrypoint creates the same layout at
11
+ ``/tmp/aisc-toolchain`` (container-only).
12
+
13
+ Both modes inject identical user-level package-manager paths so commands
14
+ behave the same regardless of scope:
15
+ - npm: ``NPM_CONFIG_PREFIX=<tc>/npm-global`` (``npm i -g`` persists)
16
+ - pip: ``PYTHONUSERBASE=<tc>/python`` (``--user`` installs persist;
17
+ PIP_USER is deliberately NOT forced — it breaks venv installs, and
18
+ the plan's §7.2 already scopes system pip to container-only)
19
+ - cargo: ``CARGO_HOME=<tc>/cargo`` (``cargo install`` persists)
20
+
21
+ v1 carries a lightweight ``environment.json`` baseline marker (OS/arch/
22
+ glibc/Node/Python/image id) — NOT an install manifest; a mismatch is a
23
+ warning, never a block or auto-delete (02 §8.3, D-RUNTIME-11).
24
+
25
+ Pure constants/helpers only; filesystem work lives in
26
+ application/toolchain.py.
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ from typing import List
32
+
33
+ #: In-container mount target for the persistent toolchain (both backends).
34
+ TOOLCHAIN_MOUNT_TARGET = "/opt/aisc/toolchain"
35
+
36
+ #: Temporary-mode equivalent layout (container-only, dies with the runtime).
37
+ TOOLCHAIN_TMP_ROOT = "/tmp/aisc-toolchain"
38
+
39
+ #: Subdirs under the toolchain root (contract layout, 02 §8.2).
40
+ TOOLCHAIN_SUBDIRS = ("bin", "npm-global", "python", "cargo", "cache")
41
+
42
+ #: Lightweight environment baseline marker (never an install manifest).
43
+ ENVIRONMENT_MARKER = "environment.json"
44
+ ENVIRONMENT_MARKER_SCHEMA = "aisc.toolchain-environment/v1"
45
+ ENVIRONMENT_MARKER_VERSION = 1
46
+
47
+ #: Mismatch warning file (entrypoint writes when the baseline differs from
48
+ #: the running container; host-side inspect surfaces it as a warning).
49
+ TOOLCHAIN_WARNING_FILE = "toolchain-incompatible.txt"
50
+
51
+ #: Registry metadata: which storage backend this runtime's toolchain uses.
52
+ TOOLCHAIN_STORAGE_HOST_BIND = "host_bind"
53
+ TOOLCHAIN_STORAGE_DOCKER_VOLUME = "docker_volume" # parameterized, not default
54
+
55
+
56
+ def toolchain_bind_argv(host_dir: str) -> List[str]:
57
+ """docker -v argv mounting a host toolchain dir at the contract target."""
58
+ return ["-v", f"{host_dir}:{TOOLCHAIN_MOUNT_TARGET}"]
59
+
60
+
61
+ def base_environment_marker(
62
+ *, source_version: str, image_id: str, written_at: str
63
+ ) -> dict:
64
+ """Host-side marker seed (container facts merge in from the entrypoint)."""
65
+ return {
66
+ "schema": ENVIRONMENT_MARKER_SCHEMA,
67
+ "schema_version": ENVIRONMENT_MARKER_VERSION,
68
+ "os": "linux",
69
+ "source_version": source_version,
70
+ "image_id": image_id,
71
+ "written_at": written_at,
72
+ }
@@ -0,0 +1,355 @@
1
+ """Container web-service access contract (svc-0, 2026-08-25).
2
+
3
+ Single source of truth for the cross-language constants and pure functions
4
+ behind "Agent 启动容器内 Web 服务 → 宿主机浏览器可访问":
5
+
6
+ - docs/plans/container-service-access.md (implementation plan)
7
+ - docs/plans/container-service-access/decisions.md (frozen contract)
8
+ - tests/fixtures/web-services/ (samples decoded identically by
9
+ Python/Rust/TypeScript — the svc-0 stage gate)
10
+
11
+ Nothing here performs I/O; side-effectful pieces (host port allocation,
12
+ Docker argv, docker exec) live in application/adapters layers.
13
+
14
+ Secret-free by construction: ports/labels/state only, never request paths,
15
+ queries, headers or bodies.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import re
21
+ from dataclasses import dataclass, field
22
+ from typing import Any, Dict, List, Optional
23
+
24
+ # ---------------------------------------------------------------------------
25
+ # Frozen constants (mirrored in Rust web_services.rs / TS webServices.ts)
26
+ # ---------------------------------------------------------------------------
27
+
28
+ #: Fixed container-side port the in-container gateway listens on.
29
+ WEB_GATEWAY_CONTAINER_PORT = 45871
30
+
31
+ #: Host loopback port range allocated to runtime gateways (inclusive).
32
+ WEB_GATEWAY_HOST_PORT_MIN = 47000
33
+ WEB_GATEWAY_HOST_PORT_MAX = 47999
34
+
35
+ #: Registrable container service ports (non-privileged TCP only).
36
+ WEB_SERVICE_PORT_MIN = 1024
37
+ WEB_SERVICE_PORT_MAX = 65535
38
+
39
+ #: In-container manifest directory; one ``<port>.json`` per registered service.
40
+ WEB_SERVICES_DIR = "/run/aisc/web-services"
41
+
42
+ #: Schema stamped on every manifest record and every ``runtime services``
43
+ #: payload. Unknown schema versions fail closed at the decode boundary.
44
+ WEB_SERVICE_SCHEMA_V1 = "aisc.web-service/v1"
45
+ RUNTIME_SERVICES_SCHEMA_V1 = "aisc.runtime-services/v1"
46
+
47
+ #: v1 protocol: HTTP/1.1-over-TCP (+ WebSocket upgrade). HTTPS is deferred.
48
+ WEB_SERVICE_PROTOCOL = "http"
49
+
50
+ #: The gateway is host-published on loopback only, never 0.0.0.0.
51
+ WEB_GATEWAY_HOST_BIND = "127.0.0.1"
52
+
53
+ #: URL scheme for user-facing service URLs (frozen for v1).
54
+ WEB_SERVICE_URL_SCHEME = "http"
55
+
56
+ #: Hostname label the gateway routes on: ``p<container-port>.localhost``.
57
+ GATEWAY_HOST_SUFFIX = ".localhost"
58
+
59
+
60
+ class WebErrorCode:
61
+ """Stable identifiers the in-container gateway returns on failures.
62
+
63
+ Paired HTTP statuses (decisions.md §gateway errors):
64
+
65
+ ============ ============================== ===============================
66
+ identifier HTTP meaning
67
+ ============ ============================== ===============================
68
+ BAD_HOST 400 AISC_WEB_BAD_HOST Host is not p<port>.localhost
69
+ PORT_INVALID 400 AISC_WEB_PORT_INVALID port outside 1024..65535
70
+ NOT_EXPOSED 404 AISC_WEB_PORT_NOT_EXPOSED port not registered
71
+ TARGET_DOWN 502 AISC_WEB_TARGET_UNAVAILABLE container target not listening
72
+ REGISTRY 503 AISC_WEB_REGISTRY_UNAVAILABLE manifest dir unreadable
73
+ ============ ============================== ===============================
74
+ """
75
+
76
+ BAD_HOST = "AISC_WEB_BAD_HOST"
77
+ PORT_INVALID = "AISC_WEB_PORT_INVALID"
78
+ PORT_NOT_EXPOSED = "AISC_WEB_PORT_NOT_EXPOSED"
79
+ TARGET_UNAVAILABLE = "AISC_WEB_TARGET_UNAVAILABLE"
80
+ REGISTRY_UNAVAILABLE = "AISC_WEB_REGISTRY_UNAVAILABLE"
81
+
82
+
83
+ #: Why ``web_access.state`` is ``unavailable`` (UI shows the reason verbatim
84
+ #: via i18n; never invent values outside this set).
85
+ WEB_UNAVAILABLE_REASONS = (
86
+ "legacy_runtime", # container created before gateway publish existed
87
+ "runtime_not_running", # container stopped; mapping exists but port closed
88
+ "gateway_unreachable", # mapping exists, gateway did not accept (crashed?)
89
+ "docker_unavailable", # could not inspect the mapping
90
+ "no_mapping", # container has no 45871/tcp publish at all
91
+ )
92
+
93
+
94
+ # ---------------------------------------------------------------------------
95
+ # Pure validation / parsing helpers
96
+ # ---------------------------------------------------------------------------
97
+
98
+ _DECIMAL_RE = re.compile(r"^[0-9]+$")
99
+
100
+
101
+ def is_exposable_port(port: int) -> bool:
102
+ """True when *port* is a registrable TCP port (1024..65535)."""
103
+ return isinstance(port, int) and WEB_SERVICE_PORT_MIN <= port <= WEB_SERVICE_PORT_MAX
104
+
105
+
106
+ def parse_expose_port(text: str) -> int:
107
+ """Parse a service port argument; strict decimal, bounded.
108
+
109
+ Raises ``ValueError`` on anything that is not ``^[0-9]+$`` or falls
110
+ outside 1024..65535 (negative, float, empty, trailing text, privileged).
111
+ """
112
+ if not isinstance(text, str) or not _DECIMAL_RE.match(text):
113
+ raise ValueError(f"port must be a decimal integer: {text!r}")
114
+ port = int(text)
115
+ if not is_exposable_port(port):
116
+ raise ValueError(f"port out of range {WEB_SERVICE_PORT_MIN}..{WEB_SERVICE_PORT_MAX}: {port}")
117
+ return port
118
+
119
+
120
+ def sanitize_service_name(name: str) -> str:
121
+ """Normalize a display-only service label.
122
+
123
+ Rules (decisions.md §helper contract): plain short text, no control
124
+ characters, at most 64 chars after stripping. Empty is allowed (unnamed
125
+ service). The label never enters URLs or routing decisions.
126
+ """
127
+ if name is None:
128
+ return ""
129
+ stripped = str(name).strip()
130
+ if any(ord(ch) < 0x20 or ord(ch) == 0x7F for ch in stripped):
131
+ raise ValueError("service name must not contain control characters")
132
+ if len(stripped) > 64:
133
+ raise ValueError("service name longer than 64 characters")
134
+ return stripped
135
+
136
+
137
+ def build_service_url(container_port: int, host_port: int) -> str:
138
+ """Canonical user-facing URL: ``http://p<container-port>.localhost:<host-port>/``.
139
+
140
+ The only inputs are the two ports; path/query/fragment stay with the
141
+ browser request. Service labels never appear in the URL.
142
+ """
143
+ if not is_exposable_port(container_port):
144
+ raise ValueError(f"container port out of range: {container_port}")
145
+ if not (1 <= int(host_port) <= 65535):
146
+ raise ValueError(f"host port out of range: {host_port}")
147
+ return f"{WEB_SERVICE_URL_SCHEME}://p{container_port}{GATEWAY_HOST_SUFFIX}:{int(host_port)}/"
148
+
149
+
150
+ _HOST_RE = re.compile(
151
+ r"^p([0-9]{1,5})\.localhost\.?(?::([0-9]{1,5}))?$",
152
+ re.IGNORECASE,
153
+ )
154
+
155
+
156
+ def parse_gateway_host(host_value: str) -> Optional[int]:
157
+ """Extract the container service port from a request Host header.
158
+
159
+ Accepts ``p<port>.localhost`` with an optional (ignored) gateway port
160
+ suffix and an optional FQDN trailing dot, case-insensitive — exactly the
161
+ forms Chromium/Edge/Firefox emit for the canonical URL. Returns ``None``
162
+ for anything else (the gateway answers ``AISC_WEB_BAD_HOST``).
163
+
164
+ The port value itself is returned unvalidated; the caller checks
165
+ :func:`is_exposable_port` (→ ``AISC_WEB_PORT_INVALID``) and the manifest
166
+ (→ ``AISC_WEB_PORT_NOT_EXPOSED``) in that order.
167
+ """
168
+ if not isinstance(host_value, str):
169
+ return None
170
+ match = _HOST_RE.match(host_value.strip())
171
+ if match is None:
172
+ return None
173
+ return int(match.group(1))
174
+
175
+
176
+ # ---------------------------------------------------------------------------
177
+ # Data model
178
+ # ---------------------------------------------------------------------------
179
+
180
+ @dataclass
181
+ class WebServiceRecord:
182
+ """One manifest entry at ``/run/aisc/web-services/<port>.json``.
183
+
184
+ Registered by the in-container helper (or host CLI exec'ing it); read
185
+ per-request by the gateway. Atomic-write/permission rules live in the
186
+ helper, not here. ``state`` is only ever ``registered`` in v1 — the
187
+ helper never claims readiness.
188
+ """
189
+
190
+ port: int
191
+ protocol: str = WEB_SERVICE_PROTOCOL
192
+ name: str = ""
193
+ state: str = "registered"
194
+ registered_at: str = ""
195
+ pid: Optional[int] = None
196
+ schema_version: str = field(default=WEB_SERVICE_SCHEMA_V1, repr=False)
197
+
198
+ def __post_init__(self) -> None:
199
+ if not is_exposable_port(self.port):
200
+ raise ValueError(f"service port out of range: {self.port}")
201
+ if self.state != "registered":
202
+ raise ValueError(f"unsupported service state: {self.state!r}")
203
+
204
+ def to_dict(self) -> Dict[str, Any]:
205
+ return {
206
+ "schema_version": self.schema_version,
207
+ "port": self.port,
208
+ "protocol": self.protocol,
209
+ "name": self.name,
210
+ "state": self.state,
211
+ "registered_at": self.registered_at,
212
+ "pid": self.pid,
213
+ }
214
+
215
+ @classmethod
216
+ def from_dict(cls, data: Dict[str, Any]) -> "WebServiceRecord":
217
+ """Decode strictly: unknown schema or bad fields raise ``ValueError``.
218
+
219
+ Fail-closed is the contract — the gateway treats a raise here as
220
+ ``AISC_WEB_REGISTRY_UNAVAILABLE`` and refuses to forward.
221
+ """
222
+ if not isinstance(data, dict):
223
+ raise ValueError("service record must be a JSON object")
224
+ schema = data.get("schema_version")
225
+ if schema != WEB_SERVICE_SCHEMA_V1:
226
+ raise ValueError(f"unsupported schema_version: {schema!r}")
227
+ port = data.get("port")
228
+ if not isinstance(port, int) or isinstance(port, bool):
229
+ raise ValueError(f"port must be an integer: {port!r}")
230
+ pid = data.get("pid")
231
+ if pid is not None and (not isinstance(pid, int) or isinstance(pid, bool)):
232
+ raise ValueError(f"pid must be an integer or null: {pid!r}")
233
+ return cls(
234
+ port=port,
235
+ protocol=str(data.get("protocol") or WEB_SERVICE_PROTOCOL),
236
+ name=str(data.get("name") or ""),
237
+ state=str(data.get("state") or "registered"),
238
+ registered_at=str(data.get("registered_at") or ""),
239
+ pid=pid,
240
+ )
241
+
242
+
243
+ @dataclass
244
+ class WebGatewayInfo:
245
+ """Gateway reachability snapshot (``RuntimeSnapshot.web_access`` and
246
+ ``RuntimeServicesResult.gateway`` share this shape).
247
+
248
+ ``host_port``/``host`` come from ``docker inspect`` (the connectivity
249
+ source of truth), never from guessing. 0/empty when unknown.
250
+ """
251
+
252
+ state: str = "unavailable" # ready | unavailable
253
+ container_port: int = WEB_GATEWAY_CONTAINER_PORT
254
+ host_port: int = 0
255
+ host: str = WEB_GATEWAY_HOST_BIND
256
+ reason: str = "" # one of WEB_UNAVAILABLE_REASONS, "" when ready
257
+
258
+ def __post_init__(self) -> None:
259
+ if self.state not in ("ready", "unavailable"):
260
+ raise ValueError(f"gateway state must be ready|unavailable: {self.state!r}")
261
+ if self.state == "ready" and self.reason:
262
+ raise ValueError("ready gateway must not carry an unavailable reason")
263
+ if self.reason and self.reason not in WEB_UNAVAILABLE_REASONS:
264
+ raise ValueError(f"unknown unavailable reason: {self.reason!r}")
265
+
266
+ def to_dict(self) -> Dict[str, Any]:
267
+ out = {
268
+ "state": self.state,
269
+ "container_port": self.container_port,
270
+ "host_port": self.host_port,
271
+ "host": self.host,
272
+ }
273
+ if self.reason:
274
+ out["reason"] = self.reason
275
+ return out
276
+
277
+
278
+ @dataclass
279
+ class WebServiceInfo:
280
+ """One service row in a ``runtime services`` payload (URL attached)."""
281
+
282
+ port: int
283
+ protocol: str = WEB_SERVICE_PROTOCOL
284
+ name: str = ""
285
+ state: str = "registered"
286
+ url: str = ""
287
+
288
+ def __post_init__(self) -> None:
289
+ if not is_exposable_port(self.port):
290
+ raise ValueError(f"service port out of range: {self.port}")
291
+
292
+ def to_dict(self) -> Dict[str, Any]:
293
+ return {
294
+ "port": self.port,
295
+ "protocol": self.protocol,
296
+ "name": self.name,
297
+ "state": self.state,
298
+ "url": self.url,
299
+ }
300
+
301
+
302
+ @dataclass
303
+ class RuntimeServicesResult:
304
+ """``aisc runtime services`` payload (schema ``aisc.runtime-services/v1``)."""
305
+
306
+ runtime_id: str
307
+ gateway: WebGatewayInfo = field(default_factory=WebGatewayInfo)
308
+ services: List[WebServiceInfo] = field(default_factory=list)
309
+ observed_at: str = ""
310
+ schema_version: str = field(default=RUNTIME_SERVICES_SCHEMA_V1, repr=False)
311
+
312
+ def to_dict(self) -> Dict[str, Any]:
313
+ return {
314
+ "schema_version": self.schema_version,
315
+ "runtime_id": self.runtime_id,
316
+ "gateway": self.gateway.to_dict(),
317
+ "services": [s.to_dict() for s in self.services],
318
+ "observed_at": self.observed_at,
319
+ }
320
+
321
+ @classmethod
322
+ def from_dict(cls, data: Dict[str, Any]) -> "RuntimeServicesResult":
323
+ """Decode strictly (fixture gate): wrong schema or shape raises."""
324
+ if not isinstance(data, dict):
325
+ raise ValueError("runtime services payload must be a JSON object")
326
+ if data.get("schema_version") != RUNTIME_SERVICES_SCHEMA_V1:
327
+ raise ValueError(f"unsupported schema_version: {data.get('schema_version')!r}")
328
+ gateway = WebGatewayInfo(
329
+ state=str(data["gateway"]["state"]),
330
+ container_port=int(data["gateway"]["container_port"]),
331
+ host_port=int(data["gateway"]["host_port"]),
332
+ host=str(data["gateway"]["host"]),
333
+ reason=str(data["gateway"].get("reason") or ""),
334
+ )
335
+ services = [
336
+ WebServiceInfo(
337
+ port=int(s["port"]),
338
+ protocol=str(s["protocol"]),
339
+ name=str(s["name"]),
340
+ state=str(s["state"]),
341
+ url=str(s["url"]),
342
+ )
343
+ for s in data["services"]
344
+ ]
345
+ return cls(
346
+ runtime_id=str(data["runtime_id"]),
347
+ gateway=gateway,
348
+ services=services,
349
+ observed_at=str(data["observed_at"]),
350
+ )
351
+
352
+
353
+ def web_access_unavailable(reason: str) -> WebGatewayInfo:
354
+ """Shorthand for the degraded snapshot path (legacy/stopped/unreachable)."""
355
+ return WebGatewayInfo(state="unavailable", reason=reason)
@@ -0,0 +1,139 @@
1
+ """Workspace lease contract (runtime-lifecycle-ux Stage 1, 02 §2).
2
+
3
+ A cross-process, expiring workspace lease distinguishes "a Workbench
4
+ instance is still using this workspace" from "left over from a crash" —
5
+ Docker running/stopped alone cannot. The lease is an anti-mistaken-delete
6
+ mechanism, NOT a session-attach mechanism.
7
+
8
+ Timing contract (02 §2.2, frozen):
9
+ - heartbeat every ``LEASE_HEARTBEAT_INTERVAL_SECONDS`` (10-15s band);
10
+ - lease expires after ``LEASE_TTL_SECONDS`` = 3 heartbeat periods;
11
+ - expiry alone NEVER authorizes deletion — the holder must re-verify via
12
+ registry/Docker-label reconciliation first (safety invariant 2; covers
13
+ system sleep/hibernate where wall-clock time jumps past the TTL while
14
+ the owning instance is merely suspended).
15
+
16
+ Heartbeats are written by the Tauri/Rust backend (D-RUNTIME-12) — never
17
+ by frontend JS timers, which throttle when the window hides to tray.
18
+
19
+ Storage lives in adapters/workspace_lease_store.py (the lease file rides
20
+ the DataRootStore layout: ``workspaces/<hash>/runtime-lease.json`` under
21
+ the workspace-scoped lock). Pure data + freshness math only here; no I/O,
22
+ no secrets (ids and timestamps only).
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ from dataclasses import dataclass
28
+ from datetime import datetime, timezone
29
+ from typing import Any, Dict, Optional
30
+
31
+ #: Lease JSON schema marker.
32
+ LEASE_SCHEMA = "aisc.workspace-lease/v1"
33
+ LEASE_SCHEMA_VERSION = 1
34
+
35
+ #: Heartbeat cadence every Workbench instance maintains per active workspace.
36
+ LEASE_HEARTBEAT_INTERVAL_SECONDS = 15.0
37
+
38
+ #: A lease is fresh while ``now - lease_last_seen_at <= TTL``; 3 heartbeat
39
+ #: periods of grace absorb transient heartbeat hiccups.
40
+ LEASE_TTL_SECONDS = 45.0
41
+
42
+
43
+ def now_iso() -> str:
44
+ """RFC3339 UTC timestamp (the only clock representation in the file)."""
45
+ return datetime.now(timezone.utc).isoformat()
46
+
47
+
48
+ def parse_ts(value: str) -> Optional[datetime]:
49
+ """Parse an RFC3339 timestamp; ``None`` for absent/garbage input."""
50
+ if not value:
51
+ return None
52
+ try:
53
+ return datetime.fromisoformat(value)
54
+ except ValueError:
55
+ return None
56
+
57
+
58
+ @dataclass(frozen=True)
59
+ class WorkspaceLease:
60
+ """One workspace lease record (the parsed lease file)."""
61
+
62
+ workspace_key: str = "" # sha256 hex of the canonical workspace path
63
+ lease_id: str = ""
64
+ workbench_instance_id: str = ""
65
+ claimed_at: str = ""
66
+ lease_last_seen_at: str = ""
67
+
68
+ # -- freshness ----------------------------------------------------------
69
+
70
+ def _last_seen(self) -> Optional[datetime]:
71
+ return parse_ts(self.lease_last_seen_at)
72
+
73
+ def age_seconds(self, now: Optional[datetime] = None) -> Optional[float]:
74
+ """Seconds since the last heartbeat; ``None`` when unparseable."""
75
+ seen = self._last_seen()
76
+ if seen is None:
77
+ return None
78
+ if now is None:
79
+ now = datetime.now(timezone.utc)
80
+ return max(0.0, (now - seen).total_seconds())
81
+
82
+ def expires_at_iso(self) -> str:
83
+ seen = self._last_seen()
84
+ if seen is None:
85
+ return ""
86
+ return datetime.fromtimestamp(
87
+ seen.timestamp() + LEASE_TTL_SECONDS, tz=timezone.utc
88
+ ).isoformat()
89
+
90
+ def is_expired(self, now: Optional[datetime] = None) -> bool:
91
+ """Expired by wall-clock TTL.
92
+
93
+ Callers MUST NOT treat ``False`` as "actively used" beyond this
94
+ process's own heartbeat, nor ``True`` as delete authorization —
95
+ post-expiry, reconcile against registry/Docker labels first.
96
+ A lease whose ``lease_last_seen_at`` is unparseable is NOT fresh
97
+ (fail closed toward re-claim, which overwrites the garbage).
98
+ """
99
+ age = self.age_seconds(now)
100
+ if age is None:
101
+ return True
102
+ return age > LEASE_TTL_SECONDS
103
+
104
+ def held_by(self, instance_id: str) -> bool:
105
+ return bool(instance_id) and self.workbench_instance_id == instance_id
106
+
107
+ # -- serialization ------------------------------------------------------
108
+
109
+ def to_dict(self) -> Dict[str, Any]:
110
+ return {
111
+ "schema": LEASE_SCHEMA,
112
+ "schema_version": LEASE_SCHEMA_VERSION,
113
+ "workspace_key": self.workspace_key,
114
+ "lease_id": self.lease_id,
115
+ "workbench_instance_id": self.workbench_instance_id,
116
+ "claimed_at": self.claimed_at,
117
+ "lease_last_seen_at": self.lease_last_seen_at,
118
+ }
119
+
120
+
121
+ def lease_from_dict(data: Any) -> Optional[WorkspaceLease]:
122
+ """Decode a lease record; ``None`` for foreign/garbage shapes.
123
+
124
+ Unknown ``schema``/``schema_version`` fail closed to ``None`` (treated
125
+ as no lease → a fresh claim overwrites the unrecognized file).
126
+ """
127
+ if not isinstance(data, dict):
128
+ return None
129
+ if data.get("schema") not in (None, LEASE_SCHEMA):
130
+ return None
131
+ if data.get("schema_version", LEASE_SCHEMA_VERSION) != LEASE_SCHEMA_VERSION:
132
+ return None
133
+ return WorkspaceLease(
134
+ workspace_key=str(data.get("workspace_key") or ""),
135
+ lease_id=str(data.get("lease_id") or ""),
136
+ workbench_instance_id=str(data.get("workbench_instance_id") or ""),
137
+ claimed_at=str(data.get("claimed_at") or ""),
138
+ lease_last_seen_at=str(data.get("lease_last_seen_at") or ""),
139
+ )
@@ -0,0 +1 @@
1
+ """Schema package — config validation and legacy parsers."""