amv-diffusion 2.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 (39) hide show
  1. amv_diffusion-2.1.0.dist-info/METADATA +180 -0
  2. amv_diffusion-2.1.0.dist-info/RECORD +39 -0
  3. amv_diffusion-2.1.0.dist-info/WHEEL +5 -0
  4. amv_diffusion-2.1.0.dist-info/entry_points.txt +7 -0
  5. amv_diffusion-2.1.0.dist-info/licenses/LICENSE +21 -0
  6. amv_diffusion-2.1.0.dist-info/top_level.txt +1 -0
  7. termux_diffusion/__init__.py +185 -0
  8. termux_diffusion/__main__.py +7 -0
  9. termux_diffusion/_version.py +3 -0
  10. termux_diffusion/adapter.py +41 -0
  11. termux_diffusion/canvas.py +364 -0
  12. termux_diffusion/cli.py +492 -0
  13. termux_diffusion/cluster.py +340 -0
  14. termux_diffusion/control/__init__.py +5 -0
  15. termux_diffusion/control/component.py +309 -0
  16. termux_diffusion/control/status.py +15 -0
  17. termux_diffusion/core.py +2936 -0
  18. termux_diffusion/data/bootstrap-manifest.json +15 -0
  19. termux_diffusion/data/prompt_cache/108a7149c4e70550.json +10 -0
  20. termux_diffusion/data/release-public-keys.json +10 -0
  21. termux_diffusion/data/validated-vulkan-profiles.json +298 -0
  22. termux_diffusion/detailer.py +283 -0
  23. termux_diffusion/doctor.py +128 -0
  24. termux_diffusion/downloader.py +111 -0
  25. termux_diffusion/engine.py +226 -0
  26. termux_diffusion/exceptions.py +158 -0
  27. termux_diffusion/hardware.py +985 -0
  28. termux_diffusion/hub.py +686 -0
  29. termux_diffusion/installer.py +499 -0
  30. termux_diffusion/locking.py +134 -0
  31. termux_diffusion/manifest.py +128 -0
  32. termux_diffusion/npu.py +267 -0
  33. termux_diffusion/platform.py +533 -0
  34. termux_diffusion/purger.py +145 -0
  35. termux_diffusion/py.typed +1 -0
  36. termux_diffusion/safety.py +150 -0
  37. termux_diffusion/selftest.py +178 -0
  38. termux_diffusion/web.py +3370 -0
  39. termux_diffusion/workflow.py +1345 -0
