experimaestro 2.0.0a8__py3-none-any.whl → 2.0.0b8__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.

Potentially problematic release.


This version of experimaestro might be problematic. Click here for more details.

Files changed (122) hide show
  1. experimaestro/__init__.py +10 -11
  2. experimaestro/annotations.py +167 -206
  3. experimaestro/cli/__init__.py +278 -7
  4. experimaestro/cli/filter.py +42 -74
  5. experimaestro/cli/jobs.py +157 -106
  6. experimaestro/cli/refactor.py +249 -0
  7. experimaestro/click.py +0 -1
  8. experimaestro/commandline.py +19 -3
  9. experimaestro/connectors/__init__.py +20 -1
  10. experimaestro/connectors/local.py +12 -0
  11. experimaestro/core/arguments.py +182 -46
  12. experimaestro/core/identifier.py +107 -6
  13. experimaestro/core/objects/__init__.py +6 -0
  14. experimaestro/core/objects/config.py +542 -25
  15. experimaestro/core/objects/config_walk.py +20 -0
  16. experimaestro/core/serialization.py +91 -34
  17. experimaestro/core/subparameters.py +164 -0
  18. experimaestro/core/types.py +175 -38
  19. experimaestro/exceptions.py +26 -0
  20. experimaestro/experiments/cli.py +111 -25
  21. experimaestro/generators.py +50 -9
  22. experimaestro/huggingface.py +3 -1
  23. experimaestro/launcherfinder/parser.py +29 -0
  24. experimaestro/launchers/__init__.py +26 -1
  25. experimaestro/launchers/direct.py +12 -0
  26. experimaestro/launchers/slurm/base.py +154 -2
  27. experimaestro/mkdocs/metaloader.py +0 -1
  28. experimaestro/mypy.py +452 -7
  29. experimaestro/notifications.py +63 -13
  30. experimaestro/progress.py +0 -2
  31. experimaestro/rpyc.py +0 -1
  32. experimaestro/run.py +19 -6
  33. experimaestro/scheduler/base.py +510 -125
  34. experimaestro/scheduler/dependencies.py +43 -28
  35. experimaestro/scheduler/dynamic_outputs.py +259 -130
  36. experimaestro/scheduler/experiment.py +256 -31
  37. experimaestro/scheduler/interfaces.py +501 -0
  38. experimaestro/scheduler/jobs.py +216 -206
  39. experimaestro/scheduler/remote/__init__.py +31 -0
  40. experimaestro/scheduler/remote/client.py +874 -0
  41. experimaestro/scheduler/remote/protocol.py +467 -0
  42. experimaestro/scheduler/remote/server.py +423 -0
  43. experimaestro/scheduler/remote/sync.py +144 -0
  44. experimaestro/scheduler/services.py +323 -23
  45. experimaestro/scheduler/state_db.py +437 -0
  46. experimaestro/scheduler/state_provider.py +2766 -0
  47. experimaestro/scheduler/state_sync.py +891 -0
  48. experimaestro/scheduler/workspace.py +52 -10
  49. experimaestro/scriptbuilder.py +7 -0
  50. experimaestro/server/__init__.py +147 -57
  51. experimaestro/server/data/index.css +0 -125
  52. experimaestro/server/data/index.css.map +1 -1
  53. experimaestro/server/data/index.js +194 -58
  54. experimaestro/server/data/index.js.map +1 -1
  55. experimaestro/settings.py +44 -5
  56. experimaestro/sphinx/__init__.py +3 -3
  57. experimaestro/taskglobals.py +20 -0
  58. experimaestro/tests/conftest.py +80 -0
  59. experimaestro/tests/core/test_generics.py +2 -2
  60. experimaestro/tests/identifier_stability.json +45 -0
  61. experimaestro/tests/launchers/bin/sacct +6 -2
  62. experimaestro/tests/launchers/bin/sbatch +4 -2
  63. experimaestro/tests/launchers/test_slurm.py +80 -0
  64. experimaestro/tests/tasks/test_dynamic.py +231 -0
  65. experimaestro/tests/test_cli_jobs.py +615 -0
  66. experimaestro/tests/test_deprecated.py +630 -0
  67. experimaestro/tests/test_environment.py +200 -0
  68. experimaestro/tests/test_file_progress_integration.py +1 -1
  69. experimaestro/tests/test_forward.py +3 -3
  70. experimaestro/tests/test_identifier.py +372 -41
  71. experimaestro/tests/test_identifier_stability.py +458 -0
  72. experimaestro/tests/test_instance.py +3 -3
  73. experimaestro/tests/test_multitoken.py +442 -0
  74. experimaestro/tests/test_mypy.py +433 -0
  75. experimaestro/tests/test_objects.py +312 -5
  76. experimaestro/tests/test_outputs.py +2 -2
  77. experimaestro/tests/test_param.py +8 -12
  78. experimaestro/tests/test_partial_paths.py +231 -0
  79. experimaestro/tests/test_progress.py +0 -48
  80. experimaestro/tests/test_remote_state.py +671 -0
  81. experimaestro/tests/test_resumable_task.py +480 -0
  82. experimaestro/tests/test_serializers.py +141 -1
  83. experimaestro/tests/test_state_db.py +434 -0
  84. experimaestro/tests/test_subparameters.py +160 -0
  85. experimaestro/tests/test_tags.py +136 -0
  86. experimaestro/tests/test_tasks.py +107 -121
  87. experimaestro/tests/test_token_locking.py +252 -0
  88. experimaestro/tests/test_tokens.py +17 -13
  89. experimaestro/tests/test_types.py +123 -1
  90. experimaestro/tests/test_workspace_triggers.py +158 -0
  91. experimaestro/tests/token_reschedule.py +4 -2
  92. experimaestro/tests/utils.py +2 -2
  93. experimaestro/tokens.py +154 -57
  94. experimaestro/tools/diff.py +1 -1
  95. experimaestro/tui/__init__.py +8 -0
  96. experimaestro/tui/app.py +2395 -0
  97. experimaestro/tui/app.tcss +353 -0
  98. experimaestro/tui/log_viewer.py +228 -0
  99. experimaestro/utils/__init__.py +23 -0
  100. experimaestro/utils/environment.py +148 -0
  101. experimaestro/utils/git.py +129 -0
  102. experimaestro/utils/resources.py +1 -1
  103. experimaestro/version.py +34 -0
  104. {experimaestro-2.0.0a8.dist-info → experimaestro-2.0.0b8.dist-info}/METADATA +68 -38
  105. experimaestro-2.0.0b8.dist-info/RECORD +187 -0
  106. {experimaestro-2.0.0a8.dist-info → experimaestro-2.0.0b8.dist-info}/WHEEL +1 -1
  107. experimaestro-2.0.0b8.dist-info/entry_points.txt +16 -0
  108. experimaestro/compat.py +0 -6
  109. experimaestro/core/objects.pyi +0 -221
  110. experimaestro/server/data/0c35d18bf06992036b69.woff2 +0 -0
  111. experimaestro/server/data/219aa9140e099e6c72ed.woff2 +0 -0
  112. experimaestro/server/data/3a4004a46a653d4b2166.woff +0 -0
  113. experimaestro/server/data/3baa5b8f3469222b822d.woff +0 -0
  114. experimaestro/server/data/4d73cb90e394b34b7670.woff +0 -0
  115. experimaestro/server/data/4ef4218c522f1eb6b5b1.woff2 +0 -0
  116. experimaestro/server/data/5d681e2edae8c60630db.woff +0 -0
  117. experimaestro/server/data/6f420cf17cc0d7676fad.woff2 +0 -0
  118. experimaestro/server/data/c380809fd3677d7d6903.woff2 +0 -0
  119. experimaestro/server/data/f882956fd323fd322f31.woff +0 -0
  120. experimaestro-2.0.0a8.dist-info/RECORD +0 -166
  121. experimaestro-2.0.0a8.dist-info/entry_points.txt +0 -17
  122. {experimaestro-2.0.0a8.dist-info → experimaestro-2.0.0b8.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,467 @@
1
+ """JSON-RPC 2.0 protocol utilities for SSH-based remote monitoring
2
+
3
+ This module provides JSON-RPC message types, serialization utilities,
4
+ and protocol constants for communication between SSHStateProviderServer
5
+ and SSHStateProviderClient.
6
+
7
+ Message format: Newline-delimited JSON (one JSON object per line)
8
+ """
9
+
10
+ import json
11
+ import logging
12
+ from dataclasses import dataclass, field
13
+ from datetime import datetime
14
+ from enum import Enum
15
+ from pathlib import Path
16
+ from typing import TYPE_CHECKING, Any, Dict, Optional, Union
17
+
18
+ if TYPE_CHECKING:
19
+ from experimaestro.scheduler.interfaces import JobState
20
+
21
+ logger = logging.getLogger("xpm.remote.protocol")
22
+
23
+ # JSON-RPC 2.0 version
24
+ JSONRPC_VERSION = "2.0"
25
+
26
+ # Standard JSON-RPC error codes
27
+ PARSE_ERROR = -32700
28
+ INVALID_REQUEST = -32600
29
+ METHOD_NOT_FOUND = -32601
30
+ INVALID_PARAMS = -32602
31
+ INTERNAL_ERROR = -32603
32
+
33
+ # Custom error codes
34
+ CONNECTION_ERROR = -32001
35
+ WORKSPACE_NOT_FOUND = -32002
36
+ PERMISSION_DENIED = -32003
37
+ TIMEOUT_ERROR = -32004
38
+
39
+
40
+ class NotificationMethod(str, Enum):
41
+ """Server-to-client notification methods"""
42
+
43
+ EXPERIMENT_UPDATED = "notification.experiment_updated"
44
+ RUN_UPDATED = "notification.run_updated"
45
+ JOB_UPDATED = "notification.job_updated"
46
+ SERVICE_UPDATED = "notification.service_updated"
47
+ FILE_CHANGED = "notification.file_changed"
48
+ SHUTDOWN = "notification.shutdown"
49
+
50
+
51
+ class RPCMethod(str, Enum):
52
+ """Client-to-server RPC methods"""
53
+
54
+ GET_EXPERIMENTS = "get_experiments"
55
+ GET_EXPERIMENT = "get_experiment"
56
+ GET_EXPERIMENT_RUNS = "get_experiment_runs"
57
+ GET_JOBS = "get_jobs"
58
+ GET_JOB = "get_job"
59
+ GET_ALL_JOBS = "get_all_jobs"
60
+ GET_SERVICES = "get_services"
61
+ KILL_JOB = "kill_job"
62
+ CLEAN_JOB = "clean_job"
63
+ GET_SYNC_INFO = "get_sync_info"
64
+
65
+
66
+ @dataclass
67
+ class RPCError:
68
+ """JSON-RPC error object"""
69
+
70
+ code: int
71
+ message: str
72
+ data: Optional[Any] = None
73
+
74
+ def to_dict(self) -> Dict:
75
+ result = {"code": self.code, "message": self.message}
76
+ if self.data is not None:
77
+ result["data"] = self.data
78
+ return result
79
+
80
+ @classmethod
81
+ def from_dict(cls, d: Dict) -> "RPCError":
82
+ return cls(code=d["code"], message=d["message"], data=d.get("data"))
83
+
84
+
85
+ @dataclass
86
+ class RPCRequest:
87
+ """JSON-RPC request message"""
88
+
89
+ method: str
90
+ params: Dict = field(default_factory=dict)
91
+ id: Optional[int] = None # None for notifications
92
+
93
+ def to_dict(self) -> Dict:
94
+ result = {"jsonrpc": JSONRPC_VERSION, "method": self.method}
95
+ if self.params:
96
+ result["params"] = self.params
97
+ if self.id is not None:
98
+ result["id"] = self.id
99
+ return result
100
+
101
+ def to_json(self) -> str:
102
+ return json.dumps(self.to_dict())
103
+
104
+ @classmethod
105
+ def from_dict(cls, d: Dict) -> "RPCRequest":
106
+ return cls(method=d["method"], params=d.get("params", {}), id=d.get("id"))
107
+
108
+
109
+ @dataclass
110
+ class RPCResponse:
111
+ """JSON-RPC response message"""
112
+
113
+ id: int
114
+ result: Optional[Any] = None
115
+ error: Optional[RPCError] = None
116
+
117
+ def to_dict(self) -> Dict:
118
+ d = {"jsonrpc": JSONRPC_VERSION, "id": self.id}
119
+ if self.error is not None:
120
+ d["error"] = self.error.to_dict()
121
+ else:
122
+ d["result"] = self.result
123
+ return d
124
+
125
+ def to_json(self) -> str:
126
+ return json.dumps(self.to_dict())
127
+
128
+ @classmethod
129
+ def from_dict(cls, d: Dict) -> "RPCResponse":
130
+ error = None
131
+ if "error" in d:
132
+ error = RPCError.from_dict(d["error"])
133
+ return cls(id=d["id"], result=d.get("result"), error=error)
134
+
135
+
136
+ @dataclass
137
+ class RPCNotification:
138
+ """JSON-RPC notification (no id, no response expected)"""
139
+
140
+ method: str
141
+ params: Dict = field(default_factory=dict)
142
+
143
+ def to_dict(self) -> Dict:
144
+ result = {"jsonrpc": JSONRPC_VERSION, "method": self.method}
145
+ if self.params:
146
+ result["params"] = self.params
147
+ return result
148
+
149
+ def to_json(self) -> str:
150
+ return json.dumps(self.to_dict())
151
+
152
+ @classmethod
153
+ def from_dict(cls, d: Dict) -> "RPCNotification":
154
+ return cls(method=d["method"], params=d.get("params", {}))
155
+
156
+
157
+ def parse_message(line: str) -> Union[RPCRequest, RPCResponse, RPCNotification]:
158
+ """Parse a JSON-RPC message from a line of text
159
+
160
+ Args:
161
+ line: A single line of JSON text
162
+
163
+ Returns:
164
+ RPCRequest, RPCResponse, or RPCNotification
165
+
166
+ Raises:
167
+ ValueError: If the message is malformed
168
+ """
169
+ try:
170
+ d = json.loads(line)
171
+ except json.JSONDecodeError as e:
172
+ raise ValueError(f"Invalid JSON: {e}")
173
+
174
+ if "jsonrpc" not in d or d["jsonrpc"] != JSONRPC_VERSION:
175
+ raise ValueError("Invalid or missing jsonrpc version")
176
+
177
+ # Response: has "id" and either "result" or "error"
178
+ if "result" in d or "error" in d:
179
+ return RPCResponse.from_dict(d)
180
+
181
+ # Request or notification: has "method"
182
+ if "method" in d:
183
+ if "id" in d:
184
+ return RPCRequest.from_dict(d)
185
+ else:
186
+ return RPCNotification.from_dict(d)
187
+
188
+ raise ValueError("Cannot determine message type")
189
+
190
+
191
+ def create_error_response(id: int, code: int, message: str, data: Any = None) -> str:
192
+ """Create a JSON-RPC error response
193
+
194
+ Args:
195
+ id: Request ID
196
+ code: Error code
197
+ message: Error message
198
+ data: Optional additional data
199
+
200
+ Returns:
201
+ JSON string
202
+ """
203
+ response = RPCResponse(id=id, error=RPCError(code=code, message=message, data=data))
204
+ return response.to_json()
205
+
206
+
207
+ def create_success_response(id: int, result: Any) -> str:
208
+ """Create a JSON-RPC success response
209
+
210
+ Args:
211
+ id: Request ID
212
+ result: Result data
213
+
214
+ Returns:
215
+ JSON string
216
+ """
217
+ response = RPCResponse(id=id, result=result)
218
+ return response.to_json()
219
+
220
+
221
+ def create_notification(method: Union[str, NotificationMethod], params: Dict) -> str:
222
+ """Create a JSON-RPC notification
223
+
224
+ Args:
225
+ method: Notification method name
226
+ params: Notification parameters
227
+
228
+ Returns:
229
+ JSON string
230
+ """
231
+ if isinstance(method, NotificationMethod):
232
+ method = method.value
233
+ notification = RPCNotification(method=method, params=params)
234
+ return notification.to_json()
235
+
236
+
237
+ def create_request(method: Union[str, RPCMethod], params: Dict, id: int) -> str:
238
+ """Create a JSON-RPC request
239
+
240
+ Args:
241
+ method: Method name
242
+ params: Request parameters
243
+ id: Request ID
244
+
245
+ Returns:
246
+ JSON string
247
+ """
248
+ if isinstance(method, RPCMethod):
249
+ method = method.value
250
+ request = RPCRequest(method=method, params=params, id=id)
251
+ return request.to_json()
252
+
253
+
254
+ # Serialization helpers for data types
255
+
256
+
257
+ def serialize_datetime(dt) -> Optional[str]:
258
+ """Serialize datetime or timestamp to ISO format string
259
+
260
+ Handles:
261
+ - None: returns None
262
+ - datetime: returns ISO format string
263
+ - float/int: treats as Unix timestamp, converts to ISO format
264
+ - str: returns as-is (already serialized)
265
+ """
266
+ if dt is None:
267
+ return None
268
+ if isinstance(dt, str):
269
+ return dt # Already serialized
270
+ if isinstance(dt, (int, float)):
271
+ # Unix timestamp
272
+ return datetime.fromtimestamp(dt).isoformat()
273
+ if isinstance(dt, datetime):
274
+ return dt.isoformat()
275
+ # Try to convert to string as fallback
276
+ return str(dt)
277
+
278
+
279
+ def deserialize_datetime(s: Optional[str]) -> Optional[datetime]:
280
+ """Deserialize ISO format string to datetime"""
281
+ if s is None:
282
+ return None
283
+ return datetime.fromisoformat(s)
284
+
285
+
286
+ def serialize_job(job) -> Dict:
287
+ """Serialize a job (MockJob or Job) to a dictionary for JSON-RPC"""
288
+ from experimaestro.scheduler.interfaces import JobState
289
+
290
+ result = {
291
+ "identifier": job.identifier,
292
+ "task_id": job.task_id,
293
+ "locator": job.locator,
294
+ "path": str(job.path) if job.path else None,
295
+ "state": job.state.name if isinstance(job.state, JobState) else str(job.state),
296
+ "submittime": serialize_datetime(job.submittime),
297
+ "starttime": serialize_datetime(job.starttime),
298
+ "endtime": serialize_datetime(job.endtime),
299
+ "progress": job.progress,
300
+ "tags": job.tags,
301
+ "experiment_id": getattr(job, "experiment_id", None),
302
+ "run_id": getattr(job, "run_id", None),
303
+ }
304
+ return result
305
+
306
+
307
+ def deserialize_job(d: Dict) -> "MockJobData":
308
+ """Deserialize a dictionary to MockJobData"""
309
+ from experimaestro.scheduler.interfaces import JobState, STATE_NAME_TO_JOBSTATE
310
+ from pathlib import Path
311
+
312
+ state = STATE_NAME_TO_JOBSTATE.get(d["state"], JobState.WAITING)
313
+ return MockJobData(
314
+ identifier=d["identifier"],
315
+ task_id=d["task_id"],
316
+ locator=d["locator"],
317
+ path=Path(d["path"]) if d["path"] else None,
318
+ state=state,
319
+ submittime=deserialize_datetime(d.get("submittime")),
320
+ starttime=deserialize_datetime(d.get("starttime")),
321
+ endtime=deserialize_datetime(d.get("endtime")),
322
+ progress=d.get("progress"),
323
+ tags=d.get("tags", {}),
324
+ experiment_id=d.get("experiment_id"),
325
+ run_id=d.get("run_id"),
326
+ )
327
+
328
+
329
+ def serialize_experiment(experiment) -> Dict:
330
+ """Serialize a MockExperiment to a dictionary for JSON-RPC"""
331
+ result = {
332
+ "experiment_id": experiment.experiment_id,
333
+ "workdir": str(experiment.workdir) if experiment.workdir else None,
334
+ "current_run_id": experiment.current_run_id,
335
+ "total_jobs": experiment.total_jobs,
336
+ "finished_jobs": experiment.finished_jobs,
337
+ "failed_jobs": experiment.failed_jobs,
338
+ "updated_at": serialize_datetime(experiment.updated_at),
339
+ "started_at": serialize_datetime(experiment.started_at),
340
+ "ended_at": serialize_datetime(experiment.ended_at),
341
+ "hostname": experiment.hostname,
342
+ }
343
+ return result
344
+
345
+
346
+ def deserialize_experiment(d: Dict) -> "MockExperimentData":
347
+ """Deserialize a dictionary to MockExperimentData"""
348
+ from pathlib import Path
349
+
350
+ return MockExperimentData(
351
+ experiment_id=d["experiment_id"],
352
+ workdir=Path(d["workdir"]) if d["workdir"] else None,
353
+ current_run_id=d.get("current_run_id"),
354
+ total_jobs=d.get("total_jobs", 0),
355
+ finished_jobs=d.get("finished_jobs", 0),
356
+ failed_jobs=d.get("failed_jobs", 0),
357
+ updated_at=deserialize_datetime(d.get("updated_at")),
358
+ started_at=deserialize_datetime(d.get("started_at")),
359
+ ended_at=deserialize_datetime(d.get("ended_at")),
360
+ hostname=d.get("hostname"),
361
+ )
362
+
363
+
364
+ def serialize_service(service) -> Dict:
365
+ """Serialize a service to a dictionary for JSON-RPC"""
366
+ from experimaestro.scheduler.services import Service
367
+
368
+ # Service has id attribute, description() method, state property, state_dict() method
369
+ state = service.state
370
+ if hasattr(state, "name"):
371
+ state = state.name # Convert ServiceState enum to string
372
+ elif hasattr(state, "value"):
373
+ state = state.value
374
+
375
+ # Get URL if service has it (e.g., TensorboardService)
376
+ url = None
377
+ if hasattr(service, "url"):
378
+ url = service.url
379
+ elif hasattr(service, "get_url"):
380
+ try:
381
+ url = service.get_url()
382
+ except Exception:
383
+ pass
384
+
385
+ # Get state_dict with __class__ and serialize paths
386
+ if hasattr(service, "_full_state_dict"):
387
+ state_dict = Service.serialize_state_dict(service._full_state_dict())
388
+ elif callable(getattr(service, "state_dict", None)):
389
+ # Fallback: serialize paths in the raw state_dict
390
+ state_dict = Service.serialize_state_dict(service.state_dict())
391
+ else:
392
+ state_dict = getattr(service, "state_dict", {})
393
+
394
+ return {
395
+ "service_id": getattr(service, "id", None),
396
+ "description": (
397
+ service.description()
398
+ if callable(service.description)
399
+ else service.description
400
+ ),
401
+ "state": state,
402
+ "state_dict": state_dict,
403
+ "experiment_id": getattr(service, "experiment_id", None),
404
+ "run_id": getattr(service, "run_id", None),
405
+ "url": url,
406
+ }
407
+
408
+
409
+ def serialize_run(run) -> Dict:
410
+ """Serialize an experiment run to a dictionary for JSON-RPC
411
+
412
+ Handles both dictionary and object inputs (get_experiment_runs returns dicts).
413
+ """
414
+ if isinstance(run, dict):
415
+ # Already a dictionary - just ensure datetime serialization
416
+ return {
417
+ "run_id": run.get("run_id"),
418
+ "experiment_id": run.get("experiment_id"),
419
+ "hostname": run.get("hostname"),
420
+ "started_at": run.get("started_at"), # Already serialized
421
+ "ended_at": run.get("ended_at"), # Already serialized
422
+ "status": run.get("status"),
423
+ }
424
+ else:
425
+ # Object with attributes
426
+ return {
427
+ "run_id": run.run_id,
428
+ "experiment_id": run.experiment_id,
429
+ "hostname": getattr(run, "hostname", None),
430
+ "started_at": serialize_datetime(run.started_at),
431
+ "ended_at": serialize_datetime(run.ended_at),
432
+ "status": run.status,
433
+ }
434
+
435
+
436
+ @dataclass
437
+ class MockJobData:
438
+ """Deserialized job data from remote"""
439
+
440
+ identifier: str
441
+ task_id: str
442
+ locator: str
443
+ path: Optional["Path"]
444
+ state: "JobState"
445
+ submittime: Optional[datetime]
446
+ starttime: Optional[datetime]
447
+ endtime: Optional[datetime]
448
+ progress: Optional[float]
449
+ tags: Dict
450
+ experiment_id: Optional[str]
451
+ run_id: Optional[str]
452
+
453
+
454
+ @dataclass
455
+ class MockExperimentData:
456
+ """Deserialized experiment data from remote"""
457
+
458
+ experiment_id: str
459
+ workdir: Optional["Path"]
460
+ current_run_id: Optional[str]
461
+ total_jobs: int
462
+ finished_jobs: int
463
+ failed_jobs: int
464
+ updated_at: Optional[datetime]
465
+ started_at: Optional[datetime]
466
+ ended_at: Optional[datetime]
467
+ hostname: Optional[str]