labtasker-client 2.0.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.
- labtasker/__init__.py +77 -0
- labtasker/__main__.py +3 -0
- labtasker/api.py +122 -0
- labtasker/binding.py +184 -0
- labtasker/cli.py +506 -0
- labtasker/client.py +725 -0
- labtasker/command_template.py +173 -0
- labtasker/command_worker.py +492 -0
- labtasker/config.py +193 -0
- labtasker/errors.py +56 -0
- labtasker/execution.py +380 -0
- labtasker/journal.py +314 -0
- labtasker/local.py +190 -0
- labtasker/models.py +218 -0
- labtasker/paths.py +34 -0
- labtasker/py.typed +1 -0
- labtasker/tee.py +128 -0
- labtasker/types.py +31 -0
- labtasker/validation.py +207 -0
- labtasker/worker.py +473 -0
- labtasker_client-2.0.0.dist-info/METADATA +13 -0
- labtasker_client-2.0.0.dist-info/RECORD +25 -0
- labtasker_client-2.0.0.dist-info/WHEEL +4 -0
- labtasker_client-2.0.0.dist-info/entry_points.txt +2 -0
- labtasker_client-2.0.0.dist-info/licenses/LICENSE +201 -0
labtasker/journal.py
ADDED
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import tempfile
|
|
6
|
+
import threading
|
|
7
|
+
from datetime import UTC, datetime
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Literal, TypedDict, cast
|
|
10
|
+
|
|
11
|
+
from labtasker.config import EndpointRecord
|
|
12
|
+
from labtasker.models import ClaimResponse
|
|
13
|
+
from labtasker.types import JSONValue
|
|
14
|
+
from labtasker.validation import (
|
|
15
|
+
validate_identifier,
|
|
16
|
+
validate_int64,
|
|
17
|
+
validate_run_id,
|
|
18
|
+
validate_task_id,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
JournalPhase = Literal["running", "reporting", "acknowledged", "revoked"]
|
|
22
|
+
TerminalAction = Literal["complete", "fail", "unclaim"]
|
|
23
|
+
LOCAL_GITIGNORE = "*\n!.gitignore\n"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class RunRecord(TypedDict):
|
|
27
|
+
schema_version: int
|
|
28
|
+
endpoint: EndpointRecord
|
|
29
|
+
queue: str
|
|
30
|
+
task_id: str
|
|
31
|
+
run_id: str
|
|
32
|
+
route: str
|
|
33
|
+
attempt: int
|
|
34
|
+
started_at: str
|
|
35
|
+
finished_at: str | None
|
|
36
|
+
phase: JournalPhase
|
|
37
|
+
terminal_action: TerminalAction | None
|
|
38
|
+
acknowledged_at: str | None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class LocalRunJournal:
|
|
42
|
+
def __init__(self, run_dir: Path, record: RunRecord) -> None:
|
|
43
|
+
self.run_dir = run_dir
|
|
44
|
+
self._record = record
|
|
45
|
+
self._lock = threading.Lock()
|
|
46
|
+
|
|
47
|
+
@classmethod
|
|
48
|
+
def create(
|
|
49
|
+
cls,
|
|
50
|
+
*,
|
|
51
|
+
claim: ClaimResponse,
|
|
52
|
+
endpoint: EndpointRecord,
|
|
53
|
+
queue: str,
|
|
54
|
+
route: str,
|
|
55
|
+
cwd: Path | None = None,
|
|
56
|
+
) -> LocalRunJournal:
|
|
57
|
+
started_at = claim.task.started_at
|
|
58
|
+
if started_at is None:
|
|
59
|
+
raise ValueError("claimed Task is missing started_at")
|
|
60
|
+
root = (Path.cwd() if cwd is None else cwd).resolve()
|
|
61
|
+
task_group = f"{_task_slug(claim.task.name)}__{claim.task.id}"
|
|
62
|
+
run_name = (
|
|
63
|
+
f"{started_at.astimezone(UTC):%Y%m%dT%H%M%SZ}"
|
|
64
|
+
f"__attempt-{claim.task.attempt}__{claim.run_id}"
|
|
65
|
+
)
|
|
66
|
+
labtasker_dir = root / ".labtasker"
|
|
67
|
+
labtasker_dir.mkdir(parents=True, exist_ok=True)
|
|
68
|
+
_ensure_local_gitignore(labtasker_dir)
|
|
69
|
+
run_dir = labtasker_dir / "runs" / queue / task_group / run_name
|
|
70
|
+
run_dir.mkdir(parents=True, exist_ok=False)
|
|
71
|
+
record: RunRecord = {
|
|
72
|
+
"schema_version": 1,
|
|
73
|
+
"endpoint": endpoint,
|
|
74
|
+
"queue": queue,
|
|
75
|
+
"task_id": claim.task.id,
|
|
76
|
+
"run_id": claim.run_id,
|
|
77
|
+
"route": route,
|
|
78
|
+
"attempt": claim.task.attempt,
|
|
79
|
+
"started_at": _timestamp(started_at),
|
|
80
|
+
"finished_at": None,
|
|
81
|
+
"phase": "running",
|
|
82
|
+
"terminal_action": None,
|
|
83
|
+
"acknowledged_at": None,
|
|
84
|
+
}
|
|
85
|
+
journal = cls(run_dir.resolve(), record)
|
|
86
|
+
_atomic_json(journal.task_path, claim.task.model_dump(mode="json"))
|
|
87
|
+
_atomic_json(journal.run_path, record)
|
|
88
|
+
journal.log_path.touch(exist_ok=False)
|
|
89
|
+
return journal
|
|
90
|
+
|
|
91
|
+
@classmethod
|
|
92
|
+
def open(cls, run_dir: Path) -> LocalRunJournal:
|
|
93
|
+
resolved = run_dir.resolve()
|
|
94
|
+
parsed = json.loads((resolved / "run.json").read_text(encoding="utf-8"))
|
|
95
|
+
return cls(resolved, _validate_record(parsed))
|
|
96
|
+
|
|
97
|
+
@property
|
|
98
|
+
def endpoint(self) -> EndpointRecord:
|
|
99
|
+
return self._record["endpoint"]
|
|
100
|
+
|
|
101
|
+
@property
|
|
102
|
+
def queue(self) -> str:
|
|
103
|
+
return self._record["queue"]
|
|
104
|
+
|
|
105
|
+
@property
|
|
106
|
+
def task_id(self) -> str:
|
|
107
|
+
return self._record["task_id"]
|
|
108
|
+
|
|
109
|
+
@property
|
|
110
|
+
def run_id(self) -> str:
|
|
111
|
+
return self._record["run_id"]
|
|
112
|
+
|
|
113
|
+
@property
|
|
114
|
+
def route(self) -> str:
|
|
115
|
+
return self._record["route"]
|
|
116
|
+
|
|
117
|
+
@property
|
|
118
|
+
def task_path(self) -> Path:
|
|
119
|
+
return self.run_dir / "task.json"
|
|
120
|
+
|
|
121
|
+
@property
|
|
122
|
+
def run_path(self) -> Path:
|
|
123
|
+
return self.run_dir / "run.json"
|
|
124
|
+
|
|
125
|
+
@property
|
|
126
|
+
def result_path(self) -> Path:
|
|
127
|
+
return self.run_dir / "result.json"
|
|
128
|
+
|
|
129
|
+
@property
|
|
130
|
+
def error_path(self) -> Path:
|
|
131
|
+
return self.run_dir / "error.json"
|
|
132
|
+
|
|
133
|
+
@property
|
|
134
|
+
def log_path(self) -> Path:
|
|
135
|
+
return self.run_dir / "run.log"
|
|
136
|
+
|
|
137
|
+
@property
|
|
138
|
+
def phase(self) -> JournalPhase:
|
|
139
|
+
return self._record["phase"]
|
|
140
|
+
|
|
141
|
+
@property
|
|
142
|
+
def terminal_action(self) -> TerminalAction | None:
|
|
143
|
+
return self._record["terminal_action"]
|
|
144
|
+
|
|
145
|
+
def read_result(self) -> dict[str, JSONValue]:
|
|
146
|
+
parsed = json.loads(self.result_path.read_text(encoding="utf-8"))
|
|
147
|
+
if not isinstance(parsed, dict):
|
|
148
|
+
raise ValueError("result.json must be an object")
|
|
149
|
+
return cast(dict[str, JSONValue], parsed)
|
|
150
|
+
|
|
151
|
+
def reporting(
|
|
152
|
+
self,
|
|
153
|
+
action: TerminalAction,
|
|
154
|
+
payload: dict[str, JSONValue] | None = None,
|
|
155
|
+
) -> None:
|
|
156
|
+
with self._lock:
|
|
157
|
+
if action == "complete":
|
|
158
|
+
if payload is None:
|
|
159
|
+
raise ValueError("complete journal entry requires a result")
|
|
160
|
+
_atomic_json_once(self.result_path, payload)
|
|
161
|
+
elif action == "fail":
|
|
162
|
+
if payload is None:
|
|
163
|
+
raise ValueError("fail journal entry requires an error")
|
|
164
|
+
_atomic_json_once(self.error_path, payload)
|
|
165
|
+
elif payload is not None:
|
|
166
|
+
raise ValueError("unclaim journal entry cannot have a payload")
|
|
167
|
+
self._record["phase"] = "reporting"
|
|
168
|
+
self._record["terminal_action"] = action
|
|
169
|
+
self._record["finished_at"] = None
|
|
170
|
+
self._record["acknowledged_at"] = None
|
|
171
|
+
_atomic_json(self.run_path, self._record)
|
|
172
|
+
|
|
173
|
+
def acknowledged(self) -> None:
|
|
174
|
+
now = _timestamp(datetime.now(UTC))
|
|
175
|
+
with self._lock:
|
|
176
|
+
self._record["phase"] = "acknowledged"
|
|
177
|
+
self._record["finished_at"] = now
|
|
178
|
+
self._record["acknowledged_at"] = now
|
|
179
|
+
_atomic_json(self.run_path, self._record)
|
|
180
|
+
|
|
181
|
+
def revoked(self) -> None:
|
|
182
|
+
with self._lock:
|
|
183
|
+
self._record["phase"] = "revoked"
|
|
184
|
+
self._record["finished_at"] = _timestamp(datetime.now(UTC))
|
|
185
|
+
self._record["acknowledged_at"] = None
|
|
186
|
+
_atomic_json(self.run_path, self._record)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _ensure_local_gitignore(labtasker_dir: Path) -> None:
|
|
190
|
+
try:
|
|
191
|
+
with (labtasker_dir / ".gitignore").open("x", encoding="utf-8", newline="\n") as stream:
|
|
192
|
+
stream.write(LOCAL_GITIGNORE)
|
|
193
|
+
except FileExistsError:
|
|
194
|
+
pass
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _task_slug(name: str | None) -> str:
|
|
198
|
+
source = name if name else "unnamed"
|
|
199
|
+
characters: list[str] = []
|
|
200
|
+
for character in source:
|
|
201
|
+
if character.isalnum():
|
|
202
|
+
characters.append(character)
|
|
203
|
+
elif characters and characters[-1] != "-":
|
|
204
|
+
characters.append("-")
|
|
205
|
+
slug = "".join(characters).strip("-") or "unnamed"
|
|
206
|
+
encoded = slug.encode("utf-8")
|
|
207
|
+
if len(encoded) <= 80:
|
|
208
|
+
return slug
|
|
209
|
+
prefix = encoded[:80]
|
|
210
|
+
while True:
|
|
211
|
+
try:
|
|
212
|
+
slug = prefix.decode("utf-8").rstrip("-")
|
|
213
|
+
break
|
|
214
|
+
except UnicodeDecodeError:
|
|
215
|
+
prefix = prefix[:-1]
|
|
216
|
+
return slug or "unnamed"
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _timestamp(value: datetime) -> str:
|
|
220
|
+
return value.astimezone(UTC).isoformat().replace("+00:00", "Z")
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _atomic_json(path: Path, value: object) -> None:
|
|
224
|
+
descriptor, temporary_name = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent)
|
|
225
|
+
temporary_path = Path(temporary_name)
|
|
226
|
+
try:
|
|
227
|
+
with os.fdopen(descriptor, "w", encoding="utf-8", newline="\n") as stream:
|
|
228
|
+
json.dump(value, stream, ensure_ascii=False, allow_nan=False, indent=2)
|
|
229
|
+
stream.write("\n")
|
|
230
|
+
stream.flush()
|
|
231
|
+
os.fsync(stream.fileno())
|
|
232
|
+
temporary_path.replace(path)
|
|
233
|
+
except BaseException:
|
|
234
|
+
temporary_path.unlink(missing_ok=True)
|
|
235
|
+
raise
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _atomic_json_once(path: Path, value: object) -> None:
|
|
239
|
+
descriptor, temporary_name = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent)
|
|
240
|
+
temporary_path = Path(temporary_name)
|
|
241
|
+
try:
|
|
242
|
+
with os.fdopen(descriptor, "w", encoding="utf-8", newline="\n") as stream:
|
|
243
|
+
json.dump(value, stream, ensure_ascii=False, allow_nan=False, indent=2)
|
|
244
|
+
stream.write("\n")
|
|
245
|
+
stream.flush()
|
|
246
|
+
os.fsync(stream.fileno())
|
|
247
|
+
try:
|
|
248
|
+
os.link(temporary_path, path)
|
|
249
|
+
except FileExistsError:
|
|
250
|
+
existing = json.loads(path.read_text(encoding="utf-8"))
|
|
251
|
+
if existing != value:
|
|
252
|
+
raise ValueError(
|
|
253
|
+
f"{path.name} already contains a different terminal payload"
|
|
254
|
+
) from None
|
|
255
|
+
finally:
|
|
256
|
+
temporary_path.unlink(missing_ok=True)
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _validate_record(value: object) -> RunRecord:
|
|
260
|
+
fields = {
|
|
261
|
+
"schema_version",
|
|
262
|
+
"endpoint",
|
|
263
|
+
"queue",
|
|
264
|
+
"task_id",
|
|
265
|
+
"run_id",
|
|
266
|
+
"route",
|
|
267
|
+
"attempt",
|
|
268
|
+
"started_at",
|
|
269
|
+
"finished_at",
|
|
270
|
+
"phase",
|
|
271
|
+
"terminal_action",
|
|
272
|
+
"acknowledged_at",
|
|
273
|
+
}
|
|
274
|
+
if not isinstance(value, dict) or set(value) != fields:
|
|
275
|
+
raise ValueError("run.json does not match journal schema version 1")
|
|
276
|
+
if value["schema_version"] != 1:
|
|
277
|
+
raise ValueError("run.json uses an unsupported journal schema")
|
|
278
|
+
_validate_endpoint(value["endpoint"])
|
|
279
|
+
validate_identifier(value["queue"], field="queue")
|
|
280
|
+
validate_task_id(value["task_id"])
|
|
281
|
+
validate_run_id(value["run_id"])
|
|
282
|
+
validate_identifier(value["route"], field="route")
|
|
283
|
+
validate_int64(value["attempt"], field="attempt", positive=True)
|
|
284
|
+
for field in ("started_at", "finished_at", "acknowledged_at"):
|
|
285
|
+
if value[field] is not None and not isinstance(value[field], str):
|
|
286
|
+
raise ValueError(f"run.json {field} must be a timestamp string or null")
|
|
287
|
+
if value["phase"] not in {"running", "reporting", "acknowledged", "revoked"}:
|
|
288
|
+
raise ValueError("run.json phase is invalid")
|
|
289
|
+
if value["terminal_action"] not in {None, "complete", "fail", "unclaim"}:
|
|
290
|
+
raise ValueError("run.json terminal_action is invalid")
|
|
291
|
+
return cast(RunRecord, value)
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def _validate_endpoint(value: object) -> EndpointRecord:
|
|
295
|
+
fields = {"mode", "url", "socket", "directory", "database"}
|
|
296
|
+
if not isinstance(value, dict) or set(value) != fields:
|
|
297
|
+
raise ValueError("run.json endpoint is invalid")
|
|
298
|
+
mode = value["mode"]
|
|
299
|
+
if mode == "http":
|
|
300
|
+
if not isinstance(value["url"], str) or not value["url"]:
|
|
301
|
+
raise ValueError("run.json HTTP endpoint URL is invalid")
|
|
302
|
+
if any(value[field] is not None for field in ("socket", "directory", "database")):
|
|
303
|
+
raise ValueError("run.json HTTP endpoint contains local paths")
|
|
304
|
+
elif mode == "local":
|
|
305
|
+
if value["url"] is not None:
|
|
306
|
+
raise ValueError("run.json local endpoint contains a URL")
|
|
307
|
+
if any(
|
|
308
|
+
not isinstance(value[field], str) or not value[field]
|
|
309
|
+
for field in ("socket", "directory", "database")
|
|
310
|
+
):
|
|
311
|
+
raise ValueError("run.json local endpoint paths are invalid")
|
|
312
|
+
else:
|
|
313
|
+
raise ValueError("run.json endpoint mode is invalid")
|
|
314
|
+
return cast(EndpointRecord, value)
|
labtasker/local.py
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import importlib.util
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import socket
|
|
8
|
+
import subprocess
|
|
9
|
+
import sys
|
|
10
|
+
from collections.abc import Callable
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
import httpx
|
|
15
|
+
|
|
16
|
+
from labtasker.errors import ConfigError, TransportError
|
|
17
|
+
|
|
18
|
+
COORDINATOR_TIMEOUT_SECONDS = 35.0
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True, slots=True)
|
|
22
|
+
class LocalPaths:
|
|
23
|
+
directory: Path
|
|
24
|
+
database: Path
|
|
25
|
+
log: Path
|
|
26
|
+
runtime_directory: Path
|
|
27
|
+
socket: Path
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True, slots=True)
|
|
31
|
+
class LocalEnsureResult:
|
|
32
|
+
started: bool
|
|
33
|
+
pid: int | None
|
|
34
|
+
server_version: str | None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def require_local_capabilities() -> None:
|
|
38
|
+
if os.name != "posix" or not hasattr(socket, "AF_UNIX"):
|
|
39
|
+
raise ConfigError(
|
|
40
|
+
"invalid_config",
|
|
41
|
+
"Local mode requires POSIX Unix-domain sockets; configure a URL.",
|
|
42
|
+
{"source": "default", "field": "url"},
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def local_paths(directory: Path | None = None) -> LocalPaths:
|
|
47
|
+
canonical = (Path.cwd() if directory is None else directory).resolve()
|
|
48
|
+
digest = hashlib.sha256(os.fsencode(canonical)).hexdigest()
|
|
49
|
+
runtime_directory = Path("/tmp") / f"labtasker-{os.geteuid()}"
|
|
50
|
+
local_directory = canonical / ".labtasker"
|
|
51
|
+
return LocalPaths(
|
|
52
|
+
directory=canonical,
|
|
53
|
+
database=local_directory / "server.db",
|
|
54
|
+
log=local_directory / "server.log",
|
|
55
|
+
runtime_directory=runtime_directory,
|
|
56
|
+
socket=runtime_directory / f"{digest}.sock",
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def ensure_local_server(paths: LocalPaths, *, emit: Callable[[str], None]) -> LocalEnsureResult:
|
|
61
|
+
require_local_capabilities()
|
|
62
|
+
if socket_health(paths):
|
|
63
|
+
return LocalEnsureResult(started=False, pid=None, server_version=None)
|
|
64
|
+
if importlib.util.find_spec("labtasker_server") is None:
|
|
65
|
+
raise ConfigError(
|
|
66
|
+
"invalid_config",
|
|
67
|
+
"Local mode requires labtasker-server; install labtasker or configure a URL.",
|
|
68
|
+
{"source": "default", "field": "url"},
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
emit(f"requesting local daemon ensure directory={paths.directory} socket={paths.socket}")
|
|
72
|
+
try:
|
|
73
|
+
result = subprocess.run(
|
|
74
|
+
[
|
|
75
|
+
sys.executable,
|
|
76
|
+
"-m",
|
|
77
|
+
"labtasker_server",
|
|
78
|
+
"_ensure-daemon",
|
|
79
|
+
"--directory",
|
|
80
|
+
str(paths.directory),
|
|
81
|
+
],
|
|
82
|
+
cwd=paths.directory,
|
|
83
|
+
stdin=subprocess.DEVNULL,
|
|
84
|
+
stdout=subprocess.PIPE,
|
|
85
|
+
stderr=None,
|
|
86
|
+
text=True,
|
|
87
|
+
check=False,
|
|
88
|
+
timeout=COORDINATOR_TIMEOUT_SECONDS,
|
|
89
|
+
)
|
|
90
|
+
except subprocess.TimeoutExpired as error:
|
|
91
|
+
raise _local_transport_error(
|
|
92
|
+
paths,
|
|
93
|
+
state="starting",
|
|
94
|
+
message="The local Server coordinator did not finish within 35 seconds.",
|
|
95
|
+
) from error
|
|
96
|
+
except OSError as error:
|
|
97
|
+
raise _local_transport_error(
|
|
98
|
+
paths,
|
|
99
|
+
state="stopped",
|
|
100
|
+
message="The local Server coordinator could not be started.",
|
|
101
|
+
) from error
|
|
102
|
+
|
|
103
|
+
payload = _parse_coordinator_result(result.stdout)
|
|
104
|
+
if result.returncode != 0 or payload is None or payload.get("ok") is not True:
|
|
105
|
+
state = payload.get("state") if payload is not None else None
|
|
106
|
+
message = payload.get("message") if payload is not None else None
|
|
107
|
+
raise _local_transport_error(
|
|
108
|
+
paths,
|
|
109
|
+
state=state if isinstance(state, str) else "unhealthy",
|
|
110
|
+
message=(
|
|
111
|
+
message
|
|
112
|
+
if isinstance(message, str)
|
|
113
|
+
else "The local Server coordinator failed without a valid result."
|
|
114
|
+
),
|
|
115
|
+
retry_after_seconds=_optional_number(payload, "retry_after_seconds"),
|
|
116
|
+
)
|
|
117
|
+
if not socket_health(paths):
|
|
118
|
+
raise _local_transport_error(
|
|
119
|
+
paths,
|
|
120
|
+
state="unhealthy",
|
|
121
|
+
message="The local Server coordinator returned before its socket was healthy.",
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
pid = payload.get("pid")
|
|
125
|
+
version = payload.get("version")
|
|
126
|
+
started = payload.get("started")
|
|
127
|
+
return LocalEnsureResult(
|
|
128
|
+
started=started is True,
|
|
129
|
+
pid=pid if isinstance(pid, int) and not isinstance(pid, bool) and pid > 0 else None,
|
|
130
|
+
server_version=version if isinstance(version, str) else None,
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def socket_transport(paths: LocalPaths) -> httpx.HTTPTransport:
|
|
135
|
+
return httpx.HTTPTransport(uds=str(paths.socket))
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def socket_health(paths: LocalPaths, *, timeout: float = 0.2) -> bool:
|
|
139
|
+
try:
|
|
140
|
+
with httpx.Client(
|
|
141
|
+
transport=socket_transport(paths),
|
|
142
|
+
base_url="http://labtasker",
|
|
143
|
+
timeout=timeout,
|
|
144
|
+
) as client:
|
|
145
|
+
response = client.get("/health")
|
|
146
|
+
return response.status_code == 200 and response.json() == {
|
|
147
|
+
"status": "ok",
|
|
148
|
+
"api_version": "2",
|
|
149
|
+
"database": "ok",
|
|
150
|
+
}
|
|
151
|
+
except (httpx.HTTPError, ValueError):
|
|
152
|
+
return False
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _parse_coordinator_result(output: str) -> dict[str, object] | None:
|
|
156
|
+
try:
|
|
157
|
+
payload = json.loads(output)
|
|
158
|
+
except (json.JSONDecodeError, TypeError):
|
|
159
|
+
return None
|
|
160
|
+
if not isinstance(payload, dict):
|
|
161
|
+
return None
|
|
162
|
+
return {str(key): value for key, value in payload.items()}
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _optional_number(payload: dict[str, object] | None, field: str) -> float | None:
|
|
166
|
+
if payload is None:
|
|
167
|
+
return None
|
|
168
|
+
value = payload.get(field)
|
|
169
|
+
if isinstance(value, (int, float)) and not isinstance(value, bool):
|
|
170
|
+
return float(value)
|
|
171
|
+
return None
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _local_transport_error(
|
|
175
|
+
paths: LocalPaths,
|
|
176
|
+
*,
|
|
177
|
+
state: str,
|
|
178
|
+
message: str,
|
|
179
|
+
retry_after_seconds: float | None = None,
|
|
180
|
+
) -> TransportError:
|
|
181
|
+
details: dict[str, object] = {
|
|
182
|
+
"state": state,
|
|
183
|
+
"directory": str(paths.directory),
|
|
184
|
+
"database": str(paths.database),
|
|
185
|
+
"socket": str(paths.socket),
|
|
186
|
+
"log": str(paths.log),
|
|
187
|
+
}
|
|
188
|
+
if retry_after_seconds is not None:
|
|
189
|
+
details["retry_after_seconds"] = retry_after_seconds
|
|
190
|
+
return TransportError(message, details)
|