@@ -0,0 +1,340 @@
1
+ # Copyright (c) 2026 Uno Kim / AMEVA Foundation. All rights reserved.
2
+ # SPDX-License-Identifier: Apache-2.0 OR Commercial-License
3
+ """
4
+ AMEVA Unified Distributed RPC Cluster Orchestration Utility.
5
+ Strict Protocol: Zero-Silent-Fallback & Zero-Deception ABI.
6
+ Component: [DIFFUSION-CLUSTER]
7
+ """
8
+
9
+ import socket
10
+ import logging
11
+ import time
12
+ from typing import List, Optional, Union, Dict, Any, Callable, Set, Tuple
13
+
14
+ from termux_diffusion.exceptions import (
15
+ ClusterConnectionError,
16
+ ClusterConfigurationError,
17
+ ClusterLicenseRequiredError,
18
+ )
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+
23
+ def check_cluster_license(license_key: Optional[str] = None) -> None:
24
+ """Enforces exclusive AMEVA-Cluster runtime license requirement.
25
+ Fail-Fast: Raises ClusterLicenseRequiredError if ameva_cluster is not installed
26
+ or license verification fails.
27
+ """
28
+ try:
29
+ from ameva_cluster.guard import verify_cluster_license
30
+ except ImportError:
31
+ raise ClusterLicenseRequiredError()
32
+
33
+ if not verify_cluster_license(license_key):
34
+ raise ClusterLicenseRequiredError(
35
+ "\n================================================================================\n"
36
+ "[AMEVA-CLUSTER] CLUSTER LICENSE INVALID (E403)\n"
37
+ "================================================================================\n"
38
+ "The provided AMEVA Cluster license token is invalid or expired.\n"
39
+ "Please check your AMEVA_CLUSTER_LICENSE environment variable.\n"
40
+ "================================================================================"
41
+ )
42
+
43
+
44
+ def setup_cluster_guard_tunnels(
45
+ servers: List[str],
46
+ secret_key: Optional[str] = None
47
+ ) -> Tuple[List[str], List[Any]]:
48
+ """Establishes authenticated loopback MasterTunnels for remote RPC nodes.
49
+
50
+ Transforms remote endpoints into local authenticated tunnel endpoints (127.0.0.1:PORT)
51
+ which transparently inject the AMEVA Guard cryptographic handshake.
52
+
53
+ Returns:
54
+ Tuple of (tunnel_server_endpoints, active_tunnel_instances)
55
+ """
56
+ if not servers:
57
+ return [], []
58
+
59
+ try:
60
+ from ameva_cluster.guard import MasterTunnel
61
+ except ImportError:
62
+ raise ClusterLicenseRequiredError()
63
+
64
+ tunnel_endpoints: List[str] = []
65
+ active_tunnels: List[Any] = []
66
+
67
+ for endpoint in servers:
68
+ host, port_str = endpoint.split(":")
69
+ port = int(port_str)
70
+
71
+ # Local loopback addresses do not require an extra proxy hop
72
+ if host in ("127.0.0.1", "localhost", "::1"):
73
+ tunnel_endpoints.append(endpoint)
74
+ continue
75
+
76
+ tunnel = MasterTunnel(remote_host=host, remote_port=port, secret_key=secret_key)
77
+ tunnel.start()
78
+ active_tunnels.append(tunnel)
79
+ local_ep = f"127.0.0.1:{tunnel.local_port}"
80
+ tunnel_endpoints.append(local_ep)
81
+ logger.info(
82
+ "[AMEVA-DIFFUSION-CLUSTER] Authenticated MasterTunnel active: %s -> %s",
83
+ local_ep,
84
+ endpoint,
85
+ )
86
+
87
+ return tunnel_endpoints, active_tunnels
88
+
89
+
90
+
91
+ def parse_cluster_rpc_spec(rpc_input: Optional[Union[str, List[str]]]) -> List[str]:
92
+ """Parse and normalize distributed RPC server endpoints.
93
+
94
+ Supports comma-separated strings or list of host:port strings.
95
+ Validates port ranges and host syntax strictly without silent normalization.
96
+ """
97
+ if not rpc_input:
98
+ return []
99
+
100
+ if isinstance(rpc_input, str):
101
+ raw_servers = [s.strip() for s in rpc_input.split(",") if s.strip()]
102
+ elif isinstance(rpc_input, (list, tuple)):
103
+ raw_servers = [str(s).strip() for s in rpc_input if str(s).strip()]
104
+ else:
105
+ raise ClusterConfigurationError(
106
+ f"Invalid cluster_rpc_servers specification type: {type(rpc_input).__name__}. "
107
+ f"Expected comma-separated string or list of endpoints."
108
+ )
109
+
110
+ parsed_servers: List[str] = []
111
+ for entry in raw_servers:
112
+ if ":" not in entry:
113
+ raise ClusterConfigurationError(
114
+ f"Invalid RPC endpoint format '{entry}'. Expected 'host:port' format (e.g. '192.0.2.11:50052')."
115
+ )
116
+ parts = entry.split(":")
117
+ if len(parts) != 2:
118
+ raise ClusterConfigurationError(
119
+ f"Malformed RPC endpoint '{entry}'. Exactly one colon separator required."
120
+ )
121
+ host, port_str = parts[0].strip(), parts[1].strip()
122
+ if not host:
123
+ raise ClusterConfigurationError(f"Empty hostname/IP in RPC endpoint '{entry}'.")
124
+ try:
125
+ port = int(port_str)
126
+ if port < 1 or port > 65535:
127
+ raise ValueError()
128
+ except ValueError:
129
+ raise ClusterConfigurationError(
130
+ f"Invalid port number '{port_str}' in RPC endpoint '{entry}'. Must be integer 1..65535."
131
+ )
132
+ parsed_servers.append(f"{host}:{port}")
133
+
134
+ return parsed_servers
135
+
136
+
137
+ def ensure_wakelock() -> bool:
138
+ """Acquires Termux system-level wakelock to prevent Android kernel Doze deep-sleep."""
139
+ wakelock_bin = "/data/data/com.termux/files/usr/bin/termux-wake-lock"
140
+ import os
141
+ import subprocess
142
+ if os.path.exists(wakelock_bin):
143
+ try:
144
+ res = subprocess.run([wakelock_bin], capture_output=True, timeout=2.0)
145
+ if res.returncode == 0:
146
+ logger.info("[AMEVA-CLUSTER] Termux WakeLock acquired successfully.")
147
+ return True
148
+ except Exception as exc:
149
+ logger.warning("[AMEVA-CLUSTER] Failed to acquire termux-wake-lock: %s", exc)
150
+ return False
151
+
152
+
153
+ def resolve_auto_tensor_split(
154
+ rpc_spec: Optional[Union[str, List[str]]],
155
+ tensor_split: Optional[str] = None,
156
+ min_guardband_mb: int = 400
157
+ ) -> Tuple[List[str], Optional[str]]:
158
+ """Automatically compute optimal tensor split when tensor_split == 'auto'.
159
+ Preserves explicit tensor_split specifications without alteration.
160
+ """
161
+ servers = parse_cluster_rpc_spec(rpc_spec)
162
+ if not servers:
163
+ return [], tensor_split
164
+
165
+ if tensor_split and str(tensor_split).strip().lower() == "auto":
166
+ try:
167
+ from termux_ai_orchestrator.auto_split import resolve_auto_tensor_split as orch_resolve
168
+ return orch_resolve(servers, min_guardband_mb=min_guardband_mb)
169
+ except Exception as exc:
170
+ logger.warning("[AMEVA-CLUSTER] Auto-TS fallback to equal distribution: %s", exc)
171
+ # Safe equal distribution fallback
172
+ n_nodes = len(servers) + 1
173
+ equal_pct = int(100 // n_nodes)
174
+ remainder = 100 - (equal_pct * n_nodes)
175
+ splits = [equal_pct + remainder] + [equal_pct] * len(servers)
176
+ return servers, ",".join(str(s) for s in splits)
177
+
178
+ return servers, tensor_split
179
+
180
+
181
+ def verify_rpc_cluster_nodes(servers: List[str], timeout: float = 3.0) -> None:
182
+ """Strictly verify TCP reachability of each RPC server prior to initiating inference graph.
183
+
184
+ Zero-Silent-Fallback: If ANY specified RPC worker is unreachable, this function raises
185
+ ClusterConnectionError immediately to prevent silent local CPU/GPU fallback degradation.
186
+ """
187
+ if not servers:
188
+ return
189
+
190
+ failed_nodes = []
191
+ for endpoint in servers:
192
+ host, port_str = endpoint.split(":")
193
+ port = int(port_str)
194
+ try:
195
+ with socket.create_connection((host, port), timeout=timeout):
196
+ logger.info("[AMEVA-CLUSTER] RPC Worker verified reachable: %s", endpoint)
197
+ except (socket.timeout, ConnectionRefusedError, OSError) as exc:
198
+ failed_nodes.append((endpoint, str(exc)))
199
+
200
+ if failed_nodes:
201
+ err_details = "\n".join([f" - {ep}: {err}" for ep, err in failed_nodes])
202
+ raise ClusterConnectionError(
203
+ f"Distributed RPC Cluster Pre-Flight Check FAILED for {len(failed_nodes)}/{len(servers)} worker(s):\n"
204
+ f"{err_details}\n"
205
+ f"Zero-Silent-Fallback Violation Prevented: Inference terminated to avoid silent execution degradation."
206
+ )
207
+
208
+
209
+ def verify_rpc_cluster_health(servers: List[str], timeout: float = 3.0) -> Dict[str, Any]:
210
+ """Inspect and report cluster connectivity status without throwing, returning granular diagnostics."""
211
+ status: Dict[str, Any] = {"all_healthy": True, "nodes": {}}
212
+ for endpoint in servers:
213
+ host, port_str = endpoint.split(":")
214
+ port = int(port_str)
215
+ try:
216
+ with socket.create_connection((host, port), timeout=timeout):
217
+ status["nodes"][endpoint] = {"status": "ONLINE", "error": None}
218
+ except Exception as exc:
219
+ status["all_healthy"] = False
220
+ status["nodes"][endpoint] = {"status": "OFFLINE", "error": str(exc)}
221
+ return status
222
+
223
+
224
+ class ClusterOptionForwarder:
225
+ """Transparent option collector and pass-through forwarder for sub-engines.
226
+
227
+ Zero-Deception & Zero-Silent-Drop Principle:
228
+ Preserves all CLI arguments, keyword options, and custom engine parameters
229
+ without silent truncation, enabling seamless pass-through to underlying binaries
230
+ (sd-cli, llama-cli, whisper-cli, etc.) across distributed cluster nodes.
231
+ """
232
+
233
+ @staticmethod
234
+ def to_cli_flag(key: str) -> str:
235
+ """Convert snake_case Python identifier to --kebab-case CLI argument flag."""
236
+ clean_key = key.strip().replace("_", "-")
237
+ if not clean_key.startswith("-"):
238
+ clean_key = f"--{clean_key}"
239
+ return clean_key
240
+
241
+ @classmethod
242
+ def normalize_forward_args(
243
+ cls,
244
+ options: Dict[str, Any],
245
+ extra_args: Optional[List[str]] = None,
246
+ exclude_keys: Optional[Union[List[str], set]] = None,
247
+ ) -> List[str]:
248
+ """Convert a dictionary of engine options into an argv argument list,
249
+ losslessly preserving raw extra arguments at the end.
250
+ """
251
+ exclude = set(exclude_keys or [])
252
+ cmd_args: List[str] = []
253
+
254
+ for k, v in options.items():
255
+ if k in exclude or v is None:
256
+ continue
257
+
258
+ flag = cls.to_cli_flag(k)
259
+
260
+ if isinstance(v, bool):
261
+ if v:
262
+ cmd_args.append(flag)
263
+ elif isinstance(v, (list, tuple)):
264
+ for item in v:
265
+ cmd_args.extend([flag, str(item)])
266
+ else:
267
+ cmd_args.extend([flag, str(v)])
268
+
269
+ if extra_args:
270
+ for arg in extra_args:
271
+ if str(arg).strip():
272
+ cmd_args.append(str(arg).strip())
273
+
274
+ return cmd_args
275
+
276
+ @classmethod
277
+ def merge_pass_through_args(
278
+ cls,
279
+ base_cmd: List[str],
280
+ extra_args: Optional[List[str]] = None,
281
+ overrides: Optional[Dict[str, Any]] = None,
282
+ ) -> List[str]:
283
+ """Merge base command with pass-through extra arguments and key-value overrides."""
284
+ merged = list(base_cmd)
285
+ if overrides:
286
+ override_args = cls.normalize_forward_args(overrides)
287
+ merged.extend(override_args)
288
+ if extra_args:
289
+ for a in extra_args:
290
+ if str(a).strip():
291
+ merged.append(str(a).strip())
292
+ return merged
293
+
294
+
295
+ class SequentialClusterPipeline:
296
+ """Two-Stage Memory Isolation Pipeline for Mobile On-Device AI Clustering.
297
+
298
+ Architecture (Text-Encode-Then-Kill -> Disaggregated DiT Inference):
299
+ - Stage 1 (Text Encoding Phase): Spawns isolated subprocess for LLM/CLIP text encoding.
300
+ Generates prompt embeddings/cache, then immediately terminates and kills the process
301
+ to return 100% of the allocated memory (~1.7GB) to the Android kernel.
302
+ - Stage 2 (Disaggregated DiT Sampling Phase): Launches diffusion backbone inference
303
+ in clean RAM environment, losslessly forwarding all distributed RPC cluster flags
304
+ (--rpc-servers, -ts, --schedule, --flow-shift, etc.) to remote workers.
305
+ """
306
+
307
+ def __init__(self, forwarder: Optional[ClusterOptionForwarder] = None):
308
+ self.forwarder = forwarder or ClusterOptionForwarder()
309
+
310
+ def execute_stages(
311
+ self,
312
+ prompt: str,
313
+ stage1_fn: Callable[[], Any],
314
+ stage2_fn: Callable[[], Any],
315
+ ) -> Any:
316
+ """Execute Stage 1 and Stage 2 sequentially with strict fail-fast enforcement."""
317
+ ensure_wakelock()
318
+ logger.info("[AMEVA-CLUSTER] Initiating Sequential 2-Stage Memory Isolation Pipeline...")
319
+ t0 = time.time()
320
+
321
+ # Stage 1: Text Encoding with memory recovery guarantee
322
+ logger.info("[AMEVA-CLUSTER] [Stage 1/2] Executing Isolated Text Encoding Phase...")
323
+ try:
324
+ stage1_result = stage1_fn()
325
+ logger.info("[AMEVA-CLUSTER] [Stage 1/2] Text Encoding completed. Reclaiming memory.")
326
+ except Exception as e:
327
+ logger.error("[AMEVA-CLUSTER] [Stage 1/2] Text Encoding failed: %s", e)
328
+ raise
329
+
330
+ # Stage 2: Disaggregated DiT Sampling & Decoding
331
+ logger.info("[AMEVA-CLUSTER] [Stage 2/2] Launching Distributed DiT Inference in clean RAM...")
332
+ try:
333
+ stage2_result = stage2_fn()
334
+ elapsed = time.time() - t0
335
+ logger.info("[AMEVA-CLUSTER] 2-Stage Sequential Inference succeeded in %.2fs", elapsed)
336
+ return stage2_result
337
+ except Exception as e:
338
+ logger.error("[AMEVA-CLUSTER] [Stage 2/2] Distributed DiT Inference failed: %s", e)
339
+ raise
340
+
@@ -0,0 +1,5 @@
1
+ # Copyright (c) 2026 Uno Kim / AMEVA Foundation. All rights reserved.
2
+ # SPDX-License-Identifier: Apache-2.0 OR Commercial-License
3
+ """termux_diffusion.control — AMEVA Component Protocol v1."""
4
+ from .component import DiffusionControl
5
+ __all__ = ["DiffusionControl"]
@@ -0,0 +1,309 @@
1
+ # Copyright (c) 2026 Uno Kim / AMEVA Foundation. All rights reserved.
2
+ # SPDX-License-Identifier: Apache-2.0 OR Commercial-License
3
+ """
4
+ termux_diffusion.control.component
5
+ AMEVA Component Protocol v1 — DiffusionControl
6
+
7
+ model_id는 파일명이 아닌 Pipeline Manifest를 가리킵니다.
8
+ (UNet + VAE + Text Encoder + Tokenizer + Scheduler Config)
9
+ 기존 installer.run_doctor() + hub.py를 Adapter로 연결합니다.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import os
14
+ import time
15
+ from pathlib import Path
16
+
17
+ from ameva_component import (
18
+ ActivationLock, ComponentInfo, ComponentStateFile,
19
+ ControlMode, InstanceRegistry, InstanceState, InstanceStatus,
20
+ ModelRegistry, ModelState, ModelNotFound, ModelLoadFailed,
21
+ OperationNotSupported, now_timestamps, log_stderr, PROTOCOL_COMPONENT,
22
+ )
23
+ from ameva_component.control import ComponentControl
24
+
25
+
26
+ class DiffusionControl(ComponentControl):
27
+ """
28
+ termux-diffusion ComponentControl.
29
+
30
+ model_id = Pipeline Manifest ID (단일 파일명 아님).
31
+ 기존 installer.run_doctor(), hub.DEFAULT_PRESETS를 Adapter로 연결.
32
+ """
33
+
34
+ COMPONENT_ID = "termux-diffusion"
35
+ COMPONENT_TYPE = "diffusion"
36
+ CAPABILITIES = ("image.generate",)
37
+
38
+ DEFAULT_MODELS_DIR = Path.home() / ".cache" / "termux-diffusion" / "models"
39
+
40
+ def __init__(self, models_dir: Path | None = None) -> None:
41
+ self._models_dir = models_dir or self.DEFAULT_MODELS_DIR
42
+ self._state_file = ComponentStateFile(self.COMPONENT_ID)
43
+ self._model_reg = ModelRegistry(self.COMPONENT_ID)
44
+ self._inst_reg = InstanceRegistry(self.COMPONENT_ID)
45
+ self._act_lock = ActivationLock()
46
+ # Phase 4: Heartbeat Writer
47
+ from termux_diffusion.control.status import DiffusionStatusWriter
48
+ self._heartbeat = DiffusionStatusWriter(self)
49
+
50
+ def _get_version(self) -> str:
51
+ try:
52
+ from termux_diffusion import __version__; return __version__
53
+ except Exception: return "1.3.2"
54
+
55
+ def component_info(self) -> dict:
56
+ info = ComponentInfo(
57
+ protocol=PROTOCOL_COMPONENT, component_id=self.COMPONENT_ID,
58
+ component_type=self.COMPONENT_TYPE, version=self._get_version(),
59
+ capabilities=self.CAPABILITIES,
60
+ )
61
+ info.validate()
62
+ return info.to_dict()
63
+
64
+ def doctor_lite(self) -> dict:
65
+ """
66
+ 경량 진단.
67
+ 샘플 추론 금지, 엔진 바이너리 존재 여부 + 상태파일만.
68
+ """
69
+ ts = now_timestamps()
70
+ state_data = self._state_file.read()
71
+ stale = self._state_file.is_stale(threshold_ms=30_000)
72
+ pid_info = self._check_pid()
73
+ pid = pid_info.get("pid")
74
+ pid_alive = pid_info.get("alive")
75
+ instances = self._inst_reg.list_all()
76
+ hot = [i for i in instances if i.state == InstanceState.HOT]
77
+
78
+ # 엔진 바이너리 존재 여부 (실행 금지)
79
+ engine_present = self._check_engine_binary()
80
+
81
+ ready = engine_present
82
+ degraded = stale or not engine_present or (pid_alive is not True)
83
+
84
+ proc_dict: dict[str, Any] = {
85
+ "running": pid_alive,
86
+ "pid": pid,
87
+ "verified": pid_info.get("verified", False),
88
+ }
89
+ if "inspection_error" in pid_info:
90
+ proc_dict["inspection_error"] = pid_info["inspection_error"]
91
+ if "reason" in pid_info:
92
+ proc_dict["reason"] = pid_info["reason"]
93
+
94
+ return {
95
+ "protocol": "ameva-component-status/1",
96
+ "component_id": self.COMPONENT_ID, "component_type": self.COMPONENT_TYPE,
97
+ "version": self._get_version(), "ready": ready, "degraded": degraded,
98
+ **ts,
99
+ "process": proc_dict,
100
+ "capabilities": list(self.CAPABILITIES),
101
+ "active_models": [i.model_id for i in hot],
102
+ "engine": {"present": engine_present},
103
+ "instances": [{"instance_id": i.instance_id, "model_id": i.model_id,
104
+ "state": i.state.value, "active_jobs": i.active_jobs} for i in instances],
105
+ "errors": [state_data.get("last_error")] if state_data and state_data.get("last_error") else [],
106
+ "state_file": {"path": str(self._state_file.path), "stale": stale,
107
+ "updated_at": state_data.get("updated_at") if state_data else None},
108
+ }
109
+
110
+ # DEFAULT_PID_FILE: COMPONENT_ID 기반 표준 경로
111
+ DEFAULT_PID_FILE: Path = Path.home() / ".local" / "run" / "termux-diffusion.pid"
112
+
113
+ def _check_pid(self) -> dict[str, Any]:
114
+ """BLOCKER 1: PID 파일에서 sd-cli 프로세스 생존 여부 확인.
115
+ PermissionError/OSError 발생 시 alive=None, verified=False, inspection_error 반환."""
116
+ import logging
117
+ _log = logging.getLogger(__name__)
118
+
119
+ if self.DEFAULT_PID_FILE.exists():
120
+ try:
121
+ raw = self.DEFAULT_PID_FILE.read_text().strip()
122
+ pid = int(raw)
123
+ except (ValueError, OSError) as parse_err:
124
+ _log.warning("[diffusion] PID file parse/read error: %s", parse_err)
125
+ return {
126
+ "pid": None,
127
+ "alive": None,
128
+ "verified": False,
129
+ "inspection_error": {
130
+ "code": "PID_PARSE_ERROR",
131
+ "message": str(parse_err),
132
+ },
133
+ }
134
+
135
+ try:
136
+ os.kill(pid, 0)
137
+ return {"pid": pid, "alive": True, "verified": True}
138
+ except ProcessLookupError:
139
+ return {
140
+ "pid": pid,
141
+ "alive": False,
142
+ "verified": True,
143
+ "reason": "process_lookup_failed",
144
+ }
145
+ except PermissionError as perm_err:
146
+ _log.warning("[diffusion] PID %s alive check PermissionError: %s", pid, perm_err)
147
+ return {
148
+ "pid": pid,
149
+ "alive": None,
150
+ "verified": False,
151
+ "inspection_error": {
152
+ "code": "PROCESS_INSPECTION_PERMISSION_DENIED",
153
+ "message": str(perm_err),
154
+ },
155
+ }
156
+ except OSError as os_err:
157
+ _log.warning("[diffusion] PID %s alive check OSError: %s", pid, os_err)
158
+ return {
159
+ "pid": pid,
160
+ "alive": None,
161
+ "verified": False,
162
+ "inspection_error": {
163
+ "code": "PROCESS_INSPECTION_OS_ERROR",
164
+ "message": str(os_err),
165
+ },
166
+ }
167
+ return {
168
+ "pid": None,
169
+ "alive": False,
170
+ "verified": True,
171
+ "reason": "pid_file_missing",
172
+ }
173
+
174
+
175
+
176
+ def _check_engine_binary(self) -> bool:
177
+ """엔진 바이너리 존재 여부만 확인 — 실행 금지."""
178
+ try:
179
+ from termux_diffusion.installer import provision_engine
180
+ return True # 모듈 import 성공 = 설치 가능 상태
181
+ except ImportError:
182
+ return False # Allowed: engine not installed -> binary check fails-closed.
183
+
184
+ def doctor_full(self) -> dict:
185
+ """기존 installer.run_doctor() 전체 실행."""
186
+ lite = self.doctor_lite()
187
+ try:
188
+ from termux_diffusion.installer import run_doctor
189
+ result = run_doctor()
190
+ lite["full_doctor"] = result if isinstance(result, dict) else {"output": str(result)}
191
+ except Exception as e:
192
+ lite["doctor_error"] = str(e)
193
+ lite["doctor_level"] = "full"
194
+ return lite
195
+
196
+ def list_models(self) -> dict:
197
+ """Pipeline Manifest 기반 모델 목록 + hub.DEFAULT_PRESETS."""
198
+ reg_map = {m["model_id"]: m for m in self._model_reg.list_all()}
199
+ try:
200
+ from termux_diffusion.hub import DEFAULT_PRESETS, list_presets
201
+ for preset_id, preset_info in DEFAULT_PRESETS.items():
202
+ if preset_id not in reg_map:
203
+ reg_map[preset_id] = {
204
+ "model_id": preset_id,
205
+ "state": "missing",
206
+ "format": "pipeline",
207
+ "note": "Available preset — not yet downloaded",
208
+ "description": str(preset_info),
209
+ "verified_at": None,
210
+ }
211
+ except Exception as e:
212
+ log_stderr(f"[diffusion] hub.DEFAULT_PRESETS load failed: {e}")
213
+ return {"models": list(reg_map.values()), "total": len(reg_map),
214
+ "models_dir": str(self._models_dir)}
215
+
216
+ def model_status(self, model_id: str | None = None) -> dict:
217
+ if model_id:
218
+ rec = self._model_reg.get(model_id)
219
+ if rec is None: raise ModelNotFound(model_id)
220
+ return {"model": rec}
221
+ return self.list_models()
222
+
223
+ def install_model(self, request: dict) -> dict:
224
+ """Pipeline Manifest 기반 설치 — 모든 구성 파일 Hash 검증."""
225
+ from ameva_component import ModelInstaller
226
+ url = request.get("url", ""); filename = request.get("filename", "")
227
+ sha256 = request.get("sha256", "")
228
+ expected_bytes = int(request.get("expected_bytes", 0))
229
+ model_id = request.get("model_id") or Path(filename).stem
230
+ self._models_dir.mkdir(parents=True, exist_ok=True)
231
+ installer = ModelInstaller(self.COMPONENT_ID, self._models_dir, self._model_reg)
232
+ return installer.install(url=url, filename=filename, sha256=sha256,
233
+ expected_bytes=expected_bytes, model_id=model_id,
234
+ after_download=self._verify_pipeline_file)
235
+
236
+ def _verify_pipeline_file(self, path: Path) -> None:
237
+ """Pipeline 구성 파일 기본 형식 확인 — 추론 실행 금지."""
238
+ valid = {".safetensors", ".bin", ".json", ".onnx", ".pt"}
239
+ if path.suffix.lower() not in valid:
240
+ raise ValueError(f"Unexpected pipeline file format: '{path.suffix}'")
241
+
242
+ async def activate_model(self, request: dict) -> dict:
243
+ model_id = request.get("model_id", "")
244
+ rec = self._model_reg.get(model_id)
245
+ if rec is None: raise ModelNotFound(model_id)
246
+ if ModelState.from_str(rec.get("state", "missing")) not in (ModelState.INSTALLED, ModelState.INACTIVE):
247
+ raise ModelLoadFailed(model_id, f"Pipeline state is '{rec.get('state')}'")
248
+ with self._act_lock.acquire(timeout=60.0):
249
+ self._model_reg.set_state(model_id, ModelState.ACTIVE)
250
+ self._write_state()
251
+ return {"activated": True, "model_id": model_id,
252
+ "rollback": {"attempted": False, "succeeded": False}}
253
+
254
+ async def deactivate_model(self, request: dict) -> dict:
255
+ model_id = request.get("model_id", "")
256
+ self._model_reg.set_state(model_id, ModelState.INACTIVE)
257
+ self._write_state()
258
+ return {"deactivated": True, "model_id": model_id}
259
+
260
+ def list_instances(self) -> dict:
261
+ instances = self._inst_reg.list_all()
262
+ return {"instances": [i.to_dict() for i in instances], "total": len(instances)}
263
+
264
+ async def start_instance(self, request: dict) -> dict:
265
+ model_id = request.get("model_id", "")
266
+ instance_id = request.get("instance_id") or f"diffusion-worker-{int(time.time())}"
267
+ inst = InstanceStatus(
268
+ instance_id=instance_id, component_id=self.COMPONENT_ID,
269
+ model_id=model_id, state=InstanceState.HOT,
270
+ active_jobs=0, queue_depth=0, max_concurrency=1,
271
+ backend="cpu", started_at=time.time(), last_heartbeat=time.time(),
272
+ last_error=None, control_mode=ControlMode.IN_PROCESS,
273
+ )
274
+ self._inst_reg.register(inst)
275
+ self._write_state()
276
+ # Phase 4: Heartbeat 시작 (Worker 시작 트리거)
277
+ self._heartbeat.start()
278
+ return {"instance_id": instance_id, "state": InstanceState.HOT.value}
279
+
280
+ async def drain_instance(self, instance_id: str) -> dict:
281
+ from ameva_component import InstanceNotFound
282
+ if not self._inst_reg.get(instance_id): raise InstanceNotFound(instance_id)
283
+ self._inst_reg.update_state(instance_id, InstanceState.DRAINING)
284
+ return {"instance_id": instance_id, "state": InstanceState.DRAINING.value}
285
+
286
+ async def stop_instance(self, instance_id: str) -> dict:
287
+ from ameva_component import InstanceNotFound
288
+ if not self._inst_reg.get(instance_id): raise InstanceNotFound(instance_id)
289
+ self._inst_reg.update_state(instance_id, InstanceState.STOPPED)
290
+ self._inst_reg.remove(instance_id)
291
+ # Phase 4: Heartbeat 중단 (정상 종료 트리거)
292
+ remaining = self._inst_reg.list_all()
293
+ if not remaining:
294
+ self._heartbeat.stop()
295
+ else:
296
+ self._write_state()
297
+ return {"instance_id": instance_id, "state": InstanceState.STOPPED.value}
298
+
299
+ def _write_state(self, *, ready: bool | None = None, last_error: str | None = None) -> None:
300
+ ts = now_timestamps()
301
+ hot = [i for i in self._inst_reg.list_all() if i.state == InstanceState.HOT]
302
+ _, pid_alive = self._check_pid()
303
+ _ready = self._check_engine_binary() if ready is None else ready
304
+ self._state_file.write({
305
+ "protocol": "ameva-component-status/1", "component_id": self.COMPONENT_ID,
306
+ "component_type": self.COMPONENT_TYPE, "version": self._get_version(),
307
+ "ready": _ready, "degraded": not _ready, **ts,
308
+ "active_models": [i.model_id for i in hot], "last_error": last_error,
309
+ })
@@ -0,0 +1,15 @@
1
+ # Copyright (c) 2026 Uno Kim / AMEVA Foundation. All rights reserved.
2
+ # SPDX-License-Identifier: Apache-2.0 OR Commercial-License
3
+ """
4
+ termux_diffusion.control.status — Diffusion 상태 파일 Writer + Heartbeat
5
+ """
6
+ from __future__ import annotations
7
+ from typing import Any
8
+ from ameva_component.heartbeat import HeartbeatWriter
9
+
10
+
11
+ class DiffusionStatusWriter(HeartbeatWriter):
12
+ """DiffusionControl 상태를 10초마다 상태 파일에 원자적으로 기록합니다."""
13
+
14
+ def __init__(self, control: Any) -> None:
15
+ super().__init__(control, name="diffusion")