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,274 @@
1
+ """PERF P4 (D-13): SDK-backed Docker executor for the command layer.
2
+
3
+ The runtime/provider/services command layer used to hardcode
4
+ ``RealDockerExecutor`` — every Docker call inside each aisc.exe invocation
5
+ was a ``docker.exe`` subprocess chain (info → ps → inspect → exec). On weak
6
+ machines each docker.exe cold start costs 100-300ms × 5-6 calls per tick.
7
+
8
+ ``SdkBackedDockerExecutor`` implements the same ``DockerExecutor`` protocol
9
+ with the HOT-READ face served over the docker SDK's named-pipe/unix-socket
10
+ HTTP client (in-process, zero subprocesses):
11
+
12
+ - ``preflight()`` — SDK ping first, CLI fallback (reason vocabulary kept)
13
+ - ``inspect_container(name)`` — SDK inspect; NotFound maps to the CLI's
14
+ "No such object" stderr semantics the callers pattern-match
15
+ - ``run_captured`` for the three hot argv shapes:
16
+ * ``ps -a --filter label=... --format <template>`` — SDK containers.list
17
+ + a mini renderer for the templates we ship (``ID``/``Names``/``Image``/
18
+ ``Status``/``Label "k"`` tokens only — anything else falls back to CLI)
19
+ * ``exec <name> <cmd...>`` (no flags) — exec_create/start/inspect
20
+ * ``inspect <name>``
21
+ - everything else (run/build/start/stop/rm/pull/... and all streaming /
22
+ interactive methods) delegates verbatim to an inner ``RealDockerExecutor``
23
+ — low-frequency control operations where the CLI's behavior is battle-
24
+ tested (buildx flags, process groups, etc.).
25
+
26
+ Failure semantics: ANY SDK exception on a mapped path falls back to the CLI
27
+ invocation — worst case = today's behavior, never worse.
28
+ ``AISC_DOCKER_EXECUTOR=cli`` restores the pure-CLI executor globally.
29
+ """
30
+ from __future__ import annotations
31
+
32
+ import json
33
+ import os
34
+ import re
35
+ from typing import Any, Dict, List, Optional
36
+
37
+ from aisc.domain.models import DockerPreflightResult, ProcessResult
38
+
39
+ # Templates the ps renderer understands — every token must be one of
40
+ # ID / Names / Image / Status / Label "k". Anything else → CLI fallback.
41
+ _PS_TOKEN = re.compile(r'\{\{\.(ID|Names|Image|Status|Label "([^"]+)")\}\}')
42
+
43
+
44
+ def _render_ps_line(template: str, c: Any) -> Optional[str]:
45
+ """Render one docker-ps line for a SDK container object.
46
+
47
+ ``Status`` synthesizes the CLI's human form ("Up"/"Exited") — the only
48
+ property consumers rely on is ``startswith("Up")`` (runtime.py
49
+ ``_docker_status_to_state``)."""
50
+ out: List[str] = []
51
+ pos = 0
52
+ for m in _PS_TOKEN.finditer(template):
53
+ out.append(template[pos : m.start()])
54
+ label_key = m.group(2)
55
+ try:
56
+ if label_key is not None:
57
+ labels = c.attrs.get("Labels")
58
+ if labels is None:
59
+ labels = c.attrs.get("Config", {}).get("Labels", {})
60
+ out.append(str(labels.get(label_key, "")))
61
+ elif m.group(1) == "Status":
62
+ # containers.list() is backed by /containers/json, where State
63
+ # is a string ("running"/"paused"/"exited"), unlike inspect.
64
+ state = c.attrs.get("State", "")
65
+ running = (
66
+ bool(state.get("Running"))
67
+ if isinstance(state, dict)
68
+ else str(state).lower() in {"running", "paused"}
69
+ )
70
+ out.append("Up" if running else "Exited")
71
+ elif m.group(1) == "ID":
72
+ out.append(str(c.id)[:12])
73
+ elif m.group(1) == "Names":
74
+ names = list(c.attrs.get("Names") or [])
75
+ if names:
76
+ rendered = ",".join(name.lstrip("/") for name in names)
77
+ else:
78
+ rendered = str(c.name).lstrip("/")
79
+ out.append(rendered)
80
+ elif m.group(1) == "Image":
81
+ # Avoid `c.image`: it triggers an extra image inspect. Sparse
82
+ # list payloads expose Image directly; inspect payloads expose
83
+ # Config.Image, so support both shapes without extra calls.
84
+ image = c.attrs.get("Config", {}).get("Image")
85
+ if image is None:
86
+ image = c.attrs.get("Image", "")
87
+ out.append(str(image))
88
+ else:
89
+ return None # unreachable; keeps the checker honest
90
+ except Exception: # noqa: BLE001 — unexpected shape: bail to CLI
91
+ return None
92
+ pos = m.end()
93
+ out.append(template[pos:])
94
+ return "".join(out)
95
+
96
+
97
+ class SdkBackedDockerExecutor:
98
+ """DockerExecutor protocol with the hot-read face on the SDK."""
99
+
100
+ def __init__(self, client: Any = None):
101
+ self._client = client
102
+ from aisc.adapters.docker_ import RealDockerExecutor
103
+
104
+ self._cli = RealDockerExecutor()
105
+
106
+ # -- SDK client ------------------------------------------------------
107
+
108
+ def _sdk(self) -> Any:
109
+ if self._client is None:
110
+ from aisc.adapters.docker_gateway import _default_client
111
+
112
+ self._client = _default_client()
113
+ return self._client
114
+
115
+ # -- structured hot ops ----------------------------------------------
116
+
117
+ def preflight(self) -> DockerPreflightResult:
118
+ try:
119
+ self._sdk().ping()
120
+ return DockerPreflightResult(
121
+ docker_path="(sdk)", available=True, reason="ok"
122
+ )
123
+ except Exception: # noqa: BLE001 — SDK unusable: CLI may still be
124
+ return self._cli.preflight()
125
+
126
+ def inspect_container(self, container_name: str) -> ProcessResult:
127
+ try:
128
+ data = self._sdk().api.inspect_container(container_name)
129
+ return ProcessResult(
130
+ stdout=json.dumps([data]), stderr="", exit_code=0
131
+ )
132
+ except Exception as exc: # noqa: BLE001
133
+ import docker.errors
134
+
135
+ if isinstance(exc, docker.errors.NotFound):
136
+ return ProcessResult(
137
+ stdout="",
138
+ stderr=f"Error: No such object: {container_name}",
139
+ exit_code=1,
140
+ )
141
+ # Transient/unknown: CLI fallback preserves today's semantics.
142
+ return self._cli.inspect_container(container_name)
143
+
144
+ # -- free argv: map the three hot shapes, CLI for the rest ------------
145
+
146
+ def run_captured(
147
+ self,
148
+ docker_argv: List[str],
149
+ *,
150
+ timeout: Optional[float] = None,
151
+ input_text: Optional[str] = None,
152
+ ) -> ProcessResult:
153
+ try:
154
+ if docker_argv and docker_argv[0] == "ps":
155
+ # ps is read-only and does not consume stdin; SDK execution can
156
+ # preserve this shape even when the caller sets a CLI timeout.
157
+ mapped = self._run_ps(docker_argv)
158
+ if mapped is not None:
159
+ return mapped
160
+ elif (
161
+ docker_argv
162
+ and docker_argv[0] == "exec"
163
+ # exec is the only hot shape where the CLI timeout/input
164
+ # semantics differ materially from the mapped SDK call. Never
165
+ # silently drop either argument.
166
+ and timeout is None
167
+ and input_text is None
168
+ ):
169
+ mapped = self._run_exec(docker_argv)
170
+ if mapped is not None:
171
+ return mapped
172
+ elif docker_argv and docker_argv[0] == "inspect" and len(docker_argv) == 2:
173
+ return self.inspect_container(docker_argv[1])
174
+ except Exception: # noqa: BLE001 — any mapping trouble: CLI path
175
+ pass
176
+ return self._cli.run_captured(
177
+ docker_argv, timeout=timeout, input_text=input_text
178
+ )
179
+
180
+ def _run_ps(self, argv: List[str]) -> Optional[ProcessResult]:
181
+ filters: Dict[str, List[str]] = {}
182
+ template: Optional[str] = None
183
+ all_flag = False
184
+ i = 1
185
+ while i < len(argv):
186
+ a = argv[i]
187
+ if a in ("-a", "--all"):
188
+ all_flag = True
189
+ elif a == "--filter" and i + 1 < len(argv):
190
+ f = argv[i + 1]
191
+ if not f.startswith("label="):
192
+ return None # non-label filters unmapped → CLI
193
+ filters.setdefault("label", []).append(f[len("label=") :])
194
+ i += 1
195
+ elif a == "--format" and i + 1 < len(argv):
196
+ template = argv[i + 1]
197
+ i += 1
198
+ elif a.startswith("-"):
199
+ return None # unknown flag → CLI
200
+ i += 1
201
+ if template is None:
202
+ return None
203
+ # Docker's format language is larger than the field set mapped here.
204
+ # Reject any unknown field token before fetching so a new caller shape
205
+ # falls back to CLI instead of rendering the template literally.
206
+ if "{{" in _PS_TOKEN.sub("", template):
207
+ return None
208
+ # sparse=True keeps this as one /containers/json request. The default
209
+ # list() call performs an additional container inspect per row.
210
+ containers = self._sdk().containers.list(
211
+ all=all_flag, filters=filters or None, sparse=True
212
+ )
213
+ lines = []
214
+ for c in containers:
215
+ line = _render_ps_line(template, c)
216
+ if line is None:
217
+ return None
218
+ lines.append(line)
219
+ return ProcessResult(stdout="\n".join(lines), stderr="", exit_code=0)
220
+
221
+ def _run_exec(self, argv: List[str]) -> Optional[ProcessResult]:
222
+ # Plain `exec <name> <cmd...>` only — flags (-e/-u/-w/...) between
223
+ # "exec" and the container name are unmapped (the command TAIL may
224
+ # contain anything, including dash-prefixed tool args like --json).
225
+ if len(argv) < 3 or argv[1].startswith("-"):
226
+ return None
227
+ container_name, cmd = argv[1], argv[2:]
228
+ api = self._sdk().api
229
+ exec_id = api.exec_create(container_name, cmd, stdout=True, stderr=True)
230
+ # demux=True is required for CLI parity: without it docker-py merges the
231
+ # multiplexed stdout/stderr stream and JSON consumers can silently see
232
+ # warnings/error frames mixed into stdout.
233
+ result = api.exec_start(exec_id, socket=False, tty=False, demux=True)
234
+ if not (isinstance(result, tuple) and len(result) == 2):
235
+ return None
236
+ raw_stdout, raw_stderr = result
237
+
238
+ def _decode(value: Any) -> str:
239
+ if value is None:
240
+ return ""
241
+ if isinstance(value, bytes):
242
+ return value.decode("utf-8", "replace")
243
+ if isinstance(value, str):
244
+ return value
245
+ return None # type: ignore[return-value]
246
+
247
+ stdout = _decode(raw_stdout)
248
+ stderr = _decode(raw_stderr)
249
+ if stdout is None or stderr is None:
250
+ return None
251
+ info = api.exec_inspect(exec_id)
252
+ return ProcessResult(
253
+ stdout=stdout,
254
+ stderr=stderr,
255
+ exit_code=int(info.get("ExitCode", 0) or 0),
256
+ )
257
+
258
+ # -- everything else: verbatim CLI delegation --------------------------
259
+
260
+ def __getattr__(self, name: str) -> Any:
261
+ return getattr(self.__dict__.get("_cli"), name)
262
+
263
+
264
+ def default_executor() -> Any:
265
+ """Command-layer executor factory (PERF P4).
266
+
267
+ ``AISC_DOCKER_EXECUTOR=cli`` restores the pure-CLI executor; tests pass
268
+ their own ``executor`` as before (the factory only feeds the ``or``
269
+ default)."""
270
+ if os.environ.get("AISC_DOCKER_EXECUTOR", "").lower() == "cli":
271
+ from aisc.adapters.docker_ import RealDockerExecutor
272
+
273
+ return RealDockerExecutor()
274
+ return SdkBackedDockerExecutor()
@@ -0,0 +1,97 @@
1
+ """Docker maintenance lock (A0 / docker-ownership-foundation).
2
+
3
+ Cross-process lock serializing host-level Docker resource mutations:
4
+ installer scan/cleanup/rebuild, ``aisc build`` writes to the default
5
+ workstation tag, runtime create/start resolving the default tag to an
6
+ image ID, and reconcile's removal critical sections
7
+ (docker-resource-lifecycle 02-domain-contract.md §4.1,
8
+ 05-cross-plan-coordination.md §4).
9
+
10
+ FROZEN LOCK ORDER (violating it deadlocks — enforced by convention and
11
+ reviewed in tests/docs, not by the runtime):
12
+
13
+ docker-maintenance.lock -> workspace lock -> registry transaction
14
+
15
+ The lock file lives at ``<data-root>/state/locks/docker-maintenance.lock``
16
+ via the frozen DataRootStore layout (domain/data_root.py LOCKS_SUBDIR —
17
+ the plans' ``<data-root>/locks/`` notation resolves to this contract
18
+ path; consumers never concatenate paths themselves).
19
+
20
+ The acquisition timeout must outlast the longest legitimate holder (a
21
+ no-cache workstation rebuild takes minutes); callers may lower it when
22
+ they know the contention window is short.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ from contextlib import contextmanager
28
+ from pathlib import Path
29
+ from typing import Iterator
30
+
31
+ from aisc.adapters.data_root_store import SCOPE_SHARED, DataRootStore
32
+ from aisc.domain.data_root import ERR_LOCK_TIMEOUT
33
+
34
+ #: Lock name; ``DataRootStore.lock_path_for`` turns this into
35
+ #: ``state/locks/docker-maintenance.lock`` under the shared scope.
36
+ MAINTENANCE_LOCK_NAME = "docker-maintenance"
37
+
38
+ #: Stable error code surfaced when acquisition times out.
39
+ ERR_MAINTENANCE_LOCK_TIMEOUT = "AISC_ERR_MAINTENANCE_LOCK_TIMEOUT"
40
+
41
+ #: Default acquisition timeout (seconds) — sized to survive a no-cache
42
+ #: rebuild holding the lock, not just metadata mutations.
43
+ MAINTENANCE_LOCK_TIMEOUT = 300.0
44
+
45
+
46
+ @contextmanager
47
+ def docker_maintenance_lock(
48
+ store: DataRootStore,
49
+ timeout: float = MAINTENANCE_LOCK_TIMEOUT,
50
+ ) -> Iterator[None]:
51
+ """Hold the Docker maintenance lock.
52
+
53
+ Raises ``CliError(ERR_MAINTENANCE_LOCK_TIMEOUT)`` on timeout — the
54
+ caller must NOT proceed to mutate Docker resources without the lock
55
+ (fail-closed). Acquire this BEFORE any workspace/registry lock.
56
+ """
57
+ with store.lock(
58
+ MAINTENANCE_LOCK_NAME,
59
+ timeout,
60
+ scope=SCOPE_SHARED,
61
+ error_code=ERR_MAINTENANCE_LOCK_TIMEOUT,
62
+ ):
63
+ yield
64
+
65
+
66
+ def maintenance_lock_path(store: DataRootStore):
67
+ """Canonical lock file path (contract: ``state/locks/<name>.lock``)."""
68
+ return store.lock_path_for(MAINTENANCE_LOCK_NAME, scope=SCOPE_SHARED)
69
+
70
+
71
+ @contextmanager
72
+ def docker_maintenance_lock_at_root(
73
+ data_root,
74
+ timeout: float = MAINTENANCE_LOCK_TIMEOUT,
75
+ ) -> Iterator[None]:
76
+ """Same lock, addressed by the data ROOT directly (installer-side
77
+ callers have no workspace to resolve a store with). Identical file —
78
+ both entries serialize against each other."""
79
+ from aisc.adapters.data_root_store import file_lock
80
+ from aisc.domain.data_root import LOCKS_SUBDIR
81
+
82
+ lock_path = Path(str(data_root)).joinpath(*LOCKS_SUBDIR.split("/")) / (
83
+ MAINTENANCE_LOCK_NAME + ".lock"
84
+ )
85
+ with file_lock(lock_path, timeout, error_code=ERR_MAINTENANCE_LOCK_TIMEOUT):
86
+ yield
87
+
88
+
89
+ # Re-exported for callers that construct their own timeout errors.
90
+ __all__ = [
91
+ "MAINTENANCE_LOCK_NAME",
92
+ "MAINTENANCE_LOCK_TIMEOUT",
93
+ "ERR_MAINTENANCE_LOCK_TIMEOUT",
94
+ "ERR_LOCK_TIMEOUT",
95
+ "docker_maintenance_lock",
96
+ "maintenance_lock_path",
97
+ ]
@@ -0,0 +1,184 @@
1
+ """State file adapter — read/write ``.aisc/state.env`` for container discovery.
2
+
3
+ Provides atomic writes via temp-file + rename. Only allows known safe keys.
4
+ Preserves existing keys, comments, and blank lines where practical.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ import re
11
+ import tempfile
12
+ from pathlib import Path
13
+ from typing import Dict, List, Optional, Tuple
14
+
15
+ # Known safe keys — only these may be written to state.env.
16
+ # Container-name keys (CONTAINER_NAME, IMAGE) live in containers.json now;
17
+ # state.env retains only boolean flag keys.
18
+ _KNOWN_KEYS = frozenset({
19
+ "DO_RUN",
20
+ "PROXY_ENABLED",
21
+ })
22
+
23
+ # Regex for valid key names: uppercase alphanumeric + underscore, starts with letter
24
+ _VALID_KEY_RE = re.compile(r"^[A-Z][A-Z0-9_]*$")
25
+
26
+ # Shell metacharacters / injection characters rejected from all values
27
+ _SHELL_META_RE = re.compile(r'[\r\n\0`$\'";&|<>]')
28
+
29
+ # Boolean flags
30
+ _BOOL_VALUES = frozenset({"0", "1"})
31
+
32
+
33
+ def _validate_value(key: str, value: str) -> None:
34
+ """Validate *value* against field-specific rules plus shell-metacharacter ban.
35
+
36
+ Raises ValueError with a descriptive message on any violation.
37
+ """
38
+ if not value:
39
+ return
40
+
41
+ # --- shell metacharacter / whitespace / control check (all keys) ---
42
+ if _SHELL_META_RE.search(value) or " " in value or "\t" in value:
43
+ raise ValueError(
44
+ f"State value for '{key}' contains prohibited characters "
45
+ f"(shell metacharacters, whitespace, or control chars)"
46
+ )
47
+
48
+ # --- field-specific checks ---
49
+ if key in ("DO_RUN", "PROXY_ENABLED"):
50
+ if value not in _BOOL_VALUES:
51
+ raise ValueError(
52
+ f"{key} must be 0 or 1, got: {value!r}"
53
+ )
54
+
55
+
56
+ def _parse_state_file(path: Path) -> List[Tuple[str, Optional[str], Optional[str]]]:
57
+ """Parse state.env into (key, value, raw_comment) triples.
58
+
59
+ Returns a list of (key, value, comment) where:
60
+ - key: the KEY name (empty string for comment/blank lines)
61
+ - value: the value after '=' (None for comments)
62
+ - comment: full raw line text for comments/blanks (None for key=value lines)
63
+
64
+ Duplicate keys use deterministic **last-wins** semantics (consistent with
65
+ shell ``source`` / ``set -a`` behaviour).
66
+ """
67
+ entries: List[Tuple[str, Optional[str], Optional[str]]] = []
68
+ if not path.is_file():
69
+ return entries
70
+
71
+ with open(path, "r", encoding="utf-8", errors="replace") as f:
72
+ for line in f:
73
+ stripped = line.rstrip("\n\r")
74
+ # Comments and blank lines preserved as-is
75
+ if not stripped or stripped.startswith("#"):
76
+ entries.append(("", None, stripped))
77
+ continue
78
+ # Parse KEY=VALUE
79
+ if "=" in stripped:
80
+ key, _, value = stripped.partition("=")
81
+ key = key.strip()
82
+ if _VALID_KEY_RE.match(key):
83
+ # Last-wins: replace previous entry for same key
84
+ for i in range(len(entries) - 1, -1, -1):
85
+ if entries[i][0] == key:
86
+ entries[i] = (key, value, None)
87
+ break
88
+ else:
89
+ entries.append((key, value, None))
90
+ else:
91
+ # Malformed key — preserve as comment to avoid corruption
92
+ entries.append(("", None, stripped))
93
+ else:
94
+ # Line without '=' — treat as comment
95
+ entries.append(("", None, stripped))
96
+ return entries
97
+
98
+
99
+ def read_state_key(root: Path, key: str) -> Optional[str]:
100
+ """Read a single key from ``<root>/state.env`` (Stage 7: *root* is the
101
+ state directory itself — data-root ``workspaces/<hash>/runtime`` or the
102
+ legacy ``<workspace>/.aisc``; resolved by callers, never appended here).
103
+
104
+ Returns the value string, or ``None`` if the key or file is absent.
105
+ Duplicate keys resolve to last occurrence (shell-consistent).
106
+ """
107
+ state_path = root / "state.env"
108
+ entries = _parse_state_file(state_path)
109
+ # Walk backwards to find last occurrence (last-wins)
110
+ for entry_key, value, _comment in reversed(entries):
111
+ if entry_key == key:
112
+ return value
113
+ return None
114
+
115
+
116
+ def write_state_keys(root: Path, updates: Dict[str, str]) -> None:
117
+ """Atomically write key=value pairs into ``<root>/.aisc/state.env``.
118
+
119
+ Preserves existing keys and comments. Only writes keys that are in
120
+ ``_KNOWN_KEYS``. Raises ``ValueError`` for unknown keys or values
121
+ containing ``\\r``, ``\\n``, or NUL. Silently skips keys with empty values.
122
+
123
+ Uses temp-file + rename for atomicity.
124
+ Errors during write do not corrupt the existing file.
125
+ """
126
+ for k in updates:
127
+ if k not in _KNOWN_KEYS:
128
+ raise ValueError(
129
+ f"Unknown state key '{k}'. Allowed: {sorted(_KNOWN_KEYS)}"
130
+ )
131
+
132
+ # Validate values before touching filesystem
133
+ for k, v in updates.items():
134
+ _validate_value(k, v)
135
+
136
+ state_dir = root # Stage 7: root IS the state directory
137
+ state_dir.mkdir(parents=True, exist_ok=True)
138
+
139
+ state_path = state_dir / "state.env"
140
+
141
+ # Read existing entries
142
+ existing = _parse_state_file(state_path) if state_path.is_file() else []
143
+
144
+ # Build merged: last-wins for duplicate keys, update existing, keep others
145
+ new_entries: List[Tuple[str, Optional[str], Optional[str]]] = []
146
+ seen_keys: set = set()
147
+
148
+ for entry_key, value, comment in existing:
149
+ if entry_key and entry_key in updates:
150
+ new_val = updates[entry_key]
151
+ if new_val:
152
+ new_entries.append((entry_key, new_val, None))
153
+ else:
154
+ new_entries.append((entry_key, value, None))
155
+ seen_keys.add(entry_key)
156
+ else:
157
+ new_entries.append((entry_key, value, comment))
158
+
159
+ # Append any new keys not already present
160
+ for k, v in updates.items():
161
+ if k not in seen_keys and v:
162
+ new_entries.append((k, v, None))
163
+
164
+ # Write atomically via temp file
165
+ fd, tmp_path = tempfile.mkstemp(
166
+ prefix=".state_", dir=str(state_dir), text=True
167
+ )
168
+ try:
169
+ with os.fdopen(fd, "w", encoding="utf-8") as f:
170
+ for entry_key, value, comment in new_entries:
171
+ if comment is not None:
172
+ f.write(comment + "\n")
173
+ elif entry_key:
174
+ f.write(f"{entry_key}={value}\n")
175
+ f.flush()
176
+ os.fsync(f.fileno())
177
+ os.replace(tmp_path, str(state_path))
178
+ except Exception:
179
+ # Clean up temp file on failure
180
+ try:
181
+ os.unlink(tmp_path)
182
+ except OSError:
183
+ pass
184
+ raise
@@ -0,0 +1,101 @@
1
+ """System / process adapter — narrow interface for command execution."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import subprocess
6
+ from typing import Protocol, List, Optional, runtime_checkable
7
+
8
+ from aisc.domain.models import ProcessResult
9
+
10
+
11
+ @runtime_checkable
12
+ class ProcessRunner(Protocol):
13
+ """Protocol for running subprocess commands (testable via injection)."""
14
+
15
+ def run(
16
+ self,
17
+ argv: List[str],
18
+ timeout: Optional[float] = None,
19
+ ) -> ProcessResult:
20
+ """Execute *argv* and return a ``ProcessResult``."""
21
+ ...
22
+
23
+
24
+ class RealProcessRunner:
25
+ """Real subprocess runner — shell=False, text capture, timeout support.
26
+
27
+ Uses ``encoding='utf-8'``, ``errors='replace'``.
28
+ Catches ``FileNotFoundError`` → ``command_not_found``.
29
+ Catches generic ``OSError`` → ``command_not_found`` (e.g. permission,
30
+ broken symlink).
31
+ ``subprocess.TimeoutExpired`` → ``timed_out``.
32
+ """
33
+
34
+ def run(
35
+ self,
36
+ argv: List[str],
37
+ timeout: Optional[float] = None,
38
+ ) -> ProcessResult:
39
+ """Execute *argv* in a subprocess."""
40
+ try:
41
+ proc = subprocess.run(
42
+ argv,
43
+ capture_output=True,
44
+ encoding="utf-8",
45
+ errors="replace",
46
+ timeout=timeout,
47
+ )
48
+ return ProcessResult(
49
+ stdout=proc.stdout or "",
50
+ stderr=proc.stderr or "",
51
+ exit_code=proc.returncode,
52
+ )
53
+ except FileNotFoundError:
54
+ return ProcessResult(
55
+ stdout="",
56
+ stderr=f"command not found: {argv[0] if argv else ''}",
57
+ exit_code=-1,
58
+ command_not_found=True,
59
+ )
60
+ except subprocess.TimeoutExpired:
61
+ return ProcessResult(
62
+ stdout="",
63
+ stderr="command timed out",
64
+ exit_code=-1,
65
+ timed_out=True,
66
+ )
67
+ except OSError as exc:
68
+ return ProcessResult(
69
+ stdout="",
70
+ stderr=f"command error: {exc}",
71
+ exit_code=-1,
72
+ command_not_found=True,
73
+ )
74
+
75
+ def run_streaming(
76
+ self,
77
+ argv: List[str],
78
+ timeout: Optional[float] = None,
79
+ ) -> ProcessResult:
80
+ """Execute an interactive command with inherited terminal streams."""
81
+ try:
82
+ proc = subprocess.run(argv, timeout=timeout)
83
+ return ProcessResult(exit_code=proc.returncode)
84
+ except FileNotFoundError:
85
+ return ProcessResult(
86
+ stderr=f"command not found: {argv[0] if argv else ''}",
87
+ exit_code=-1,
88
+ command_not_found=True,
89
+ )
90
+ except subprocess.TimeoutExpired:
91
+ return ProcessResult(
92
+ stderr="command timed out",
93
+ exit_code=-1,
94
+ timed_out=True,
95
+ )
96
+ except OSError as exc:
97
+ return ProcessResult(
98
+ stderr=f"command error: {exc}",
99
+ exit_code=-1,
100
+ command_not_found=True,
101
+ )