ls-algorithm-plugin-sdk 0.2.5__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.
@@ -0,0 +1,664 @@
1
+ from __future__ import annotations
2
+
3
+ import hmac
4
+ import json
5
+ import logging
6
+ import random
7
+ import threading
8
+ import time
9
+ import uuid
10
+ from concurrent.futures import Future, ThreadPoolExecutor
11
+ from contextlib import asynccontextmanager
12
+ from dataclasses import dataclass, field, replace
13
+ from typing import Any, Callable
14
+
15
+ from .context import ExecutionContext, ProgressSnapshot, utc_now
16
+ from .errors import (ExecutionCancelled, ExecutionNotFinished,
17
+ ExecutionNotFound, IdempotencyConflict)
18
+ from .models import AlgorithmRequest, AlgorithmResult
19
+ from .release import ReleaseManifest
20
+ from .runner import AlgorithmRunner
21
+ from .webui_app import announce_webui, mount_webui
22
+
23
+ TERMINAL_STATES = {"succeeded", "partial", "failed", "cancelled"}
24
+ MAX_ATTEMPTS = 10
25
+ RETRY_SECONDS = 1.0
26
+ RETRY_JITTER_RATIO = 0.25
27
+
28
+ RunnerFactory = Callable[[], AlgorithmRunner]
29
+
30
+ logger = logging.getLogger(__name__)
31
+
32
+
33
+ @dataclass(slots=True)
34
+ class ExecutionRecord:
35
+ execution_id: str
36
+ request: AlgorithmRequest
37
+ accepted_at: str
38
+ state: str = "accepted"
39
+ started_at: str | None = None
40
+ finished_at: str | None = None
41
+ result: AlgorithmResult | None = None
42
+ error: str | None = None
43
+ context: ExecutionContext | None = None
44
+ future: Future[None] | None = None
45
+ attempt: int = 0
46
+ max_attempts: int = 1
47
+ events: list[dict[str, Any]] = field(default_factory=list)
48
+ condition: threading.Condition = field(
49
+ default_factory=lambda: threading.Condition(threading.RLock())
50
+ )
51
+
52
+ def add_progress(self, snapshot: ProgressSnapshot) -> None:
53
+ with self.condition:
54
+ self.events.append(snapshot.to_dict())
55
+ self.condition.notify_all()
56
+
57
+ def set_state(
58
+ self,
59
+ state: str,
60
+ *,
61
+ result: AlgorithmResult | None = None,
62
+ error: str | None = None,
63
+ ) -> None:
64
+ with self.condition:
65
+ self.state = state
66
+ self.result = result
67
+ self.error = error
68
+ if state == "running":
69
+ self.started_at = utc_now()
70
+ if state in TERMINAL_STATES:
71
+ self.finished_at = utc_now()
72
+ self.condition.notify_all()
73
+
74
+ def status_payload(self) -> dict[str, Any]:
75
+ with self.condition:
76
+ progress = self.context.snapshot().to_dict() if self.context else None
77
+ return {
78
+ "executionId": self.execution_id,
79
+ "state": self.state,
80
+ "acceptedAt": self.accepted_at,
81
+ "startedAt": self.started_at,
82
+ "finishedAt": self.finished_at,
83
+ "error": self.error,
84
+ "attempt": self.attempt,
85
+ "maxAttempts": self.max_attempts,
86
+ "progress": progress,
87
+ }
88
+
89
+ def events_after(self, sequence: int, wait_seconds: float) -> dict[str, Any]:
90
+ deadline = time.monotonic() + wait_seconds
91
+ with self.condition:
92
+ while (
93
+ not any(event["sequence"] > sequence for event in self.events)
94
+ and self.state not in TERMINAL_STATES
95
+ and time.monotonic() < deadline
96
+ ):
97
+ self.condition.wait(timeout=max(0.0, deadline - time.monotonic()))
98
+ events = [
99
+ event for event in self.events if event["sequence"] > sequence
100
+ ]
101
+ next_sequence = events[-1]["sequence"] if events else sequence
102
+ return {
103
+ "events": events,
104
+ "nextSequence": next_sequence,
105
+ "terminal": self.state in TERMINAL_STATES,
106
+ }
107
+
108
+
109
+ class ExecutionManager:
110
+ """Protocol-independent background execution service."""
111
+
112
+ def __init__(
113
+ self,
114
+ runner: AlgorithmRunner,
115
+ *,
116
+ max_concurrent_executions: int = 1,
117
+ scratch_dir: str | None = None,
118
+ gpu_ids: list[int] | None = None,
119
+ runner_factory: RunnerFactory | None = None,
120
+ max_attempts: int = MAX_ATTEMPTS,
121
+ retry_seconds: float = RETRY_SECONDS,
122
+ retry_jitter_ratio: float = RETRY_JITTER_RATIO,
123
+ release_manifest: ReleaseManifest | None = None,
124
+ ) -> None:
125
+ if max_concurrent_executions < 1:
126
+ raise ValueError("max_concurrent_executions must be positive")
127
+ if max_attempts < 1:
128
+ raise ValueError("max_attempts must be positive")
129
+ if retry_seconds <= 0:
130
+ raise ValueError("retry_seconds must be positive")
131
+ if not 0 <= retry_jitter_ratio <= 1:
132
+ raise ValueError("retry_jitter_ratio must be in [0, 1]")
133
+ configured_gpu_ids = None if gpu_ids is None else list(gpu_ids)
134
+ if configured_gpu_ids is not None and (
135
+ any(
136
+ isinstance(gpu_id, bool) or not isinstance(gpu_id, int) or gpu_id < 0
137
+ for gpu_id in configured_gpu_ids
138
+ )
139
+ or len(configured_gpu_ids) != len(set(configured_gpu_ids))
140
+ ):
141
+ raise ValueError("gpu_ids must be unique non-negative integers")
142
+ self.runner = runner
143
+ algorithm_class = type(runner.algorithm)
144
+ self._runner_factory = runner_factory or (
145
+ lambda: AlgorithmRunner(algorithm_class())
146
+ )
147
+ self.scratch_dir = scratch_dir
148
+ self.gpu_ids = configured_gpu_ids
149
+ self.max_concurrent_executions = max_concurrent_executions
150
+ self.max_attempts = max_attempts
151
+ self.retry_seconds = retry_seconds
152
+ self.retry_jitter_ratio = retry_jitter_ratio
153
+ self.release_manifest = release_manifest
154
+ self._executor = ThreadPoolExecutor(
155
+ max_workers=max_concurrent_executions,
156
+ thread_name_prefix="algorithm-execution",
157
+ )
158
+ self._records: dict[str, ExecutionRecord] = {}
159
+ self._idempotency: dict[str, tuple[str, str]] = {}
160
+ self._lock = threading.RLock()
161
+ self._started_at = time.monotonic()
162
+ self._started = False
163
+ self._closed = False
164
+ self._runner_condition = threading.Condition(threading.RLock())
165
+ self._runner_generation = 0
166
+ self._runner_users = 0
167
+ self._restart_pending = False
168
+
169
+ def start(self) -> None:
170
+ with self._lock:
171
+ if self._closed:
172
+ raise RuntimeError("execution manager is closed")
173
+ if self._started:
174
+ return
175
+ self.runner.start()
176
+ self._started = True
177
+
178
+ def submit(
179
+ self,
180
+ request: AlgorithmRequest,
181
+ *,
182
+ idempotency_key: str | None = None,
183
+ ) -> dict[str, Any]:
184
+ if self.gpu_ids is not None:
185
+ request = replace(request, gpu_ids=self.gpu_ids)
186
+ self.start()
187
+ canonical_request = json.dumps(
188
+ request.to_dict(), ensure_ascii=False, sort_keys=True, separators=(",", ":")
189
+ )
190
+ with self._lock:
191
+ if idempotency_key:
192
+ existing = self._idempotency.get(idempotency_key)
193
+ if existing is not None:
194
+ existing_request, execution_id = existing
195
+ if existing_request != canonical_request:
196
+ raise IdempotencyConflict(idempotency_key)
197
+ record = self._records[execution_id]
198
+ return {
199
+ "executionId": execution_id,
200
+ "state": record.state,
201
+ "acceptedAt": record.accepted_at,
202
+ }
203
+ execution_id = f"exec-{uuid.uuid4().hex}"
204
+ record = ExecutionRecord(
205
+ execution_id=execution_id,
206
+ request=request,
207
+ accepted_at=utc_now(),
208
+ max_attempts=self.max_attempts,
209
+ )
210
+ record.context = ExecutionContext(
211
+ execution_id,
212
+ [item.dataset for item in request.inputs],
213
+ scratch_dir=(
214
+ record.request.workspace["scratchRoot"]
215
+ if record.request.workspace is not None
216
+ else self.scratch_dir
217
+ ),
218
+ progress_callback=record.add_progress,
219
+ )
220
+ with self._lock:
221
+ if self._closed:
222
+ raise RuntimeError("execution manager is closed")
223
+ self._records[execution_id] = record
224
+ if idempotency_key:
225
+ self._idempotency[idempotency_key] = (
226
+ canonical_request,
227
+ execution_id,
228
+ )
229
+ record.future = self._executor.submit(self._run, record)
230
+ return {
231
+ "executionId": execution_id,
232
+ "state": record.state,
233
+ "acceptedAt": record.accepted_at,
234
+ }
235
+
236
+ def manifest(self) -> dict[str, Any]:
237
+ metadata = self.runner.algorithm.metadata()
238
+ capabilities = {
239
+ "cli": True,
240
+ "webui": True,
241
+ "httpService": True,
242
+ "cancel": True,
243
+ "idempotency": True,
244
+ }
245
+ if self.release_manifest is not None:
246
+ manifest = self.release_manifest.plugin_manifest(
247
+ max_concurrency=self.max_concurrent_executions,
248
+ capabilities=capabilities,
249
+ )
250
+ manifest["metadata"] = metadata.to_dict()
251
+ return manifest
252
+ return {
253
+ "apiVersion": "ldp.algorithm/v1",
254
+ "algorithmType": metadata.name.replace("-", "_"),
255
+ "name": metadata.name,
256
+ "version": metadata.version,
257
+ "inputSchema": "algorithm-plugin-sdk.request/v1",
258
+ "outputSchema": "algorithm-plugin-sdk.result/v1",
259
+ "maxConcurrency": self.max_concurrent_executions,
260
+ "capabilities": capabilities,
261
+ "metadata": metadata.to_dict(),
262
+ }
263
+
264
+ def heartbeat(self) -> dict[str, Any]:
265
+ with self._lock:
266
+ active = [
267
+ record.status_payload()
268
+ for record in self._records.values()
269
+ if record.state not in TERMINAL_STATES
270
+ ]
271
+ started = self._started and not self._closed
272
+ return {
273
+ "status": "busy" if active else ("ready" if started else "starting"),
274
+ "uptimeSeconds": int(time.monotonic() - self._started_at),
275
+ "modelLoaded": started,
276
+ "activeExecutions": len(active),
277
+ "maxConcurrency": self.max_concurrent_executions,
278
+ "executions": active,
279
+ }
280
+
281
+ def status(self, execution_id: str) -> dict[str, Any]:
282
+ return self._record(execution_id).status_payload()
283
+
284
+ def events(
285
+ self,
286
+ execution_id: str,
287
+ *,
288
+ after_sequence: int = 0,
289
+ wait_seconds: float = 0,
290
+ ) -> dict[str, Any]:
291
+ if after_sequence < 0:
292
+ raise ValueError("after_sequence cannot be negative")
293
+ if not 0 <= wait_seconds <= 30:
294
+ raise ValueError("wait_seconds must be in [0, 30]")
295
+ return self._record(execution_id).events_after(
296
+ after_sequence,
297
+ wait_seconds,
298
+ )
299
+
300
+ def result(self, execution_id: str) -> dict[str, Any]:
301
+ record = self._record(execution_id)
302
+ with record.condition:
303
+ if record.state not in TERMINAL_STATES:
304
+ raise ExecutionNotFinished(execution_id)
305
+ if record.result is None:
306
+ return {
307
+ "executionId": execution_id,
308
+ "state": record.state,
309
+ "error": record.error,
310
+ }
311
+ return {
312
+ "executionId": execution_id,
313
+ "state": record.state,
314
+ "result": record.result.to_dict(),
315
+ }
316
+
317
+ def cancel(self, execution_id: str, reason: str = "cancelled by user") -> dict[str, Any]:
318
+ record = self._record(execution_id)
319
+ with record.condition:
320
+ if record.state in TERMINAL_STATES:
321
+ return {
322
+ "executionId": execution_id,
323
+ "state": record.state,
324
+ "cancelRequested": False,
325
+ }
326
+ assert record.context is not None
327
+ record.context.cancel(reason)
328
+ cancelled_before_start = bool(record.future and record.future.cancel())
329
+ if cancelled_before_start:
330
+ record.set_state("cancelled", error=reason)
331
+ return {
332
+ "executionId": execution_id,
333
+ "state": record.state,
334
+ "cancelRequested": True,
335
+ }
336
+
337
+ def close(self) -> None:
338
+ with self._lock:
339
+ if self._closed:
340
+ return
341
+ self._closed = True
342
+ active = [
343
+ record
344
+ for record in self._records.values()
345
+ if record.state not in TERMINAL_STATES
346
+ ]
347
+ for record in active:
348
+ assert record.context is not None
349
+ record.context.cancel("service shutdown")
350
+ if record.future and record.future.cancel():
351
+ record.set_state("cancelled", error="service shutdown")
352
+ self._executor.shutdown(wait=True, cancel_futures=True)
353
+ self.runner.close()
354
+
355
+ def _record(self, execution_id: str) -> ExecutionRecord:
356
+ with self._lock:
357
+ record = self._records.get(execution_id)
358
+ if record is None:
359
+ raise ExecutionNotFound(execution_id)
360
+ return record
361
+
362
+ def _run(self, record: ExecutionRecord) -> None:
363
+ record.set_state("running")
364
+ assert record.context is not None
365
+ try:
366
+ result = self._run_with_restarts(record)
367
+ except ExecutionCancelled as exc:
368
+ record.context.mark_unfinished("cancelled", str(exc))
369
+ record.set_state("cancelled", error=str(exc))
370
+ except Exception as exc:
371
+ error = f"{type(exc).__name__}: {exc}"
372
+ record.context.mark_unfinished("failed", error)
373
+ record.set_state("failed", error=error)
374
+ else:
375
+ record.set_state(result.status, result=result)
376
+
377
+ def _run_with_restarts(self, record: ExecutionRecord) -> AlgorithmResult:
378
+ assert record.context is not None
379
+ for attempt in range(1, self.max_attempts + 1):
380
+ with record.condition:
381
+ record.attempt = attempt
382
+ record.condition.notify_all()
383
+
384
+ runner, generation = self._acquire_runner()
385
+ failure: Exception | None = None
386
+ try:
387
+ return runner.run(
388
+ record.request,
389
+ context=record.context,
390
+ mark_unfinished_on_error=False,
391
+ )
392
+ except ExecutionCancelled:
393
+ raise
394
+ except Exception as exc:
395
+ record.context.raise_if_cancelled()
396
+ failure = exc
397
+ if attempt < self.max_attempts:
398
+ delay = random.uniform(
399
+ self.retry_seconds * (1 - self.retry_jitter_ratio),
400
+ self.retry_seconds * (1 + self.retry_jitter_ratio),
401
+ )
402
+ logger.warning(
403
+ "algorithm execution %s failed on attempt %d/%d; "
404
+ "releasing and restarting algorithm in %.3f seconds: %s: %s",
405
+ record.execution_id,
406
+ attempt,
407
+ self.max_attempts,
408
+ delay,
409
+ type(exc).__name__,
410
+ exc,
411
+ )
412
+ else:
413
+ delay = 0.0
414
+ logger.error(
415
+ "algorithm execution %s exhausted %d attempts; "
416
+ "releasing and restarting algorithm for future requests: %s: %s",
417
+ record.execution_id,
418
+ self.max_attempts,
419
+ type(exc).__name__,
420
+ exc,
421
+ )
422
+ finally:
423
+ self._release_runner()
424
+
425
+ self._restart_runner(runner, generation, delay)
426
+ if attempt >= self.max_attempts:
427
+ assert failure is not None
428
+ raise failure
429
+ record.context.raise_if_cancelled()
430
+
431
+ raise AssertionError("retry loop exited unexpectedly")
432
+
433
+ def _acquire_runner(self) -> tuple[AlgorithmRunner, int]:
434
+ with self._runner_condition:
435
+ while self._restart_pending:
436
+ self._runner_condition.wait()
437
+ if self._closed:
438
+ raise ExecutionCancelled("service shutdown")
439
+ self._runner_users += 1
440
+ return self.runner, self._runner_generation
441
+
442
+ def _release_runner(self) -> None:
443
+ with self._runner_condition:
444
+ self._runner_users -= 1
445
+ self._runner_condition.notify_all()
446
+
447
+ def _restart_runner(
448
+ self,
449
+ failed_runner: AlgorithmRunner,
450
+ failed_generation: int,
451
+ delay: float,
452
+ ) -> None:
453
+ with self._runner_condition:
454
+ while (
455
+ self._restart_pending
456
+ and failed_generation == self._runner_generation
457
+ ):
458
+ self._runner_condition.wait()
459
+ if (
460
+ failed_generation != self._runner_generation
461
+ or failed_runner is not self.runner
462
+ ):
463
+ return
464
+ if self._closed:
465
+ raise ExecutionCancelled("service shutdown")
466
+ self._restart_pending = True
467
+ while self._runner_users:
468
+ self._runner_condition.wait()
469
+
470
+ try:
471
+ try:
472
+ failed_runner.close()
473
+ except Exception:
474
+ logger.exception("failed to release algorithm before restart")
475
+ time.sleep(delay)
476
+ replacement = self._runner_factory()
477
+ replacement.start()
478
+ except Exception:
479
+ with self._runner_condition:
480
+ self._restart_pending = False
481
+ self._runner_condition.notify_all()
482
+ raise
483
+
484
+ with self._runner_condition:
485
+ self.runner = replacement
486
+ self._runner_generation += 1
487
+ self._restart_pending = False
488
+ self._runner_condition.notify_all()
489
+
490
+
491
+ def create_app(
492
+ manager: ExecutionManager,
493
+ *,
494
+ webui: bool = False,
495
+ webui_url: str | None = None,
496
+ token: str | None = None,
497
+ lifecycle: Any | None = None,
498
+ ) -> Any:
499
+ """Create the optional generic HTTP adapter."""
500
+ try:
501
+ from fastapi import FastAPI, Query, Request
502
+ from fastapi.responses import JSONResponse
503
+ except ImportError as exc:
504
+ raise RuntimeError(
505
+ 'HTTP service dependencies are missing; install "ls-algorithm-plugin-sdk[service]"'
506
+ ) from exc
507
+
508
+ # FastAPI resolves postponed annotations through module globals. Request is
509
+ # imported lazily so the base SDK keeps FastAPI optional; expose it here
510
+ # before route decorators inspect the endpoint signatures.
511
+ globals()["Request"] = Request
512
+
513
+ @asynccontextmanager
514
+ async def lifespan(_: Any):
515
+ manager.start()
516
+ if lifecycle is not None:
517
+ lifecycle.start()
518
+ announce_webui(application)
519
+ try:
520
+ yield
521
+ finally:
522
+ if lifecycle is not None:
523
+ lifecycle.stop()
524
+ manager.close()
525
+
526
+ application = FastAPI(
527
+ title="Algorithm Plugin Service",
528
+ version="0.1.0",
529
+ lifespan=lifespan,
530
+ )
531
+
532
+ @application.middleware("http")
533
+ async def authenticate(request: Request, call_next: Any) -> Any:
534
+ public_paths = {"/v1/health", "/v1/metadata"}
535
+ if (
536
+ token
537
+ and request.url.path.startswith("/v1/")
538
+ and request.url.path not in public_paths
539
+ and not hmac.compare_digest(
540
+ request.headers.get("X-LDP-Plugin-Token", ""), token
541
+ )
542
+ ):
543
+ return JSONResponse(status_code=401, content={"error": "unauthorized"})
544
+ return await call_next(request)
545
+
546
+ @application.exception_handler(ExecutionNotFound)
547
+ async def handle_not_found(_: Request, exc: ExecutionNotFound) -> JSONResponse:
548
+ return JSONResponse(
549
+ status_code=404,
550
+ content={"error": f"execution not found: {exc.args[0]}"},
551
+ )
552
+
553
+ @application.exception_handler(ExecutionNotFinished)
554
+ async def handle_not_finished(_: Request, exc: ExecutionNotFinished) -> JSONResponse:
555
+ return JSONResponse(
556
+ status_code=409,
557
+ content={"error": f"execution is not finished: {exc.args[0]}"},
558
+ )
559
+
560
+ @application.exception_handler(IdempotencyConflict)
561
+ async def handle_idempotency_conflict(
562
+ _: Request, exc: IdempotencyConflict
563
+ ) -> JSONResponse:
564
+ return JSONResponse(
565
+ status_code=409,
566
+ content={"error": f"idempotency key reused with another request: {exc.args[0]}"},
567
+ )
568
+
569
+ @application.get("/v1/metadata")
570
+ def metadata() -> dict[str, Any]:
571
+ return manager.runner.algorithm.metadata().to_dict()
572
+
573
+ @application.get("/v1/health")
574
+ def health() -> dict[str, str]:
575
+ return {"status": "ready"}
576
+
577
+ @application.get("/v1/manifest")
578
+ def manifest() -> dict[str, Any]:
579
+ return manager.manifest()
580
+
581
+ @application.get("/v1/heartbeat")
582
+ def heartbeat() -> dict[str, Any]:
583
+ return manager.heartbeat()
584
+
585
+ @application.post("/v1/executions", status_code=202)
586
+ async def create_execution(request: Request) -> Any:
587
+ try:
588
+ body = await request.json()
589
+ execution_request = AlgorithmRequest.from_dict(body)
590
+ if execution_request.workspace is None:
591
+ raise ValueError("workspace is required")
592
+ except Exception as exc:
593
+ return JSONResponse(
594
+ status_code=422,
595
+ content={"error": f"{type(exc).__name__}: {exc}"},
596
+ )
597
+ return manager.submit(
598
+ execution_request,
599
+ idempotency_key=request.headers.get("Idempotency-Key"),
600
+ )
601
+
602
+ @application.get("/v1/executions/{execution_id}")
603
+ def execution_status(execution_id: str) -> dict[str, Any]:
604
+ return manager.status(execution_id)
605
+
606
+ @application.get("/v1/executions/{execution_id}/events")
607
+ def execution_events(
608
+ execution_id: str,
609
+ after_sequence: int = Query(0, alias="afterSequence", ge=0),
610
+ wait_seconds: float = Query(0, alias="waitSeconds", ge=0, le=30),
611
+ ) -> dict[str, Any]:
612
+ return manager.events(
613
+ execution_id,
614
+ after_sequence=after_sequence,
615
+ wait_seconds=wait_seconds,
616
+ )
617
+
618
+ @application.get("/v1/executions/{execution_id}/result")
619
+ def execution_result(execution_id: str) -> dict[str, Any]:
620
+ return manager.result(execution_id)
621
+
622
+ @application.post("/v1/executions/{execution_id}:cancel", status_code=202)
623
+ async def cancel_execution(execution_id: str, request: Request) -> dict[str, Any]:
624
+ try:
625
+ body = await request.json()
626
+ except Exception:
627
+ body = {}
628
+ return manager.cancel(execution_id, str(body.get("reason", "cancelled by user")))
629
+
630
+ if webui:
631
+ mount_webui(application, webui_url=webui_url)
632
+
633
+ application.state.execution_manager = manager
634
+ return application
635
+
636
+
637
+ def run_server(
638
+ manager: ExecutionManager,
639
+ *,
640
+ host: str = "0.0.0.0",
641
+ port: int = 8000,
642
+ webui: bool = False,
643
+ token: str | None = None,
644
+ lifecycle: Any | None = None,
645
+ ) -> None:
646
+ try:
647
+ import uvicorn
648
+ except ImportError as exc:
649
+ raise RuntimeError(
650
+ 'HTTP service dependencies are missing; install "ls-algorithm-plugin-sdk[service]"'
651
+ ) from exc
652
+ display_host = "127.0.0.1" if host in {"0.0.0.0", "::"} else host
653
+ webui_url = f"http://{display_host}:{port}/ui/" if webui else None
654
+ uvicorn.run(
655
+ create_app(
656
+ manager,
657
+ webui=webui,
658
+ webui_url=webui_url,
659
+ token=token,
660
+ lifecycle=lifecycle,
661
+ ),
662
+ host=host,
663
+ port=port,
664
+ )
@@ -0,0 +1 @@
1
+ """Static assets for the optional internal processing UI."""