openadapt-ml 0.1.0__py3-none-any.whl → 0.2.1__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.
- openadapt_ml/baselines/__init__.py +121 -0
- openadapt_ml/baselines/adapter.py +185 -0
- openadapt_ml/baselines/cli.py +314 -0
- openadapt_ml/baselines/config.py +448 -0
- openadapt_ml/baselines/parser.py +922 -0
- openadapt_ml/baselines/prompts.py +787 -0
- openadapt_ml/benchmarks/__init__.py +13 -107
- openadapt_ml/benchmarks/agent.py +297 -374
- openadapt_ml/benchmarks/azure.py +62 -24
- openadapt_ml/benchmarks/azure_ops_tracker.py +521 -0
- openadapt_ml/benchmarks/cli.py +1874 -751
- openadapt_ml/benchmarks/trace_export.py +631 -0
- openadapt_ml/benchmarks/viewer.py +1236 -0
- openadapt_ml/benchmarks/vm_monitor.py +1111 -0
- openadapt_ml/benchmarks/waa_deploy/Dockerfile +216 -0
- openadapt_ml/benchmarks/waa_deploy/__init__.py +10 -0
- openadapt_ml/benchmarks/waa_deploy/api_agent.py +540 -0
- openadapt_ml/benchmarks/waa_deploy/start_waa_server.bat +53 -0
- openadapt_ml/cloud/azure_inference.py +3 -5
- openadapt_ml/cloud/lambda_labs.py +722 -307
- openadapt_ml/cloud/local.py +3194 -89
- openadapt_ml/cloud/ssh_tunnel.py +595 -0
- openadapt_ml/datasets/next_action.py +125 -96
- openadapt_ml/evals/grounding.py +32 -9
- openadapt_ml/evals/plot_eval_metrics.py +15 -13
- openadapt_ml/evals/trajectory_matching.py +120 -57
- openadapt_ml/experiments/demo_prompt/__init__.py +19 -0
- openadapt_ml/experiments/demo_prompt/format_demo.py +236 -0
- openadapt_ml/experiments/demo_prompt/results/experiment_20251231_002125.json +83 -0
- openadapt_ml/experiments/demo_prompt/results/experiment_n30_20251231_165958.json +1100 -0
- openadapt_ml/experiments/demo_prompt/results/multistep_20251231_025051.json +182 -0
- openadapt_ml/experiments/demo_prompt/run_experiment.py +541 -0
- openadapt_ml/experiments/representation_shootout/__init__.py +70 -0
- openadapt_ml/experiments/representation_shootout/conditions.py +708 -0
- openadapt_ml/experiments/representation_shootout/config.py +390 -0
- openadapt_ml/experiments/representation_shootout/evaluator.py +659 -0
- openadapt_ml/experiments/representation_shootout/runner.py +687 -0
- openadapt_ml/experiments/waa_demo/__init__.py +10 -0
- openadapt_ml/experiments/waa_demo/demos.py +357 -0
- openadapt_ml/experiments/waa_demo/runner.py +732 -0
- openadapt_ml/experiments/waa_demo/tasks.py +151 -0
- openadapt_ml/export/__init__.py +9 -0
- openadapt_ml/export/__main__.py +6 -0
- openadapt_ml/export/cli.py +89 -0
- openadapt_ml/export/parquet.py +277 -0
- openadapt_ml/grounding/detector.py +18 -14
- openadapt_ml/ingest/__init__.py +11 -10
- openadapt_ml/ingest/capture.py +97 -86
- openadapt_ml/ingest/loader.py +120 -69
- openadapt_ml/ingest/synthetic.py +344 -193
- openadapt_ml/models/api_adapter.py +14 -4
- openadapt_ml/models/base_adapter.py +10 -2
- openadapt_ml/models/providers/__init__.py +288 -0
- openadapt_ml/models/providers/anthropic.py +266 -0
- openadapt_ml/models/providers/base.py +299 -0
- openadapt_ml/models/providers/google.py +376 -0
- openadapt_ml/models/providers/openai.py +342 -0
- openadapt_ml/models/qwen_vl.py +46 -19
- openadapt_ml/perception/__init__.py +35 -0
- openadapt_ml/perception/integration.py +399 -0
- openadapt_ml/retrieval/README.md +226 -0
- openadapt_ml/retrieval/USAGE.md +391 -0
- openadapt_ml/retrieval/__init__.py +91 -0
- openadapt_ml/retrieval/demo_retriever.py +843 -0
- openadapt_ml/retrieval/embeddings.py +630 -0
- openadapt_ml/retrieval/index.py +194 -0
- openadapt_ml/retrieval/retriever.py +162 -0
- openadapt_ml/runtime/__init__.py +50 -0
- openadapt_ml/runtime/policy.py +27 -14
- openadapt_ml/runtime/safety_gate.py +471 -0
- openadapt_ml/schema/__init__.py +113 -0
- openadapt_ml/schema/converters.py +588 -0
- openadapt_ml/schema/episode.py +470 -0
- openadapt_ml/scripts/capture_screenshots.py +530 -0
- openadapt_ml/scripts/compare.py +102 -61
- openadapt_ml/scripts/demo_policy.py +4 -1
- openadapt_ml/scripts/eval_policy.py +19 -14
- openadapt_ml/scripts/make_gif.py +1 -1
- openadapt_ml/scripts/prepare_synthetic.py +16 -17
- openadapt_ml/scripts/train.py +98 -75
- openadapt_ml/segmentation/README.md +920 -0
- openadapt_ml/segmentation/__init__.py +97 -0
- openadapt_ml/segmentation/adapters/__init__.py +5 -0
- openadapt_ml/segmentation/adapters/capture_adapter.py +420 -0
- openadapt_ml/segmentation/annotator.py +610 -0
- openadapt_ml/segmentation/cache.py +290 -0
- openadapt_ml/segmentation/cli.py +674 -0
- openadapt_ml/segmentation/deduplicator.py +656 -0
- openadapt_ml/segmentation/frame_describer.py +788 -0
- openadapt_ml/segmentation/pipeline.py +340 -0
- openadapt_ml/segmentation/schemas.py +622 -0
- openadapt_ml/segmentation/segment_extractor.py +634 -0
- openadapt_ml/training/azure_ops_viewer.py +1097 -0
- openadapt_ml/training/benchmark_viewer.py +3255 -19
- openadapt_ml/training/shared_ui.py +7 -7
- openadapt_ml/training/stub_provider.py +57 -35
- openadapt_ml/training/trainer.py +255 -441
- openadapt_ml/training/trl_trainer.py +403 -0
- openadapt_ml/training/viewer.py +323 -108
- openadapt_ml/training/viewer_components.py +180 -0
- {openadapt_ml-0.1.0.dist-info → openadapt_ml-0.2.1.dist-info}/METADATA +312 -69
- openadapt_ml-0.2.1.dist-info/RECORD +116 -0
- openadapt_ml/benchmarks/base.py +0 -366
- openadapt_ml/benchmarks/data_collection.py +0 -432
- openadapt_ml/benchmarks/runner.py +0 -381
- openadapt_ml/benchmarks/waa.py +0 -704
- openadapt_ml/schemas/__init__.py +0 -53
- openadapt_ml/schemas/sessions.py +0 -122
- openadapt_ml/schemas/validation.py +0 -252
- openadapt_ml-0.1.0.dist-info/RECORD +0 -55
- {openadapt_ml-0.1.0.dist-info → openadapt_ml-0.2.1.dist-info}/WHEEL +0 -0
- {openadapt_ml-0.1.0.dist-info → openadapt_ml-0.2.1.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,521 @@
|
|
|
1
|
+
"""Azure operations status tracker.
|
|
2
|
+
|
|
3
|
+
Writes real-time status to azure_ops_status.json for dashboard consumption.
|
|
4
|
+
Used by CLI commands (setup-waa, run-waa, vm monitor) to provide visibility
|
|
5
|
+
into long-running Azure operations.
|
|
6
|
+
|
|
7
|
+
Usage:
|
|
8
|
+
from openadapt_ml.benchmarks.azure_ops_tracker import AzureOpsTracker
|
|
9
|
+
|
|
10
|
+
tracker = AzureOpsTracker()
|
|
11
|
+
tracker.start_operation("docker_build", total_steps=12)
|
|
12
|
+
tracker.update(phase="pulling_base_image", step=1, log_lines=["Pulling from ..."])
|
|
13
|
+
tracker.append_log("Step 1/12 : FROM dockurr/windows:latest")
|
|
14
|
+
tracker.finish_operation()
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import json
|
|
20
|
+
import re
|
|
21
|
+
from dataclasses import dataclass, asdict, field
|
|
22
|
+
from datetime import datetime
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from typing import Any
|
|
25
|
+
|
|
26
|
+
# VM pricing from vm_monitor.py
|
|
27
|
+
VM_HOURLY_RATES = {
|
|
28
|
+
"Standard_D2_v3": 0.096,
|
|
29
|
+
"Standard_D4_v3": 0.192,
|
|
30
|
+
"Standard_D8_v3": 0.384,
|
|
31
|
+
"Standard_D4s_v3": 0.192,
|
|
32
|
+
"Standard_D8s_v3": 0.384,
|
|
33
|
+
"Standard_D4ds_v5": 0.422, # Updated pricing as per spec
|
|
34
|
+
"Standard_D8ds_v5": 0.384,
|
|
35
|
+
"Standard_D16ds_v5": 0.768,
|
|
36
|
+
"Standard_D32ds_v5": 1.536,
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
# Typical operation durations in seconds (for ETA estimation)
|
|
40
|
+
TYPICAL_DURATIONS = {
|
|
41
|
+
"docker_build": 600, # ~10 minutes for waa-auto build
|
|
42
|
+
"docker_pull": 300, # ~5 minutes for large image pull
|
|
43
|
+
"windows_boot": 900, # ~15 minutes for first Windows boot
|
|
44
|
+
"benchmark": 1800, # ~30 minutes for 20 tasks
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
DEFAULT_OUTPUT_FILE = Path("benchmark_results/azure_ops_status.json")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass
|
|
51
|
+
class AzureOpsStatus:
|
|
52
|
+
"""Status of current Azure operation.
|
|
53
|
+
|
|
54
|
+
Attributes:
|
|
55
|
+
operation: Current operation type (idle, vm_create, docker_install,
|
|
56
|
+
docker_build, windows_boot, benchmark, etc.)
|
|
57
|
+
phase: Specific phase within the operation.
|
|
58
|
+
step: Current step number.
|
|
59
|
+
total_steps: Total number of steps in the operation.
|
|
60
|
+
progress_pct: Progress percentage (0-100).
|
|
61
|
+
log_tail: Last N lines of log output.
|
|
62
|
+
started_at: ISO timestamp when operation started.
|
|
63
|
+
elapsed_seconds: Seconds since operation started.
|
|
64
|
+
eta_seconds: Estimated seconds remaining (None if unknown).
|
|
65
|
+
cost_usd: Running cost in USD.
|
|
66
|
+
hourly_rate_usd: Hourly VM rate in USD.
|
|
67
|
+
vm_ip: VM IP address if available.
|
|
68
|
+
vm_state: VM power state (running, starting, stopped, deallocated).
|
|
69
|
+
vm_size: Azure VM size.
|
|
70
|
+
vnc_url: VNC URL for accessing Windows desktop.
|
|
71
|
+
error: Error message if operation failed.
|
|
72
|
+
download_bytes: Bytes downloaded so far (for image pulls).
|
|
73
|
+
download_total_bytes: Total bytes to download.
|
|
74
|
+
build_id: Current Docker build run ID (to detect new builds).
|
|
75
|
+
"""
|
|
76
|
+
|
|
77
|
+
operation: str = "idle"
|
|
78
|
+
phase: str = ""
|
|
79
|
+
step: int = 0
|
|
80
|
+
total_steps: int = 0
|
|
81
|
+
progress_pct: float = 0.0
|
|
82
|
+
log_tail: list[str] = field(default_factory=list)
|
|
83
|
+
started_at: str | None = None
|
|
84
|
+
elapsed_seconds: float = 0.0
|
|
85
|
+
eta_seconds: float | None = None
|
|
86
|
+
cost_usd: float = 0.0
|
|
87
|
+
hourly_rate_usd: float = 0.422 # Default for Standard_D4ds_v5
|
|
88
|
+
vm_ip: str | None = None
|
|
89
|
+
vm_state: str = "unknown"
|
|
90
|
+
vm_size: str = "Standard_D4ds_v5"
|
|
91
|
+
vnc_url: str | None = None
|
|
92
|
+
error: str | None = None
|
|
93
|
+
download_bytes: int = 0
|
|
94
|
+
download_total_bytes: int = 0
|
|
95
|
+
build_id: str | None = None
|
|
96
|
+
|
|
97
|
+
def to_dict(self) -> dict[str, Any]:
|
|
98
|
+
"""Convert to dictionary for JSON serialization."""
|
|
99
|
+
return asdict(self)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class AzureOpsTracker:
|
|
103
|
+
"""Tracks Azure operations and writes status to JSON file.
|
|
104
|
+
|
|
105
|
+
The tracker maintains a status file that the dashboard can poll to
|
|
106
|
+
display real-time progress of Azure operations.
|
|
107
|
+
"""
|
|
108
|
+
|
|
109
|
+
MAX_LOG_LINES = 100
|
|
110
|
+
|
|
111
|
+
def __init__(
|
|
112
|
+
self,
|
|
113
|
+
output_file: str | Path = DEFAULT_OUTPUT_FILE,
|
|
114
|
+
vm_size: str = "Standard_D4ds_v5",
|
|
115
|
+
):
|
|
116
|
+
"""Initialize tracker.
|
|
117
|
+
|
|
118
|
+
Args:
|
|
119
|
+
output_file: Path to output JSON file.
|
|
120
|
+
vm_size: Azure VM size for cost calculation.
|
|
121
|
+
"""
|
|
122
|
+
self.output_file = Path(output_file)
|
|
123
|
+
self.vm_size = vm_size
|
|
124
|
+
self.hourly_rate = VM_HOURLY_RATES.get(vm_size, 0.422)
|
|
125
|
+
self._status = AzureOpsStatus(
|
|
126
|
+
vm_size=vm_size,
|
|
127
|
+
hourly_rate_usd=self.hourly_rate,
|
|
128
|
+
)
|
|
129
|
+
self._start_time: datetime | None = None
|
|
130
|
+
|
|
131
|
+
def start_operation(
|
|
132
|
+
self,
|
|
133
|
+
operation: str,
|
|
134
|
+
total_steps: int = 0,
|
|
135
|
+
phase: str = "",
|
|
136
|
+
vm_ip: str | None = None,
|
|
137
|
+
vm_state: str = "running",
|
|
138
|
+
build_id: str | None = None,
|
|
139
|
+
started_at: datetime | None = None,
|
|
140
|
+
) -> None:
|
|
141
|
+
"""Start tracking a new operation.
|
|
142
|
+
|
|
143
|
+
Args:
|
|
144
|
+
operation: Operation type (vm_create, docker_install, docker_build,
|
|
145
|
+
windows_boot, benchmark, etc.)
|
|
146
|
+
total_steps: Total number of steps in the operation.
|
|
147
|
+
phase: Initial phase description.
|
|
148
|
+
vm_ip: VM IP address if known.
|
|
149
|
+
vm_state: VM power state.
|
|
150
|
+
build_id: Unique identifier for this build (to detect new builds).
|
|
151
|
+
started_at: When the operation actually started (uses now if not provided).
|
|
152
|
+
"""
|
|
153
|
+
self._start_time = started_at or datetime.now()
|
|
154
|
+
self._status = AzureOpsStatus(
|
|
155
|
+
operation=operation,
|
|
156
|
+
phase=phase,
|
|
157
|
+
step=0,
|
|
158
|
+
total_steps=total_steps,
|
|
159
|
+
progress_pct=0.0,
|
|
160
|
+
log_tail=[], # Clear stale logs
|
|
161
|
+
started_at=self._start_time.isoformat(),
|
|
162
|
+
elapsed_seconds=0.0,
|
|
163
|
+
eta_seconds=TYPICAL_DURATIONS.get(
|
|
164
|
+
operation
|
|
165
|
+
), # Use typical duration as initial ETA
|
|
166
|
+
cost_usd=0.0,
|
|
167
|
+
hourly_rate_usd=self.hourly_rate,
|
|
168
|
+
vm_ip=vm_ip,
|
|
169
|
+
vm_state=vm_state,
|
|
170
|
+
vm_size=self.vm_size,
|
|
171
|
+
vnc_url="http://localhost:8006" if vm_ip else None,
|
|
172
|
+
error=None,
|
|
173
|
+
download_bytes=0,
|
|
174
|
+
download_total_bytes=0,
|
|
175
|
+
build_id=build_id,
|
|
176
|
+
)
|
|
177
|
+
self._write_status()
|
|
178
|
+
|
|
179
|
+
def update(
|
|
180
|
+
self,
|
|
181
|
+
phase: str | None = None,
|
|
182
|
+
step: int | None = None,
|
|
183
|
+
total_steps: int | None = None,
|
|
184
|
+
log_lines: list[str] | None = None,
|
|
185
|
+
vm_ip: str | None = None,
|
|
186
|
+
vm_state: str | None = None,
|
|
187
|
+
error: str | None = None,
|
|
188
|
+
download_bytes: int | None = None,
|
|
189
|
+
download_total_bytes: int | None = None,
|
|
190
|
+
build_id: str | None = None,
|
|
191
|
+
) -> None:
|
|
192
|
+
"""Update operation status.
|
|
193
|
+
|
|
194
|
+
Args:
|
|
195
|
+
phase: Current phase description.
|
|
196
|
+
step: Current step number.
|
|
197
|
+
total_steps: Total steps (can be updated if discovered during operation).
|
|
198
|
+
log_lines: New log lines to append.
|
|
199
|
+
vm_ip: VM IP address.
|
|
200
|
+
vm_state: VM power state.
|
|
201
|
+
error: Error message if operation failed.
|
|
202
|
+
download_bytes: Bytes downloaded so far.
|
|
203
|
+
download_total_bytes: Total bytes to download.
|
|
204
|
+
build_id: Build identifier (clears log if different from current).
|
|
205
|
+
"""
|
|
206
|
+
# If build_id changed, this is a new build - clear stale logs
|
|
207
|
+
if build_id is not None and build_id != self._status.build_id:
|
|
208
|
+
self._status.build_id = build_id
|
|
209
|
+
self._status.log_tail = []
|
|
210
|
+
self._status.error = None
|
|
211
|
+
self._start_time = datetime.now()
|
|
212
|
+
self._status.started_at = self._start_time.isoformat()
|
|
213
|
+
|
|
214
|
+
if phase is not None:
|
|
215
|
+
self._status.phase = phase
|
|
216
|
+
if step is not None:
|
|
217
|
+
self._status.step = step
|
|
218
|
+
if total_steps is not None:
|
|
219
|
+
self._status.total_steps = total_steps
|
|
220
|
+
if log_lines is not None:
|
|
221
|
+
for line in log_lines:
|
|
222
|
+
self.append_log(line)
|
|
223
|
+
if vm_ip is not None:
|
|
224
|
+
self._status.vm_ip = vm_ip
|
|
225
|
+
self._status.vnc_url = "http://localhost:8006"
|
|
226
|
+
if vm_state is not None:
|
|
227
|
+
self._status.vm_state = vm_state
|
|
228
|
+
if error is not None:
|
|
229
|
+
self._status.error = error
|
|
230
|
+
if download_bytes is not None:
|
|
231
|
+
self._status.download_bytes = download_bytes
|
|
232
|
+
if download_total_bytes is not None:
|
|
233
|
+
self._status.download_total_bytes = download_total_bytes
|
|
234
|
+
|
|
235
|
+
# Update derived fields
|
|
236
|
+
self._update_progress()
|
|
237
|
+
self._write_status()
|
|
238
|
+
|
|
239
|
+
def append_log(self, line: str) -> None:
|
|
240
|
+
"""Append a log line (keeps last MAX_LOG_LINES).
|
|
241
|
+
|
|
242
|
+
Args:
|
|
243
|
+
line: Log line to append.
|
|
244
|
+
"""
|
|
245
|
+
self._status.log_tail.append(line.rstrip())
|
|
246
|
+
if len(self._status.log_tail) > self.MAX_LOG_LINES:
|
|
247
|
+
self._status.log_tail = self._status.log_tail[-self.MAX_LOG_LINES :]
|
|
248
|
+
self._update_progress()
|
|
249
|
+
self._write_status()
|
|
250
|
+
|
|
251
|
+
def parse_docker_build_line(self, line: str) -> dict[str, Any]:
|
|
252
|
+
"""Parse Docker build output for step progress and download info.
|
|
253
|
+
|
|
254
|
+
Handles both patterns:
|
|
255
|
+
- Old style: "Step X/Y : ..."
|
|
256
|
+
- Buildx style: "#N [stage X/Y] ..." or "#N sha256:... XXXMB / YGB ..."
|
|
257
|
+
|
|
258
|
+
Args:
|
|
259
|
+
line: Docker build output line.
|
|
260
|
+
|
|
261
|
+
Returns:
|
|
262
|
+
Dict with parsed info: {step, total_steps, download_bytes, download_total_bytes, phase}
|
|
263
|
+
"""
|
|
264
|
+
result: dict[str, Any] = {}
|
|
265
|
+
|
|
266
|
+
# Old style: "Step X/Y : ..."
|
|
267
|
+
step_match = re.search(r"Step\s+(\d+)/(\d+)", line)
|
|
268
|
+
if step_match:
|
|
269
|
+
result["step"] = int(step_match.group(1))
|
|
270
|
+
result["total_steps"] = int(step_match.group(2))
|
|
271
|
+
|
|
272
|
+
# Buildx style: "#N [stage X/Y] ..."
|
|
273
|
+
buildx_stage = re.search(r"#\d+\s+\[.*?\s+(\d+)/(\d+)\]", line)
|
|
274
|
+
if buildx_stage:
|
|
275
|
+
result["step"] = int(buildx_stage.group(1))
|
|
276
|
+
result["total_steps"] = int(buildx_stage.group(2))
|
|
277
|
+
|
|
278
|
+
# Download progress: "sha256:... XXXMB / YGB ..." or "XXX.XXMB / YY.YYGB ..."
|
|
279
|
+
download_match = re.search(
|
|
280
|
+
r"(\d+(?:\.\d+)?)\s*(MB|GB|KB|B)\s*/\s*(\d+(?:\.\d+)?)\s*(MB|GB|KB|B)",
|
|
281
|
+
line,
|
|
282
|
+
)
|
|
283
|
+
if download_match:
|
|
284
|
+
size_multipliers = {"B": 1, "KB": 1024, "MB": 1024**2, "GB": 1024**3}
|
|
285
|
+
downloaded = float(download_match.group(1))
|
|
286
|
+
downloaded_unit = download_match.group(2)
|
|
287
|
+
total = float(download_match.group(3))
|
|
288
|
+
total_unit = download_match.group(4)
|
|
289
|
+
result["download_bytes"] = int(
|
|
290
|
+
downloaded * size_multipliers[downloaded_unit]
|
|
291
|
+
)
|
|
292
|
+
result["download_total_bytes"] = int(total * size_multipliers[total_unit])
|
|
293
|
+
|
|
294
|
+
# Extract phase from buildx output
|
|
295
|
+
if line.startswith("#"):
|
|
296
|
+
# #N DONE, #N CACHED, #N [stage]
|
|
297
|
+
phase_match = re.match(r"#\d+\s+(.*)", line)
|
|
298
|
+
if phase_match:
|
|
299
|
+
phase_text = phase_match.group(1)[:80]
|
|
300
|
+
# Clean up ANSI codes
|
|
301
|
+
phase_text = re.sub(r"\x1b\[[0-9;]*m", "", phase_text)
|
|
302
|
+
result["phase"] = phase_text.strip()
|
|
303
|
+
|
|
304
|
+
# Apply updates if we found anything
|
|
305
|
+
if "step" in result:
|
|
306
|
+
self._status.step = result["step"]
|
|
307
|
+
if "total_steps" in result:
|
|
308
|
+
self._status.total_steps = result["total_steps"]
|
|
309
|
+
if "download_bytes" in result:
|
|
310
|
+
self._status.download_bytes = result["download_bytes"]
|
|
311
|
+
if "download_total_bytes" in result:
|
|
312
|
+
self._status.download_total_bytes = result["download_total_bytes"]
|
|
313
|
+
if "phase" in result:
|
|
314
|
+
self._status.phase = result["phase"]
|
|
315
|
+
|
|
316
|
+
if result:
|
|
317
|
+
self._update_progress()
|
|
318
|
+
|
|
319
|
+
return result
|
|
320
|
+
|
|
321
|
+
def is_error_line(self, line: str) -> bool:
|
|
322
|
+
"""Check if a line is an error message.
|
|
323
|
+
|
|
324
|
+
Args:
|
|
325
|
+
line: Log line to check.
|
|
326
|
+
|
|
327
|
+
Returns:
|
|
328
|
+
True if line contains an error.
|
|
329
|
+
"""
|
|
330
|
+
error_patterns = [
|
|
331
|
+
r"ERROR:",
|
|
332
|
+
r"failed to build",
|
|
333
|
+
r"failed to solve",
|
|
334
|
+
r"error reading from server",
|
|
335
|
+
r"rpc error",
|
|
336
|
+
]
|
|
337
|
+
return any(re.search(p, line, re.IGNORECASE) for p in error_patterns)
|
|
338
|
+
|
|
339
|
+
def finish_operation(self, success: bool = True, error: str | None = None) -> None:
|
|
340
|
+
"""Mark operation as complete.
|
|
341
|
+
|
|
342
|
+
Args:
|
|
343
|
+
success: Whether the operation completed successfully.
|
|
344
|
+
error: Error message if operation failed.
|
|
345
|
+
"""
|
|
346
|
+
if error:
|
|
347
|
+
self._status.error = error
|
|
348
|
+
self._status.operation = "complete" if success else "failed"
|
|
349
|
+
self._status.progress_pct = 100.0 if success else self._status.progress_pct
|
|
350
|
+
self._update_progress()
|
|
351
|
+
self._write_status()
|
|
352
|
+
|
|
353
|
+
def set_idle(self) -> None:
|
|
354
|
+
"""Reset tracker to idle state."""
|
|
355
|
+
self._start_time = None
|
|
356
|
+
self._status = AzureOpsStatus(
|
|
357
|
+
vm_size=self.vm_size,
|
|
358
|
+
hourly_rate_usd=self.hourly_rate,
|
|
359
|
+
)
|
|
360
|
+
self._write_status()
|
|
361
|
+
|
|
362
|
+
def get_status(self) -> AzureOpsStatus:
|
|
363
|
+
"""Get current status (with updated elapsed time and cost)."""
|
|
364
|
+
self._update_progress()
|
|
365
|
+
return self._status
|
|
366
|
+
|
|
367
|
+
def _update_progress(self) -> None:
|
|
368
|
+
"""Update derived fields (elapsed time, cost, progress percentage, ETA)."""
|
|
369
|
+
# Update elapsed time
|
|
370
|
+
if self._start_time:
|
|
371
|
+
elapsed = datetime.now() - self._start_time
|
|
372
|
+
self._status.elapsed_seconds = elapsed.total_seconds()
|
|
373
|
+
|
|
374
|
+
# Update cost
|
|
375
|
+
elapsed_hours = self._status.elapsed_seconds / 3600
|
|
376
|
+
self._status.cost_usd = elapsed_hours * self.hourly_rate
|
|
377
|
+
|
|
378
|
+
# Calculate progress from multiple sources
|
|
379
|
+
progress_pct = 0.0
|
|
380
|
+
eta_seconds = None
|
|
381
|
+
|
|
382
|
+
# 1. Download progress (most accurate during image pulls)
|
|
383
|
+
if self._status.download_total_bytes > 0:
|
|
384
|
+
download_pct = (
|
|
385
|
+
self._status.download_bytes / self._status.download_total_bytes
|
|
386
|
+
) * 100
|
|
387
|
+
progress_pct = max(progress_pct, download_pct)
|
|
388
|
+
|
|
389
|
+
# ETA from download speed
|
|
390
|
+
if self._status.download_bytes > 0 and self._status.elapsed_seconds > 1:
|
|
391
|
+
bytes_per_sec = (
|
|
392
|
+
self._status.download_bytes / self._status.elapsed_seconds
|
|
393
|
+
)
|
|
394
|
+
remaining_bytes = (
|
|
395
|
+
self._status.download_total_bytes - self._status.download_bytes
|
|
396
|
+
)
|
|
397
|
+
if bytes_per_sec > 0:
|
|
398
|
+
eta_seconds = remaining_bytes / bytes_per_sec
|
|
399
|
+
|
|
400
|
+
# 2. Step-based progress
|
|
401
|
+
if self._status.total_steps > 0:
|
|
402
|
+
step_pct = (self._status.step / self._status.total_steps) * 100
|
|
403
|
+
progress_pct = max(progress_pct, step_pct)
|
|
404
|
+
|
|
405
|
+
# ETA from step rate (only if we have meaningful progress)
|
|
406
|
+
if self._status.step > 0 and self._status.elapsed_seconds > 10:
|
|
407
|
+
time_per_step = self._status.elapsed_seconds / self._status.step
|
|
408
|
+
remaining_steps = self._status.total_steps - self._status.step
|
|
409
|
+
step_eta = time_per_step * remaining_steps
|
|
410
|
+
# Use step ETA if we don't have download ETA or if step progress > download
|
|
411
|
+
if (
|
|
412
|
+
eta_seconds is None
|
|
413
|
+
or step_pct
|
|
414
|
+
> (
|
|
415
|
+
self._status.download_bytes
|
|
416
|
+
/ max(self._status.download_total_bytes, 1)
|
|
417
|
+
)
|
|
418
|
+
* 100
|
|
419
|
+
):
|
|
420
|
+
eta_seconds = step_eta
|
|
421
|
+
|
|
422
|
+
# 3. Fallback: Use typical duration if no progress info
|
|
423
|
+
if eta_seconds is None and self._status.operation in TYPICAL_DURATIONS:
|
|
424
|
+
typical = TYPICAL_DURATIONS[self._status.operation]
|
|
425
|
+
remaining = max(0, typical - self._status.elapsed_seconds)
|
|
426
|
+
eta_seconds = remaining
|
|
427
|
+
# Estimate progress from elapsed vs typical
|
|
428
|
+
if progress_pct == 0 and self._status.elapsed_seconds > 0:
|
|
429
|
+
progress_pct = min(95, (self._status.elapsed_seconds / typical) * 100)
|
|
430
|
+
|
|
431
|
+
self._status.progress_pct = min(100.0, progress_pct)
|
|
432
|
+
self._status.eta_seconds = eta_seconds
|
|
433
|
+
|
|
434
|
+
def _write_status(self) -> None:
|
|
435
|
+
"""Write current status to JSON file."""
|
|
436
|
+
self.output_file.parent.mkdir(parents=True, exist_ok=True)
|
|
437
|
+
with open(self.output_file, "w") as f:
|
|
438
|
+
json.dump(self._status.to_dict(), f, indent=2)
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
# Global tracker instance for convenience
|
|
442
|
+
_tracker: AzureOpsTracker | None = None
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
def get_tracker(
|
|
446
|
+
output_file: str | Path = DEFAULT_OUTPUT_FILE,
|
|
447
|
+
vm_size: str = "Standard_D4ds_v5",
|
|
448
|
+
) -> AzureOpsTracker:
|
|
449
|
+
"""Get or create global tracker instance.
|
|
450
|
+
|
|
451
|
+
Args:
|
|
452
|
+
output_file: Path to output JSON file.
|
|
453
|
+
vm_size: Azure VM size for cost calculation.
|
|
454
|
+
|
|
455
|
+
Returns:
|
|
456
|
+
AzureOpsTracker instance.
|
|
457
|
+
"""
|
|
458
|
+
global _tracker
|
|
459
|
+
if _tracker is None:
|
|
460
|
+
_tracker = AzureOpsTracker(output_file=output_file, vm_size=vm_size)
|
|
461
|
+
return _tracker
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
def read_status(
|
|
465
|
+
status_file: str | Path = DEFAULT_OUTPUT_FILE,
|
|
466
|
+
) -> dict[str, Any]:
|
|
467
|
+
"""Read status from JSON file with fresh computed values.
|
|
468
|
+
|
|
469
|
+
This function reads the persisted status and recomputes time-dependent
|
|
470
|
+
fields (elapsed_seconds, cost_usd) based on the current time. This ensures
|
|
471
|
+
the API always returns accurate values without relying on client-side
|
|
472
|
+
computation.
|
|
473
|
+
|
|
474
|
+
Args:
|
|
475
|
+
status_file: Path to status JSON file.
|
|
476
|
+
|
|
477
|
+
Returns:
|
|
478
|
+
Status dictionary with fresh elapsed_seconds and cost_usd, or idle status
|
|
479
|
+
if file doesn't exist.
|
|
480
|
+
"""
|
|
481
|
+
status_path = Path(status_file)
|
|
482
|
+
if status_path.exists():
|
|
483
|
+
try:
|
|
484
|
+
with open(status_path) as f:
|
|
485
|
+
status = json.load(f)
|
|
486
|
+
|
|
487
|
+
# Recompute time-dependent fields if operation is active
|
|
488
|
+
if status.get("started_at") and status.get("operation") not in (
|
|
489
|
+
"idle",
|
|
490
|
+
"complete",
|
|
491
|
+
"failed",
|
|
492
|
+
):
|
|
493
|
+
started_at = datetime.fromisoformat(status["started_at"])
|
|
494
|
+
elapsed = datetime.now() - started_at
|
|
495
|
+
elapsed_seconds = max(0, elapsed.total_seconds())
|
|
496
|
+
|
|
497
|
+
# Update elapsed time
|
|
498
|
+
status["elapsed_seconds"] = elapsed_seconds
|
|
499
|
+
|
|
500
|
+
# Update cost based on elapsed time
|
|
501
|
+
hourly_rate = status.get("hourly_rate_usd", 0.422)
|
|
502
|
+
status["cost_usd"] = (elapsed_seconds / 3600) * hourly_rate
|
|
503
|
+
|
|
504
|
+
# Update ETA if we have progress info
|
|
505
|
+
progress_pct = status.get("progress_pct", 0)
|
|
506
|
+
if progress_pct > 0 and elapsed_seconds > 10:
|
|
507
|
+
# Estimate remaining time from progress rate
|
|
508
|
+
time_per_pct = elapsed_seconds / progress_pct
|
|
509
|
+
remaining_pct = 100 - progress_pct
|
|
510
|
+
status["eta_seconds"] = time_per_pct * remaining_pct
|
|
511
|
+
elif status.get("operation") in TYPICAL_DURATIONS:
|
|
512
|
+
# Use typical duration minus elapsed
|
|
513
|
+
typical = TYPICAL_DURATIONS[status["operation"]]
|
|
514
|
+
status["eta_seconds"] = max(0, typical - elapsed_seconds)
|
|
515
|
+
|
|
516
|
+
return status
|
|
517
|
+
except (json.JSONDecodeError, IOError, ValueError):
|
|
518
|
+
pass
|
|
519
|
+
|
|
520
|
+
# Return default idle status
|
|
521
|
+
return AzureOpsStatus().to_dict()
|