labtasker-webui 0.1.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.
@@ -0,0 +1,3 @@
1
+ """Labtasker WebUI."""
2
+
3
+ __version__ = "0.1.1"
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ main()
labtasker_webui/app.py ADDED
@@ -0,0 +1,503 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ from pathlib import Path
5
+ from typing import Annotated, Any, cast
6
+ from urllib.parse import quote
7
+
8
+ from fastapi import Cookie, FastAPI, HTTPException, Query, Request, Response
9
+ from fastapi.exceptions import RequestValidationError
10
+ from fastapi.responses import FileResponse, JSONResponse
11
+ from fastapi.staticfiles import StaticFiles
12
+ from pydantic import TypeAdapter, ValidationError
13
+
14
+ from .config import Settings, is_loopback_host
15
+ from .local import local_connection
16
+ from .operations import OperationStore
17
+ from .profile import Profile
18
+ from .schemas import (
19
+ BatchRequest,
20
+ ConnectRequest,
21
+ CountResponse,
22
+ QueueResponse,
23
+ SelectRequest,
24
+ TaskOrderField,
25
+ TaskPageResponse,
26
+ TaskResponse,
27
+ TaskStatus,
28
+ )
29
+ from .security import DestinationBlocked, validate_server_url
30
+ from .sessions import Connection, SessionStore
31
+ from .upstream import Upstream, UpstreamError
32
+
33
+ COOKIE = "labtasker_webui_session"
34
+ STATUSES = ("pending", "running", "succeeded", "failed", "cancelled")
35
+ QUEUES_ADAPTER = TypeAdapter(list[QueueResponse])
36
+
37
+
38
+ def validated(value: Any, model: Any) -> Any:
39
+ try:
40
+ if isinstance(model, TypeAdapter):
41
+ parsed = model.validate_python(value)
42
+ return model.dump_python(parsed, mode="json")
43
+ parsed = model.model_validate(value)
44
+ return parsed.model_dump(mode="json")
45
+ except ValidationError as exc:
46
+ raise UpstreamError(
47
+ 502,
48
+ "malformed_upstream",
49
+ "Labtasker Server returned a response that does not match API v2.",
50
+ {"errors": exc.errors(include_input=False)},
51
+ ) from exc
52
+
53
+
54
+ def create_app(settings: Settings | None = None) -> FastAPI:
55
+ settings = settings or Settings()
56
+ local = (
57
+ local_connection(settings.local_directory) if settings.local_directory is not None else None
58
+ )
59
+ locked = settings.server_url is not None or local is not None
60
+ profile = Profile(settings.profile_path)
61
+ sessions = SessionStore()
62
+ upstream = Upstream(settings.allowed_server_origins, strict=not is_loopback_host(settings.host))
63
+ operations = OperationStore(upstream)
64
+ app = FastAPI(title="Labtasker WebUI", version="0.1.0", docs_url=None, redoc_url=None)
65
+
66
+ @app.middleware("http")
67
+ async def enforce_same_origin(request: Request, call_next: Any) -> Response:
68
+ origin = request.headers.get("origin")
69
+ if origin and request.method not in {"GET", "HEAD", "OPTIONS"}:
70
+ try:
71
+ origin_value = validate_server_url(origin, (), strict=False)
72
+ webui_value = validate_server_url(str(request.base_url), (), strict=False)
73
+ except DestinationBlocked:
74
+ origin_value = ""
75
+ webui_value = "different"
76
+ if origin_value != webui_value:
77
+ return JSONResponse(
78
+ status_code=403,
79
+ content={
80
+ "error": {
81
+ "code": "cross_origin_request_blocked",
82
+ "message": "The WebUI rejected a cross-origin write request.",
83
+ "details": {},
84
+ }
85
+ },
86
+ )
87
+ return cast(Response, await call_next(request))
88
+
89
+ @app.exception_handler(UpstreamError)
90
+ async def upstream_error(_: Request, exc: UpstreamError) -> JSONResponse:
91
+ return JSONResponse(
92
+ content={"error": {"code": exc.code, "message": exc.message, "details": exc.details}},
93
+ status_code=exc.status,
94
+ )
95
+
96
+ @app.exception_handler(DestinationBlocked)
97
+ async def blocked(_: Request, exc: DestinationBlocked) -> JSONResponse:
98
+ return JSONResponse(
99
+ content={"error": {"code": "destination_blocked", "message": str(exc), "details": {}}},
100
+ status_code=400,
101
+ )
102
+
103
+ @app.exception_handler(HTTPException)
104
+ async def webui_http_error(_: Request, exc: HTTPException) -> JSONResponse:
105
+ details: Any
106
+ if isinstance(exc.detail, dict):
107
+ code = str(exc.detail.get("code", "webui_error"))
108
+ message = str(exc.detail.get("message", "The WebUI request failed."))
109
+ details = exc.detail.get("details", {})
110
+ else:
111
+ code = "webui_error"
112
+ message = str(exc.detail)
113
+ details = {}
114
+ return JSONResponse(
115
+ content={"error": {"code": code, "message": message, "details": details}},
116
+ status_code=exc.status_code,
117
+ )
118
+
119
+ @app.exception_handler(RequestValidationError)
120
+ async def webui_validation_error(_: Request, exc: RequestValidationError) -> JSONResponse:
121
+ return JSONResponse(
122
+ content={
123
+ "error": {
124
+ "code": "invalid_request",
125
+ "message": "The WebUI request is invalid.",
126
+ "details": {"errors": exc.errors()},
127
+ }
128
+ },
129
+ status_code=422,
130
+ )
131
+
132
+ def attach_local(directory: str) -> Connection:
133
+ if not is_loopback_host(settings.host):
134
+ raise UpstreamError(
135
+ 403, "local_forbidden", "Local connections require a loopback WebUI bind."
136
+ )
137
+ path = Path(directory).expanduser().resolve()
138
+ if not path.is_dir():
139
+ raise UpstreamError(
140
+ 422, "local_directory_missing", "The local project directory does not exist."
141
+ )
142
+ try:
143
+ return local_connection(path)
144
+ except (ValueError, ImportError) as exc:
145
+ raise UpstreamError(422, "local_unavailable", str(exc)) from exc
146
+
147
+ def connection(session_id: str | None) -> Connection:
148
+ if local is not None:
149
+ return local
150
+ if settings.server_url:
151
+ return Connection(settings.server_url, settings.server_token, 0)
152
+ item = sessions.get(session_id)
153
+ if not item and profile.path and profile.data.get("connection"):
154
+ saved = profile.data["connection"]
155
+ item = (
156
+ attach_local(saved["server_url"][6:])
157
+ if saved["server_url"].startswith("local:")
158
+ else Connection(saved["server_url"], saved.get("token"), 0)
159
+ )
160
+ if not item:
161
+ raise HTTPException(
162
+ 401, {"code": "connection_required", "message": "Connect to a Labtasker Server."}
163
+ )
164
+ return item
165
+
166
+ @app.get("/api/webui/status")
167
+ async def status(
168
+ session_id: Annotated[str | None, Cookie(alias=COOKIE)] = None,
169
+ ) -> dict[str, Any]:
170
+ item = None
171
+ connection_error = None
172
+ try:
173
+ if (
174
+ locked
175
+ or sessions.get(session_id)
176
+ or (profile.path and profile.data.get("connection"))
177
+ ):
178
+ item = connection(session_id)
179
+ if item and (locked or item.socket_path or not sessions.get(session_id)):
180
+ await upstream.verify(item)
181
+ except UpstreamError as exc:
182
+ connection_error = {"code": exc.code, "message": exc.message}
183
+ return {
184
+ "connected": item is not None and connection_error is None,
185
+ "locked": locked,
186
+ "mode": "local" if item and item.socket_path else "http",
187
+ "server_url": item.server_url if item else None,
188
+ "connection_error": connection_error,
189
+ }
190
+
191
+ @app.post("/api/webui/connect")
192
+ async def connect(
193
+ payload: ConnectRequest, request: Request, response: Response
194
+ ) -> dict[str, Any]:
195
+ if locked:
196
+ raise HTTPException(409, "Connection is locked by deployment configuration.")
197
+ if payload.mode == "local":
198
+ if payload.token:
199
+ raise HTTPException(422, "Local connections do not use a token.")
200
+ candidate = attach_local(payload.directory)
201
+ else:
202
+ url = validate_server_url(
203
+ payload.server_url,
204
+ settings.allowed_server_origins,
205
+ strict=not is_loopback_host(settings.host),
206
+ )
207
+ candidate = Connection(url, payload.token or None, 0)
208
+ health = await upstream.verify(candidate)
209
+ if profile.path:
210
+ profile.set_connection(candidate.server_url, candidate.token)
211
+ session_id = sessions.create(candidate.server_url, candidate.token, candidate.socket_path)
212
+ response.set_cookie(
213
+ COOKIE,
214
+ session_id,
215
+ httponly=True,
216
+ samesite="strict",
217
+ secure=request.url.scheme == "https",
218
+ max_age=12 * 60 * 60,
219
+ )
220
+ return {
221
+ "connected": True,
222
+ "server_url": candidate.server_url,
223
+ "api_version": health.get("api_version"),
224
+ }
225
+
226
+ @app.delete("/api/webui/connect", status_code=204)
227
+ async def disconnect(
228
+ response: Response, session_id: Annotated[str | None, Cookie(alias=COOKIE)] = None
229
+ ) -> None:
230
+ if locked:
231
+ raise HTTPException(409, "Connection is locked by deployment configuration.")
232
+ sessions.delete(session_id)
233
+ if profile.path:
234
+ profile.set_connection(None)
235
+ response.delete_cookie(COOKIE)
236
+
237
+ @app.get("/api/webui/profile")
238
+ async def get_profile() -> dict[str, Any]:
239
+ return {
240
+ "enabled": profile.path is not None,
241
+ "ui": profile.data["ui"] if profile.path else {},
242
+ }
243
+
244
+ @app.patch("/api/webui/profile")
245
+ async def update_profile(values: dict[str, str]) -> dict[str, bool]:
246
+ if not profile.path:
247
+ raise HTTPException(409, "Project profile is disabled.")
248
+ try:
249
+ profile.update_ui(values)
250
+ except ValueError as exc:
251
+ raise HTTPException(422, str(exc)) from exc
252
+ return {"saved": True}
253
+
254
+ @app.get("/api/webui/queues")
255
+ async def queues(
256
+ session_id: Annotated[str | None, Cookie(alias=COOKIE)] = None,
257
+ ) -> list[dict[str, Any]]:
258
+ conn = connection(session_id)
259
+ queue_list = validated(
260
+ await upstream.request(conn, "GET", "/api/v2/queues"),
261
+ QUEUES_ADAPTER,
262
+ )
263
+ semaphore = asyncio.Semaphore(6)
264
+
265
+ async def limited_request(path: str, params: dict[str, Any]) -> Any:
266
+ async with semaphore:
267
+ return await upstream.request(conn, "GET", path, params=params)
268
+
269
+ async def summarize(item: dict[str, str]) -> dict[str, Any]:
270
+ name = item["name"]
271
+ path = f"/api/v2/queues/{quote(name, safe='')}/tasks"
272
+ values = await asyncio.gather(
273
+ *(limited_request(path + "/count", {"status": value}) for value in STATUSES),
274
+ limited_request(
275
+ path,
276
+ {"limit": 1, "order_by": "updated_at", "descending": "true"},
277
+ ),
278
+ )
279
+ counts = {
280
+ state: validated(values[index], CountResponse)["count"]
281
+ for index, state in enumerate(STATUSES)
282
+ }
283
+ recent_page = validated(values[-1], TaskPageResponse)
284
+ recent_items = recent_page["items"]
285
+ return {
286
+ "name": name,
287
+ "counts": counts,
288
+ "recent": recent_items[0] if recent_items else None,
289
+ }
290
+
291
+ return await asyncio.gather(*(summarize(item) for item in queue_list))
292
+
293
+ @app.get("/api/webui/queues/{queue}/tasks")
294
+ async def list_tasks(
295
+ queue: str,
296
+ session_id: Annotated[str | None, Cookie(alias=COOKIE)] = None,
297
+ status: TaskStatus | None = None,
298
+ name: str | None = None,
299
+ filter_expression: Annotated[str | None, Query(alias="filter")] = None,
300
+ order_by: TaskOrderField = "created_at",
301
+ descending: bool = True,
302
+ cursor: str | None = None,
303
+ ) -> Any:
304
+ params = {"limit": 100, "order_by": order_by, "descending": str(descending).lower()}
305
+ params.update(
306
+ {
307
+ k: v
308
+ for k, v in {
309
+ "status": status,
310
+ "name": name,
311
+ "filter": filter_expression,
312
+ "cursor": cursor,
313
+ }.items()
314
+ if v
315
+ }
316
+ )
317
+ return validated(
318
+ await upstream.request(
319
+ connection(session_id),
320
+ "GET",
321
+ f"/api/v2/queues/{quote(queue, safe='')}/tasks",
322
+ params=params,
323
+ ),
324
+ TaskPageResponse,
325
+ )
326
+
327
+ @app.get("/api/webui/queues/{queue}/tasks/count")
328
+ async def count_tasks(
329
+ queue: str,
330
+ session_id: Annotated[str | None, Cookie(alias=COOKIE)] = None,
331
+ status: TaskStatus | None = None,
332
+ name: str | None = None,
333
+ filter_expression: Annotated[str | None, Query(alias="filter")] = None,
334
+ ) -> Any:
335
+ params = {
336
+ k: v
337
+ for k, v in {"status": status, "name": name, "filter": filter_expression}.items()
338
+ if v
339
+ }
340
+ return validated(
341
+ await upstream.request(
342
+ connection(session_id),
343
+ "GET",
344
+ f"/api/v2/queues/{quote(queue, safe='')}/tasks/count",
345
+ params=params,
346
+ ),
347
+ CountResponse,
348
+ )
349
+
350
+ @app.get("/api/webui/queues/{queue}/tasks/{task_id}")
351
+ async def get_task(
352
+ queue: str, task_id: str, session_id: Annotated[str | None, Cookie(alias=COOKIE)] = None
353
+ ) -> Any:
354
+ return validated(
355
+ await upstream.request(
356
+ connection(session_id),
357
+ "GET",
358
+ f"/api/v2/queues/{quote(queue, safe='')}/tasks/{quote(task_id, safe='')}",
359
+ ),
360
+ TaskResponse,
361
+ )
362
+
363
+ @app.post("/api/webui/queues/{queue}/tasks/{task_id}/{action}")
364
+ async def lifecycle(
365
+ queue: str,
366
+ task_id: str,
367
+ action: str,
368
+ session_id: Annotated[str | None, Cookie(alias=COOKIE)] = None,
369
+ ) -> Any:
370
+ if action not in {"cancel", "requeue"}:
371
+ raise HTTPException(404)
372
+ return validated(
373
+ await upstream.request(
374
+ connection(session_id),
375
+ "POST",
376
+ f"/api/v2/queues/{quote(queue, safe='')}/tasks/{quote(task_id, safe='')}/{action}",
377
+ ),
378
+ TaskResponse,
379
+ )
380
+
381
+ @app.post("/api/webui/queues/{queue}/delete-snapshot")
382
+ async def delete_snapshot(
383
+ queue: str,
384
+ selector: SelectRequest,
385
+ session_id: Annotated[str | None, Cookie(alias=COOKIE)] = None,
386
+ ) -> dict[str, Any]:
387
+ if not any([selector.status, selector.name, selector.filter and selector.filter.strip()]):
388
+ raise HTTPException(
389
+ 422,
390
+ {
391
+ "code": "selector_required",
392
+ "message": "At least one non-empty selector is required.",
393
+ },
394
+ )
395
+ conn = connection(session_id)
396
+ params = {
397
+ k: v
398
+ for k, v in {
399
+ "status": selector.status,
400
+ "name": selector.name,
401
+ "filter": selector.filter,
402
+ }.items()
403
+ if v
404
+ }
405
+ ids: list[str] = []
406
+ cursor: str | None = None
407
+ while True:
408
+ page_params = {**params, "limit": 1000}
409
+ if cursor:
410
+ page_params["cursor"] = cursor
411
+ page = validated(
412
+ await upstream.request(
413
+ conn,
414
+ "GET",
415
+ f"/api/v2/queues/{quote(queue, safe='')}/tasks",
416
+ params=page_params,
417
+ ),
418
+ TaskPageResponse,
419
+ )
420
+ ids.extend(item["id"] for item in page["items"])
421
+ if len(ids) > 1000:
422
+ raise HTTPException(
423
+ 422,
424
+ {
425
+ "code": "batch_too_large",
426
+ "message": "More than 1,000 Tasks match. Refine the filter.",
427
+ "details": {"limit": 1000},
428
+ },
429
+ )
430
+ cursor = page.get("next_cursor")
431
+ if not cursor:
432
+ break
433
+ return {
434
+ "queue": queue,
435
+ "task_ids": ids,
436
+ "count": len(ids),
437
+ "selector": selector.model_dump(),
438
+ }
439
+
440
+ @app.post("/api/webui/queues/{queue}/delete-operations", status_code=202)
441
+ async def start_delete(
442
+ queue: str,
443
+ payload: BatchRequest,
444
+ session_id: Annotated[str | None, Cookie(alias=COOKIE)] = None,
445
+ ) -> dict[str, Any]:
446
+ return operations.start(connection(session_id), queue, payload.task_ids).public()
447
+
448
+ @app.get("/api/webui/delete-operations/{operation_id}")
449
+ async def operation(
450
+ operation_id: str,
451
+ session_id: Annotated[str | None, Cookie(alias=COOKIE)] = None,
452
+ ) -> dict[str, Any]:
453
+ item = operations.get(operation_id, connection(session_id))
454
+ if not item:
455
+ raise HTTPException(
456
+ 404,
457
+ {
458
+ "code": "operation_history_lost",
459
+ "message": (
460
+ "Operation history was not found; it may have been lost after restart. "
461
+ "Re-run the current filter to inspect actual Server state."
462
+ ),
463
+ },
464
+ )
465
+ return item.public()
466
+
467
+ @app.post("/api/webui/delete-operations/{operation_id}/stop")
468
+ async def stop_operation(
469
+ operation_id: str,
470
+ session_id: Annotated[str | None, Cookie(alias=COOKIE)] = None,
471
+ ) -> dict[str, Any]:
472
+ item = operations.stop(operation_id, connection(session_id))
473
+ if not item:
474
+ raise HTTPException(404)
475
+ return item.public()
476
+
477
+ @app.post("/api/webui/delete-operations/{operation_id}/retry", status_code=202)
478
+ async def retry_operation(
479
+ operation_id: str,
480
+ session_id: Annotated[str | None, Cookie(alias=COOKIE)] = None,
481
+ ) -> dict[str, Any]:
482
+ item = operations.retry_failed(operation_id, connection(session_id))
483
+ if not item:
484
+ raise HTTPException(
485
+ 409,
486
+ "Only a completed operation with failures can be retried.",
487
+ )
488
+ return item.public()
489
+
490
+ static_dir = Path(__file__).with_name("static")
491
+ if not static_dir.exists():
492
+ static_dir = Path(__file__).resolve().parents[2] / "frontend" / "dist"
493
+ if static_dir.exists():
494
+ app.mount("/assets", StaticFiles(directory=static_dir / "assets"), name="assets")
495
+
496
+ @app.get("/{path:path}", include_in_schema=False)
497
+ async def frontend(path: str) -> FileResponse:
498
+ candidate = (static_dir / path).resolve()
499
+ if path and candidate.is_file() and static_dir.resolve() in candidate.parents:
500
+ return FileResponse(candidate)
501
+ return FileResponse(static_dir / "index.html")
502
+
503
+ return app
labtasker_webui/cli.py ADDED
@@ -0,0 +1,33 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import os
5
+ from pathlib import Path
6
+
7
+ import uvicorn
8
+
9
+ from .app import create_app
10
+ from .config import Settings, is_loopback_host
11
+
12
+
13
+ def main() -> None:
14
+ parser = argparse.ArgumentParser(
15
+ prog="labtasker-webui", description="Web interface for Labtasker v2"
16
+ )
17
+ parser.add_argument("--host", default=os.getenv("LABTASKER_WEBUI_HOST", "127.0.0.1"))
18
+ parser.add_argument("--port", type=int, default=int(os.getenv("LABTASKER_WEBUI_PORT", "8080")))
19
+ parser.add_argument(
20
+ "--no-profile", action="store_true", help="Disable project profile persistence"
21
+ )
22
+ args = parser.parse_args()
23
+ configured_origins = os.getenv("LABTASKER_WEBUI_ALLOWED_SERVER_ORIGINS", "")
24
+ origins = tuple(item.strip() for item in configured_origins.split(",") if item.strip())
25
+ settings = Settings(
26
+ host=args.host,
27
+ port=args.port,
28
+ allowed_server_origins=origins,
29
+ profile_path=Path.cwd() / ".labtasker" / "webui-profile.json"
30
+ if is_loopback_host(args.host) and not args.no_profile
31
+ else None,
32
+ )
33
+ uvicorn.run(create_app(settings), host=settings.host, port=settings.port)
@@ -0,0 +1,63 @@
1
+ from __future__ import annotations
2
+
3
+ import ipaddress
4
+ from dataclasses import dataclass
5
+ from pathlib import Path
6
+ from urllib.parse import urlsplit
7
+
8
+
9
+ def is_loopback_host(host: str) -> bool:
10
+ if host.lower() == "localhost":
11
+ return True
12
+ try:
13
+ return ipaddress.ip_address(host).is_loopback
14
+ except ValueError:
15
+ return False
16
+
17
+
18
+ def normalize_origin(value: str) -> str:
19
+ parsed = urlsplit(value)
20
+ if parsed.scheme not in {"http", "https"} or not parsed.hostname:
21
+ raise ValueError("Server origins must be absolute HTTP(S) URLs.")
22
+ if parsed.username or parsed.password or parsed.query or parsed.fragment:
23
+ raise ValueError("Server origins cannot contain credentials, query, or fragment.")
24
+ port = parsed.port or (443 if parsed.scheme == "https" else 80)
25
+ default = (parsed.scheme == "https" and port == 443) or (parsed.scheme == "http" and port == 80)
26
+ return f"{parsed.scheme}://{parsed.hostname.lower()}{'' if default else f':{port}'}"
27
+
28
+
29
+ @dataclass(frozen=True, slots=True)
30
+ class Settings:
31
+ host: str = "127.0.0.1"
32
+ port: int = 8080
33
+ server_url: str | None = None
34
+ server_token: str | None = None
35
+ allowed_server_origins: tuple[str, ...] = ()
36
+
37
+ profile_path: Path | None = None
38
+ local_directory: Path | None = None
39
+
40
+ def __post_init__(self) -> None:
41
+ if self.local_directory is not None:
42
+ if self.server_url or self.server_token:
43
+ raise ValueError("--local cannot be combined with a Server URL or token.")
44
+ if not is_loopback_host(self.host):
45
+ raise ValueError("Local attachment is available only on a loopback bind.")
46
+ directory = self.local_directory.resolve()
47
+ if not directory.is_dir():
48
+ raise ValueError("The local project directory does not exist.")
49
+ object.__setattr__(self, "local_directory", directory)
50
+ if not 1 <= self.port <= 65535:
51
+ raise ValueError("port must be between 1 and 65535.")
52
+ if self.profile_path and not is_loopback_host(self.host):
53
+ raise ValueError("Project profiles are available only on a loopback bind.")
54
+ origins = tuple(normalize_origin(item) for item in self.allowed_server_origins)
55
+ object.__setattr__(self, "allowed_server_origins", origins)
56
+ if not is_loopback_host(self.host) and not origins:
57
+ raise ValueError("A non-loopback bind requires --allowed-server-origin.")
58
+ if self.server_token and not self.server_url:
59
+ raise ValueError("LABTASKER_WEBUI_SERVER_TOKEN requires --server-url.")
60
+ if self.server_url:
61
+ origin = normalize_origin(self.server_url)
62
+ if not is_loopback_host(self.host) and origin not in origins:
63
+ raise ValueError("The configured Server URL must match the upstream allowlist.")
@@ -0,0 +1,22 @@
1
+ """Attach-only adapter for Labtasker's project-local transport."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import importlib
6
+ import os
7
+ from pathlib import Path
8
+
9
+ from .sessions import Connection
10
+
11
+
12
+ def local_connection(directory: Path) -> Connection:
13
+ if os.name != "posix":
14
+ raise ValueError("Local connections require POSIX Unix sockets.")
15
+ # Import only the pure path resolver. Never construct a Client or call ensure/start.
16
+ try:
17
+ local = importlib.import_module("labtasker.local")
18
+ except ImportError as exc:
19
+ raise ValueError("Local attachment requires labtasker-webui[local].") from exc
20
+ local.require_local_capabilities()
21
+ paths = local.local_paths(directory)
22
+ return Connection(f"local:{paths.directory}", None, 0, str(paths.socket))