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,452 @@
|
|
|
1
|
+
"""Config service — read-only validate & effective (S5.2 ora-7 final)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
9
|
+
|
|
10
|
+
from aisc.domain.config import (
|
|
11
|
+
IssueSeverity, PlatformPathConfig, PathPolicy, SchemaIssue,
|
|
12
|
+
)
|
|
13
|
+
from aisc.schemas.config_schema import validate_config
|
|
14
|
+
from aisc.adapters.config_reader import (
|
|
15
|
+
safe_read_config_bytes, parse_config_json, ReadError,
|
|
16
|
+
check_root_exists, check_dir_component,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
# ---------------------------------------------------------------------------
|
|
20
|
+
# Stable status literals — external contract (frozen set)
|
|
21
|
+
# ---------------------------------------------------------------------------
|
|
22
|
+
|
|
23
|
+
STATUS_LOADED = "loaded"
|
|
24
|
+
STATUS_MISSING = "missing"
|
|
25
|
+
STATUS_PERMISSION_DENIED = "permission_denied"
|
|
26
|
+
STATUS_INVALID_SOURCE = "invalid_source" # structural
|
|
27
|
+
STATUS_ERROR = "error"
|
|
28
|
+
|
|
29
|
+
ERR_CONFIG_INVALID = "AISC_ERR_CONFIG_INVALID"
|
|
30
|
+
ERR_CONFIG_MISSING = "AISC_ERR_CONFIG_MISSING"
|
|
31
|
+
ERR_PERMISSION_DENIED = "AISC_ERR_PERMISSION_DENIED"
|
|
32
|
+
ERR_GENERAL = "AISC_ERR_GENERAL"
|
|
33
|
+
|
|
34
|
+
# ---------------------------------------------------------------------------
|
|
35
|
+
# Result types
|
|
36
|
+
# ---------------------------------------------------------------------------
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True)
|
|
39
|
+
class SourceInfo:
|
|
40
|
+
kind: str; path: str; status: str
|
|
41
|
+
|
|
42
|
+
@dataclass(frozen=True)
|
|
43
|
+
class ConfigIssue:
|
|
44
|
+
severity: str; source: str; path: str; reason_code: str; message: str
|
|
45
|
+
|
|
46
|
+
@dataclass
|
|
47
|
+
class ServiceResult:
|
|
48
|
+
valid: bool = False
|
|
49
|
+
exit_code: int = 0
|
|
50
|
+
error_code: str = ""
|
|
51
|
+
error_message: str = ""
|
|
52
|
+
data: Dict[str, Any] = field(default_factory=dict)
|
|
53
|
+
|
|
54
|
+
# ---------------------------------------------------------------------------
|
|
55
|
+
# Helpers
|
|
56
|
+
# ---------------------------------------------------------------------------
|
|
57
|
+
|
|
58
|
+
def _mk_src(kind, path, status):
|
|
59
|
+
return SourceInfo(kind=kind, path=path, status=status)
|
|
60
|
+
|
|
61
|
+
def _early_err(code, errc, msg, user_src, ws_src):
|
|
62
|
+
"""Build an early ServiceResult with exactly [user, workspace] sources."""
|
|
63
|
+
return ServiceResult(valid=False, exit_code=code, error_code=errc,
|
|
64
|
+
error_message=msg,
|
|
65
|
+
data={"valid": False,
|
|
66
|
+
"sources": [_src_d(user_src), _src_d(ws_src)],
|
|
67
|
+
"issues": []})
|
|
68
|
+
|
|
69
|
+
def _mk_iss(severity, source, path, rc, msg):
|
|
70
|
+
return ConfigIssue(severity=severity, source=source, path=path, reason_code=rc, message=msg)
|
|
71
|
+
|
|
72
|
+
def _src_d(si): return {"kind": si.kind, "path": si.path, "status": si.status}
|
|
73
|
+
def _iss_d(ci): return {"severity": ci.severity, "source": ci.source, "path": ci.path,
|
|
74
|
+
"reason_code": ci.reason_code, "message": ci.message}
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
# ---------------------------------------------------------------------------
|
|
78
|
+
# Content error types — structured, never echoes input
|
|
79
|
+
# ---------------------------------------------------------------------------
|
|
80
|
+
|
|
81
|
+
class ContentErrorKind:
|
|
82
|
+
INVALID_UTF8 = "invalid_utf8"
|
|
83
|
+
INVALID_JSON = "invalid_json"
|
|
84
|
+
DUPLICATE_KEY = "duplicate_key"
|
|
85
|
+
CONFIG_NOT_OBJECT = "config_not_object"
|
|
86
|
+
JSON_DEPTH_LIMIT = "json_depth_limit"
|
|
87
|
+
JSON_NODE_LIMIT = "json_node_limit"
|
|
88
|
+
JSON_STRING_LIMIT = "json_string_limit"
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
_CE_MESSAGES = {
|
|
92
|
+
ContentErrorKind.INVALID_UTF8: "Config file is not valid UTF-8",
|
|
93
|
+
ContentErrorKind.INVALID_JSON: "Invalid JSON in config file",
|
|
94
|
+
ContentErrorKind.DUPLICATE_KEY: "Duplicate key in config JSON",
|
|
95
|
+
ContentErrorKind.CONFIG_NOT_OBJECT: "Config must be a JSON object",
|
|
96
|
+
ContentErrorKind.JSON_DEPTH_LIMIT: "JSON nesting too deep",
|
|
97
|
+
ContentErrorKind.JSON_NODE_LIMIT: "JSON node limit exceeded",
|
|
98
|
+
ContentErrorKind.JSON_STRING_LIMIT: "JSON string too long",
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _classify_content_error(exc: Exception) -> Optional[str]:
|
|
103
|
+
"""Classify a parser exception into a ContentErrorKind string."""
|
|
104
|
+
msg = str(exc)
|
|
105
|
+
if isinstance(exc, UnicodeDecodeError):
|
|
106
|
+
return ContentErrorKind.INVALID_UTF8
|
|
107
|
+
if "Duplicate key" in msg:
|
|
108
|
+
return ContentErrorKind.DUPLICATE_KEY
|
|
109
|
+
if "not a JSON object" in msg or "not object" in msg.lower() or "Config must be a JSON object" in msg:
|
|
110
|
+
return ContentErrorKind.CONFIG_NOT_OBJECT
|
|
111
|
+
if "too deep" in msg.lower() or "nesting too deep" in msg.lower():
|
|
112
|
+
return ContentErrorKind.JSON_DEPTH_LIMIT
|
|
113
|
+
if "node limit" in msg.lower():
|
|
114
|
+
return ContentErrorKind.JSON_NODE_LIMIT
|
|
115
|
+
if "too long" in msg.lower() or "string too long" in msg.lower():
|
|
116
|
+
return ContentErrorKind.JSON_STRING_LIMIT
|
|
117
|
+
if isinstance(exc, ValueError):
|
|
118
|
+
return ContentErrorKind.INVALID_JSON
|
|
119
|
+
return None
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
# ---------------------------------------------------------------------------
|
|
123
|
+
# Read one layer — returns (data, source_info, content_error_kind)
|
|
124
|
+
# ---------------------------------------------------------------------------
|
|
125
|
+
|
|
126
|
+
def _read_layer(file_path: Path, is_explicit: bool, kind: str) -> Tuple[Optional[dict], SourceInfo, Optional[str]]:
|
|
127
|
+
"""Read/parse one config file. content_error_kind is None on success."""
|
|
128
|
+
fp = str(file_path)
|
|
129
|
+
try:
|
|
130
|
+
raw = safe_read_config_bytes(file_path)
|
|
131
|
+
except FileNotFoundError:
|
|
132
|
+
if is_explicit: raise
|
|
133
|
+
return None, _mk_src(kind, fp, STATUS_MISSING), None
|
|
134
|
+
except PermissionError:
|
|
135
|
+
return None, _mk_src(kind, fp, STATUS_PERMISSION_DENIED), None
|
|
136
|
+
except ReadError:
|
|
137
|
+
return None, _mk_src(kind, fp, STATUS_INVALID_SOURCE), None
|
|
138
|
+
except OSError:
|
|
139
|
+
return None, _mk_src(kind, fp, STATUS_ERROR), None
|
|
140
|
+
|
|
141
|
+
try:
|
|
142
|
+
data = parse_config_json(raw)
|
|
143
|
+
except (UnicodeDecodeError, ValueError) as exc:
|
|
144
|
+
ce = _classify_content_error(exc)
|
|
145
|
+
return None, _mk_src(kind, fp, STATUS_LOADED), ce # external status = loaded
|
|
146
|
+
return data, _mk_src(kind, fp, STATUS_LOADED), None
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
# ---------------------------------------------------------------------------
|
|
150
|
+
# Overlay
|
|
151
|
+
# ---------------------------------------------------------------------------
|
|
152
|
+
|
|
153
|
+
def _overlay(base: dict, overrides: dict) -> dict:
|
|
154
|
+
result = dict(base)
|
|
155
|
+
if "defaults" in overrides and isinstance(overrides["defaults"], dict):
|
|
156
|
+
od = overrides["defaults"]
|
|
157
|
+
cur = result.get("defaults", {})
|
|
158
|
+
if not isinstance(cur, dict): cur = {}
|
|
159
|
+
result["defaults"] = {**cur}
|
|
160
|
+
for k in ("profile", "network"):
|
|
161
|
+
if k in od: result["defaults"][k] = od[k]
|
|
162
|
+
return result
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
# ---------------------------------------------------------------------------
|
|
166
|
+
# Unified core — validate + effective share reader/validation logic
|
|
167
|
+
# ---------------------------------------------------------------------------
|
|
168
|
+
|
|
169
|
+
def _read_layers(
|
|
170
|
+
explicit_config, workspace, home, env, platform_name,
|
|
171
|
+
) -> Tuple[Optional[ServiceResult], Optional[dict], Optional[dict],
|
|
172
|
+
List[SourceInfo], List[ConfigIssue]]:
|
|
173
|
+
"""Read both config layers. Returns early ServiceResult on fatal error,
|
|
174
|
+
else (user_data, ws_data, sources, issues). Always exactly 2 sources."""
|
|
175
|
+
# --- Establish user_cfg_path before any early return ---
|
|
176
|
+
if explicit_config:
|
|
177
|
+
explicit_config = os.path.abspath(explicit_config)
|
|
178
|
+
user_cfg_path = Path(explicit_config)
|
|
179
|
+
pp = PlatformPathConfig(config_dir="", state_dir="")
|
|
180
|
+
user_status = STATUS_MISSING # not yet read, but path is known
|
|
181
|
+
else:
|
|
182
|
+
try:
|
|
183
|
+
pp = _resolve_platform(home, env, platform_name)
|
|
184
|
+
except ReadError:
|
|
185
|
+
return (_early_err(1, ERR_GENERAL, "Platform path error",
|
|
186
|
+
_mk_src("user", "", STATUS_MISSING),
|
|
187
|
+
_mk_src("workspace", "", STATUS_MISSING)),
|
|
188
|
+
None, None, [], [])
|
|
189
|
+
except PermissionError:
|
|
190
|
+
return (_early_err(9, ERR_PERMISSION_DENIED, "Permission denied accessing platform",
|
|
191
|
+
_mk_src("user", "", STATUS_PERMISSION_DENIED),
|
|
192
|
+
_mk_src("workspace", "", STATUS_MISSING)),
|
|
193
|
+
None, None, [], [])
|
|
194
|
+
except OSError:
|
|
195
|
+
return (_early_err(1, ERR_GENERAL, "Cannot access platform config root",
|
|
196
|
+
_mk_src("user", "", STATUS_ERROR),
|
|
197
|
+
_mk_src("workspace", "", STATUS_MISSING)),
|
|
198
|
+
None, None, [], [])
|
|
199
|
+
user_cfg_path = Path(os.path.join(pp.config_dir, "config.json"))
|
|
200
|
+
ws_abs = os.path.abspath(workspace) if workspace else os.path.abspath(os.getcwd())
|
|
201
|
+
policy = PathPolicy(platform=pp, workspace=ws_abs, aisc_root=None)
|
|
202
|
+
|
|
203
|
+
# --- Validate workspace root ---
|
|
204
|
+
try:
|
|
205
|
+
check_root_exists(ws_abs)
|
|
206
|
+
except FileNotFoundError:
|
|
207
|
+
return (_early_err(7, ERR_CONFIG_MISSING, "Workspace root not found",
|
|
208
|
+
_mk_src("user", str(user_cfg_path), "missing"),
|
|
209
|
+
_mk_src("workspace", ws_abs, STATUS_MISSING)),
|
|
210
|
+
None, None, [], [])
|
|
211
|
+
except ReadError:
|
|
212
|
+
return (_early_err(1, ERR_GENERAL, "Workspace root is a symlink",
|
|
213
|
+
_mk_src("user", str(user_cfg_path), "missing"),
|
|
214
|
+
_mk_src("workspace", ws_abs, STATUS_INVALID_SOURCE)),
|
|
215
|
+
None, None, [], [])
|
|
216
|
+
except PermissionError:
|
|
217
|
+
return (_early_err(9, ERR_PERMISSION_DENIED, "Permission denied accessing workspace",
|
|
218
|
+
_mk_src("user", str(user_cfg_path), "missing"),
|
|
219
|
+
_mk_src("workspace", ws_abs, STATUS_PERMISSION_DENIED)),
|
|
220
|
+
None, None, [], [])
|
|
221
|
+
except OSError:
|
|
222
|
+
return (_early_err(1, ERR_GENERAL, "Cannot access workspace root",
|
|
223
|
+
_mk_src("user", str(user_cfg_path), "missing"),
|
|
224
|
+
_mk_src("workspace", ws_abs, STATUS_ERROR)),
|
|
225
|
+
None, None, [], [])
|
|
226
|
+
# check_root_exists already verified existence + directory — no second check needed
|
|
227
|
+
|
|
228
|
+
# --- Platform config root (only when auto-detecting user config) ---
|
|
229
|
+
if not explicit_config:
|
|
230
|
+
try:
|
|
231
|
+
check_root_exists(pp.config_dir)
|
|
232
|
+
except FileNotFoundError:
|
|
233
|
+
pass # auto-detected platform root missing → user config will be missing, OK
|
|
234
|
+
except ReadError:
|
|
235
|
+
return (_early_err(1, ERR_GENERAL, "Platform config root is a symlink",
|
|
236
|
+
_mk_src("user", str(user_cfg_path), STATUS_INVALID_SOURCE),
|
|
237
|
+
_mk_src("workspace", _ws_path_str(ws_abs), STATUS_MISSING)),
|
|
238
|
+
None, None, [], [])
|
|
239
|
+
except PermissionError:
|
|
240
|
+
return (_early_err(9, ERR_PERMISSION_DENIED, "Permission denied accessing platform config root",
|
|
241
|
+
_mk_src("user", str(user_cfg_path), STATUS_PERMISSION_DENIED),
|
|
242
|
+
_mk_src("workspace", _ws_path_str(ws_abs), STATUS_MISSING)),
|
|
243
|
+
None, None, [], [])
|
|
244
|
+
except OSError:
|
|
245
|
+
return (_early_err(1, ERR_GENERAL, "Cannot access platform config root",
|
|
246
|
+
_mk_src("user", str(user_cfg_path), STATUS_ERROR),
|
|
247
|
+
_mk_src("workspace", _ws_path_str(ws_abs), STATUS_MISSING)),
|
|
248
|
+
None, None, [], [])
|
|
249
|
+
|
|
250
|
+
# --- Read user layer ---
|
|
251
|
+
try:
|
|
252
|
+
user_data, user_src, user_ce = _read_layer(user_cfg_path, bool(explicit_config), "user")
|
|
253
|
+
except FileNotFoundError:
|
|
254
|
+
return (ServiceResult(valid=False, exit_code=7, error_code=ERR_CONFIG_MISSING,
|
|
255
|
+
error_message="Explicit config file not found",
|
|
256
|
+
data={"valid": False, "sources": [
|
|
257
|
+
_src_d(_mk_src("user", str(user_cfg_path), "missing")),
|
|
258
|
+
_src_d(_mk_src("workspace", _ws_path_str(ws_abs), STATUS_MISSING)),
|
|
259
|
+
], "issues": []}), None, None, [], [])
|
|
260
|
+
except PermissionError:
|
|
261
|
+
return (ServiceResult(valid=False, exit_code=9, error_code=ERR_PERMISSION_DENIED,
|
|
262
|
+
error_message="Permission denied reading user config",
|
|
263
|
+
data={"valid": False, "sources": [
|
|
264
|
+
_src_d(_mk_src("user", str(user_cfg_path), STATUS_PERMISSION_DENIED)),
|
|
265
|
+
_src_d(_mk_src("workspace", _ws_path_str(ws_abs), STATUS_MISSING)),
|
|
266
|
+
], "issues": []}), None, None, [], [])
|
|
267
|
+
|
|
268
|
+
# --- Read workspace layer ---
|
|
269
|
+
ws_path = _ws_path_str(ws_abs)
|
|
270
|
+
ws_file_path = Path(ws_path)
|
|
271
|
+
legacy_ws_file = Path(ws_abs) / ".aisc" / "config.json"
|
|
272
|
+
if ws_file_path == legacy_ws_file:
|
|
273
|
+
# Structural reparse check only applies to the legacy layout; the
|
|
274
|
+
# data-root path was validated by the resolver at resolve time.
|
|
275
|
+
try:
|
|
276
|
+
check_dir_component(str(ws_file_path.parent.parent), ".aisc")
|
|
277
|
+
except FileNotFoundError:
|
|
278
|
+
pass # .aisc doesn't exist → config.json will be missing (handled by _read_layer)
|
|
279
|
+
except ReadError:
|
|
280
|
+
return (_early_err(1, ERR_GENERAL, "Workspace config component is a symlink",
|
|
281
|
+
user_src,
|
|
282
|
+
_mk_src("workspace", ws_path, STATUS_INVALID_SOURCE)),
|
|
283
|
+
None, None, [], [])
|
|
284
|
+
except PermissionError:
|
|
285
|
+
return (_early_err(9, ERR_PERMISSION_DENIED, "Permission denied accessing workspace config",
|
|
286
|
+
user_src,
|
|
287
|
+
_mk_src("workspace", ws_path, STATUS_PERMISSION_DENIED)),
|
|
288
|
+
None, None, [], [])
|
|
289
|
+
except OSError:
|
|
290
|
+
return (_early_err(1, ERR_GENERAL, "Cannot access workspace config component",
|
|
291
|
+
user_src,
|
|
292
|
+
_mk_src("workspace", ws_path, STATUS_ERROR)),
|
|
293
|
+
None, None, [], [])
|
|
294
|
+
|
|
295
|
+
ws_data, ws_src, ws_ce = _read_layer(ws_file_path, False, "workspace")
|
|
296
|
+
|
|
297
|
+
# Build sources and issues
|
|
298
|
+
sources = [user_src, ws_src]
|
|
299
|
+
issues: List[ConfigIssue] = []
|
|
300
|
+
|
|
301
|
+
# Content errors for user
|
|
302
|
+
if user_ce:
|
|
303
|
+
issues.append(_mk_iss("error", "user", "(root)", user_ce,
|
|
304
|
+
_CE_MESSAGES.get(user_ce, "Content error")))
|
|
305
|
+
# Content errors for workspace
|
|
306
|
+
if ws_ce:
|
|
307
|
+
issues.append(_mk_iss("error", "workspace", "(root)", ws_ce,
|
|
308
|
+
_CE_MESSAGES.get(ws_ce, "Content error")))
|
|
309
|
+
|
|
310
|
+
return None, user_data, ws_data, sources, issues
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def _ws_path_str(ws_abs: str) -> str:
|
|
314
|
+
"""Workspace config layer path (Stage 7).
|
|
315
|
+
|
|
316
|
+
Canonical: ``<data-root>/workspaces/<hash>/config.json``. The legacy
|
|
317
|
+
``<ws>/.aisc/config.json`` remains the read fallback until migrations
|
|
318
|
+
catch up. This is a read-only layer — if the data root is unusable we
|
|
319
|
+
degrade to the legacy path instead of failing the whole command
|
|
320
|
+
(writes go through ``workspace_state_dir`` and fail closed there).
|
|
321
|
+
"""
|
|
322
|
+
canonical = None
|
|
323
|
+
try:
|
|
324
|
+
from aisc.application.data_root import DataRootResolver
|
|
325
|
+
|
|
326
|
+
resolved = DataRootResolver().resolve(Path(ws_abs))
|
|
327
|
+
canonical = resolved.workspace_dir / "config.json"
|
|
328
|
+
if canonical.is_file():
|
|
329
|
+
return str(canonical)
|
|
330
|
+
except Exception:
|
|
331
|
+
canonical = None
|
|
332
|
+
legacy = Path(ws_abs) / ".aisc" / "config.json"
|
|
333
|
+
# Existing legacy wins over an absent canonical (transition read);
|
|
334
|
+
# otherwise report the canonical location (honest fresh state).
|
|
335
|
+
if legacy.is_file():
|
|
336
|
+
return str(legacy)
|
|
337
|
+
return str(canonical) if canonical is not None else str(legacy)
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def _finalize_classify(valid, sources, issues, effective=None, provenance=None):
|
|
341
|
+
"""Determine exit code and build ServiceResult."""
|
|
342
|
+
has_error = any(i.severity == "error" for i in issues)
|
|
343
|
+
structural = any(s.status in (STATUS_INVALID_SOURCE, STATUS_ERROR) for s in sources)
|
|
344
|
+
perm = any(s.status == STATUS_PERMISSION_DENIED for s in sources)
|
|
345
|
+
|
|
346
|
+
if structural:
|
|
347
|
+
ec, errc, msg = 1, ERR_GENERAL, "Structural error reading config"
|
|
348
|
+
elif perm:
|
|
349
|
+
ec, errc, msg = 9, ERR_PERMISSION_DENIED, "Permission denied"
|
|
350
|
+
elif has_error:
|
|
351
|
+
ec, errc, msg = 6, ERR_CONFIG_INVALID, "Config validation failed"
|
|
352
|
+
else:
|
|
353
|
+
ec, errc, msg = 0, "", ""
|
|
354
|
+
|
|
355
|
+
d: dict = {"valid": not (has_error or structural or perm),
|
|
356
|
+
"sources": [_src_d(s) for s in sources],
|
|
357
|
+
"issues": [_iss_d(i) for i in issues]}
|
|
358
|
+
if provenance is not None:
|
|
359
|
+
d["effective"] = effective
|
|
360
|
+
d["provenance"] = provenance
|
|
361
|
+
return ServiceResult(valid=d["valid"], exit_code=ec, error_code=errc,
|
|
362
|
+
error_message=msg, data=d)
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
# ---------------------------------------------------------------------------
|
|
366
|
+
# Public entry points
|
|
367
|
+
# ---------------------------------------------------------------------------
|
|
368
|
+
|
|
369
|
+
def run_config_validate(*, explicit_config=None, workspace=None, home=None,
|
|
370
|
+
env=None, platform_name=None) -> ServiceResult:
|
|
371
|
+
early, user_data, ws_data, sources, issues = _read_layers(
|
|
372
|
+
explicit_config, workspace, home, env, platform_name)
|
|
373
|
+
if early: return early
|
|
374
|
+
|
|
375
|
+
if user_data is not None:
|
|
376
|
+
for si in validate_config(user_data, is_workspace=False):
|
|
377
|
+
issues.append(ConfigIssue(severity=si.severity.value, source="user",
|
|
378
|
+
path=si.path, reason_code=si.reason_code, message=si.message))
|
|
379
|
+
if ws_data is not None:
|
|
380
|
+
for si in validate_config(ws_data, is_workspace=True):
|
|
381
|
+
issues.append(ConfigIssue(severity=si.severity.value, source="workspace",
|
|
382
|
+
path=si.path, reason_code=si.reason_code, message=si.message))
|
|
383
|
+
return _finalize_classify(True, sources, issues)
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def run_config_effective(*, explicit_config=None, workspace=None, home=None,
|
|
387
|
+
env=None, platform_name=None) -> ServiceResult:
|
|
388
|
+
early, user_data, ws_data, sources, issues = _read_layers(
|
|
389
|
+
explicit_config, workspace, home, env, platform_name)
|
|
390
|
+
if early:
|
|
391
|
+
# Early result from validate path — add effective/provenance fields
|
|
392
|
+
early.data["effective"] = None
|
|
393
|
+
early.data["provenance"] = {}
|
|
394
|
+
return early
|
|
395
|
+
|
|
396
|
+
if user_data is not None:
|
|
397
|
+
for si in validate_config(user_data, is_workspace=False):
|
|
398
|
+
issues.append(ConfigIssue(severity=si.severity.value, source="user",
|
|
399
|
+
path=si.path, reason_code=si.reason_code, message=si.message))
|
|
400
|
+
if ws_data is not None:
|
|
401
|
+
for si in validate_config(ws_data, is_workspace=True):
|
|
402
|
+
issues.append(ConfigIssue(severity=si.severity.value, source="workspace",
|
|
403
|
+
path=si.path, reason_code=si.reason_code, message=si.message))
|
|
404
|
+
|
|
405
|
+
has_any = any(s.status not in (STATUS_LOADED, STATUS_MISSING) for s in sources) or \
|
|
406
|
+
any(i.severity == "error" for i in issues)
|
|
407
|
+
if has_any:
|
|
408
|
+
return _finalize_classify(False, sources, issues, effective=None, provenance={})
|
|
409
|
+
|
|
410
|
+
effective = {
|
|
411
|
+
"schema_version": 1,
|
|
412
|
+
"defaults": {"profile": "safe", "network": "direct"},
|
|
413
|
+
}
|
|
414
|
+
provenance: dict = {"defaults.profile": "default", "defaults.network": "default"}
|
|
415
|
+
|
|
416
|
+
if user_data is not None:
|
|
417
|
+
effective = _overlay(effective, user_data)
|
|
418
|
+
if "defaults" in user_data and isinstance(user_data["defaults"], dict):
|
|
419
|
+
ud = user_data["defaults"]
|
|
420
|
+
if "profile" in ud: provenance["defaults.profile"] = "user"
|
|
421
|
+
if "network" in ud: provenance["defaults.network"] = "user"
|
|
422
|
+
if ws_data is not None:
|
|
423
|
+
effective = _overlay(effective, ws_data)
|
|
424
|
+
if "defaults" in ws_data and isinstance(ws_data["defaults"], dict):
|
|
425
|
+
wd = ws_data["defaults"]
|
|
426
|
+
if "profile" in wd: provenance["defaults.profile"] = "workspace"
|
|
427
|
+
if "network" in wd: provenance["defaults.network"] = "workspace"
|
|
428
|
+
return _finalize_classify(True, sources, issues, effective=effective, provenance=provenance)
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
# ---------------------------------------------------------------------------
|
|
432
|
+
# Platform
|
|
433
|
+
# ---------------------------------------------------------------------------
|
|
434
|
+
|
|
435
|
+
def _user_config_path(policy: PathPolicy) -> Path:
|
|
436
|
+
return Path(os.path.join(policy.platform.config_dir, "config.json"))
|
|
437
|
+
|
|
438
|
+
def _resolve_platform(home=None, env=None, platform=None):
|
|
439
|
+
if home is None: home = os.path.expanduser("~")
|
|
440
|
+
if env is None: env = dict(os.environ)
|
|
441
|
+
if platform is None: import sys; platform = sys.platform
|
|
442
|
+
if platform == "win32":
|
|
443
|
+
appdata = env.get("APPDATA", os.path.join(home, "AppData", "Roaming"))
|
|
444
|
+
config_dir = os.path.join(appdata, "aisc")
|
|
445
|
+
elif platform == "darwin":
|
|
446
|
+
config_dir = os.path.join(home, "Library", "Application Support", "aisc")
|
|
447
|
+
else:
|
|
448
|
+
xdg = env.get("XDG_CONFIG_HOME", os.path.join(home, ".config"))
|
|
449
|
+
if not os.path.isabs(xdg):
|
|
450
|
+
raise ReadError("structural_error", "XDG_CONFIG_HOME must be absolute")
|
|
451
|
+
config_dir = os.path.join(xdg, "aisc")
|
|
452
|
+
return PlatformPathConfig(config_dir=config_dir, state_dir="")
|