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
aisc/domain/toolchain.py
ADDED
|
@@ -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
|
+
)
|
aisc/schemas/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Schema package — config validation and legacy parsers."""
|