File size: 11,335 Bytes
e0265b9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
from __future__ import annotations

import importlib
import inspect
import logging
import queue
import subprocess
import sys
import threading
import time
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Callable

from adam.registry import ToolRegistry, ToolSpec


class ToolExecutionError(RuntimeError):
    pass


class ToolCancelled(ToolExecutionError):
    pass


ProgressCallback = Callable[[int, str], None]
LogCallback = Callable[[str], None]
PreviewCallback = Callable[[dict[str, Any]], None]


@dataclass(slots=True)
class ToolContext:
    root: Path
    job_id: str
    tool: ToolSpec
    cancel_event: threading.Event
    run_event: threading.Event
    progress_callback: ProgressCallback
    log_callback: LogCallback
    preview_callback: PreviewCallback = lambda _preview: None
    step_delay: float = 0.2

    def log(self, message: str) -> None:
        self.log_callback(message)

    def progress(self, percent: int, message: str) -> None:
        self.checkpoint()
        self.progress_callback(max(0, min(int(percent), 100)), message)

    def preview(
        self, path: str | Path, *, epoch: int = 0, next_epoch: int = 0,
        prompt: str = "", seed: int | None = None, steps: int = 0,
        kind: str = "training", current: int = 0, total: int = 0,
        image_index: int = 0, image_count: int = 0,
    ) -> None:
        """Publish a trainer-created preview without coupling the UI to a backend."""
        preview = Path(path).expanduser().resolve()
        if not preview.is_file():
            return
        self.preview_callback({
            "path": str(preview), "epoch": max(0, int(epoch)),
            "next_epoch": max(0, int(next_epoch)), "prompt": str(prompt),
            "seed": seed, "steps": max(0, int(steps)), "kind": str(kind),
            "current": max(0, int(current)), "total": max(0, int(total)),
            "image_index": max(0, int(image_index)), "image_count": max(0, int(image_count)),
        })

    def checkpoint(self) -> None:
        if self.cancel_event.is_set():
            raise ToolCancelled("Job cancelled by user.")
        while not self.run_event.wait(timeout=0.15):
            if self.cancel_event.is_set():
                raise ToolCancelled("Job cancelled by user.")

    def wait(self, multiplier: float = 1.0) -> None:
        deadline = time.monotonic() + max(0.0, self.step_delay * multiplier)
        while time.monotonic() < deadline:
            self.checkpoint()
            time.sleep(min(0.05, max(0.0, deadline - time.monotonic())))


class ToolExecutor:
    def __init__(
        self,
        root: Path,
        registry: ToolRegistry,
        logger: logging.Logger,
        *,
        step_delay: float = 0.2,
    ) -> None:
        self.root = root.resolve()
        self.registry = registry
        self.logger = logger
        self.step_delay = step_delay

    def execute(
        self,
        tool_id: str,
        arguments: dict[str, Any],
        *,
        job_id: str,
        cancel_event: threading.Event,
        run_event: threading.Event,
        progress_callback: ProgressCallback,
        log_callback: LogCallback,
        preview_callback: PreviewCallback | None = None,
    ) -> dict[str, Any]:
        spec = self.registry.get(tool_id)
        self._validate_arguments(spec, arguments)
        context = ToolContext(
            root=self.root,
            job_id=job_id,
            tool=spec,
            cancel_event=cancel_event,
            run_event=run_event,
            progress_callback=progress_callback,
            log_callback=log_callback,
            preview_callback=preview_callback or (lambda _preview: None),
            step_delay=self.step_delay,
        )
        backend_type = str(spec.backend.get("type", "")).lower()
        self.logger.info("Job %s starting tool %s", job_id, spec.id)

        if backend_type == "python":
            result = self._execute_python(spec, context, arguments)
        elif backend_type == "script":
            result = self._execute_script(spec, context, arguments)
        else:
            raise ToolExecutionError(f"{spec.name} has no configured backend.")

        self.logger.info("Job %s completed tool %s", job_id, spec.id)
        return result or {}

    def _validate_arguments(self, spec: ToolSpec, arguments: dict[str, Any]) -> None:
        if not isinstance(arguments, dict):
            raise ToolExecutionError("Tool arguments must be a dictionary.")
        unknown = set(arguments) - set(spec.arguments)
        if unknown:
            raise ToolExecutionError(
                f"{spec.name} received unsupported arguments: "
                f"{', '.join(sorted(unknown))}"
            )
        missing = set(spec.required_arguments) - set(arguments)
        if missing:
            raise ToolExecutionError(
                f"{spec.name} is missing required arguments: "
                f"{', '.join(sorted(missing))}"
            )
        for key, value in arguments.items():
            if isinstance(value, str):
                if "\x00" in value or len(value) > 2_000:
                    raise ToolExecutionError(f"Unsafe value supplied for {key}.")
            elif not isinstance(value, (int, float, bool, list, dict, type(None))):
                raise ToolExecutionError(f"Unsupported value type supplied for {key}.")

    def _execute_python(
        self,
        spec: ToolSpec,
        context: ToolContext,
        arguments: dict[str, Any],
    ) -> dict[str, Any]:
        module_name = str(spec.backend.get("module", ""))
        function_name = str(spec.backend.get("function", ""))
        if not module_name or not function_name:
            raise ToolExecutionError(f"{spec.name} has an incomplete Python backend.")
        try:
            module = importlib.import_module(module_name)
            function = getattr(module, function_name)
        except (ImportError, AttributeError) as exc:
            raise ToolExecutionError(
                f"Could not load backend for {spec.name}: {exc}"
            ) from exc
        if not callable(function):
            raise ToolExecutionError(f"Backend for {spec.name} is not callable.")

        signature = inspect.signature(function)
        try:
            signature.bind(context, **arguments)
        except TypeError as exc:
            raise ToolExecutionError(
                f"Arguments do not match {spec.name}'s backend: {exc}"
            ) from exc

        result = function(context, **arguments)
        if result is None:
            return {}
        if not isinstance(result, dict):
            raise ToolExecutionError(f"{spec.name} must return a dictionary or None.")
        return result

    def _execute_script(
        self,
        spec: ToolSpec,
        context: ToolContext,
        arguments: dict[str, Any],
    ) -> dict[str, Any]:
        raw_path = str(spec.backend.get("path", ""))
        if not raw_path:
            raise ToolExecutionError(f"{spec.name} has no script path.")
        script = Path(raw_path).expanduser()
        if not script.is_absolute():
            script = self.root / script
        script = script.resolve()
        if not script.is_file() or script.suffix.lower() != ".py":
            raise ToolExecutionError(f"Python script was not found: {script}")

        command = [sys.executable, str(script)]
        for key in spec.arguments:
            if key not in arguments:
                continue
            value = arguments[key]
            command.extend([f"--{key.replace('_', '-')}", str(value)])

        context.log(f"Launching registered script: {script.name}")
        process = subprocess.Popen(
            command,
            cwd=str(script.parent),
            stdout=subprocess.PIPE,
            stderr=subprocess.STDOUT,
            text=True,
            encoding="utf-8",
            errors="replace",
            shell=False,
        )
        output_queue: queue.Queue[str | None] = queue.Queue()

        def collect_output() -> None:
            assert process.stdout is not None
            for line in process.stdout:
                output_queue.put(line.rstrip())
            output_queue.put(None)

        reader = threading.Thread(target=collect_output, daemon=True)
        reader.start()
        process_controller = None
        try:
            import psutil

            process_controller = psutil.Process(process.pid)
        except Exception:
            process_controller = None
        suspended = False

        def stop_process_tree() -> None:
            """Stop the script and any workers it launched."""
            if process_controller is not None:
                try:
                    descendants = process_controller.children(recursive=True)
                    for child in descendants:
                        try:
                            child.terminate()
                        except Exception:
                            pass
                    process_controller.terminate()
                    try:
                        import psutil

                        _gone, alive = psutil.wait_procs(descendants, timeout=2)
                        for child in alive:
                            try:
                                child.kill()
                            except Exception:
                                pass
                    except Exception:
                        pass
                    return
                except Exception:
                    pass
            process.terminate()

        try:
            while True:
                if context.cancel_event.is_set():
                    raise ToolCancelled("Job cancelled by user.")
                if not context.run_event.is_set():
                    if process_controller is not None and not suspended:
                        try:
                            process_controller.suspend()
                            suspended = True
                        except Exception:
                            process_controller = None
                    context.checkpoint()
                    if process_controller is not None and suspended:
                        try:
                            process_controller.resume()
                        except Exception:
                            pass
                        suspended = False
                try:
                    line = output_queue.get(timeout=0.08)
                    if line is not None and line:
                        context.log(line)
                except queue.Empty:
                    pass
                if process.poll() is not None and output_queue.empty():
                    break
        except ToolCancelled:
            if process_controller is not None and suspended:
                try:
                    process_controller.resume()
                except Exception:
                    pass
            stop_process_tree()
            try:
                process.wait(timeout=3)
            except subprocess.TimeoutExpired:
                process.kill()
            raise

        if process.returncode != 0:
            raise ToolExecutionError(
                f"{spec.name} exited with code {process.returncode}."
            )
        context.progress(100, f"{spec.name} completed")
        return {}