supervaizer 0.10.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.
- supervaizer/__init__.py +97 -0
- supervaizer/__version__.py +10 -0
- supervaizer/account.py +308 -0
- supervaizer/account_service.py +93 -0
- supervaizer/admin/routes.py +1293 -0
- supervaizer/admin/static/js/job-start-form.js +373 -0
- supervaizer/admin/templates/agent_detail.html +145 -0
- supervaizer/admin/templates/agents.html +249 -0
- supervaizer/admin/templates/agents_grid.html +82 -0
- supervaizer/admin/templates/base.html +233 -0
- supervaizer/admin/templates/case_detail.html +230 -0
- supervaizer/admin/templates/cases_list.html +182 -0
- supervaizer/admin/templates/cases_table.html +134 -0
- supervaizer/admin/templates/console.html +389 -0
- supervaizer/admin/templates/dashboard.html +153 -0
- supervaizer/admin/templates/job_detail.html +192 -0
- supervaizer/admin/templates/job_start_test.html +109 -0
- supervaizer/admin/templates/jobs_list.html +180 -0
- supervaizer/admin/templates/jobs_table.html +122 -0
- supervaizer/admin/templates/navigation.html +163 -0
- supervaizer/admin/templates/recent_activity.html +81 -0
- supervaizer/admin/templates/server.html +105 -0
- supervaizer/admin/templates/server_status_cards.html +121 -0
- supervaizer/admin/templates/supervaize_instructions.html +212 -0
- supervaizer/agent.py +956 -0
- supervaizer/case.py +432 -0
- supervaizer/cli.py +395 -0
- supervaizer/common.py +324 -0
- supervaizer/deploy/__init__.py +16 -0
- supervaizer/deploy/cli.py +305 -0
- supervaizer/deploy/commands/__init__.py +9 -0
- supervaizer/deploy/commands/clean.py +294 -0
- supervaizer/deploy/commands/down.py +119 -0
- supervaizer/deploy/commands/local.py +460 -0
- supervaizer/deploy/commands/plan.py +167 -0
- supervaizer/deploy/commands/status.py +169 -0
- supervaizer/deploy/commands/up.py +281 -0
- supervaizer/deploy/docker.py +377 -0
- supervaizer/deploy/driver_factory.py +42 -0
- supervaizer/deploy/drivers/__init__.py +39 -0
- supervaizer/deploy/drivers/aws_app_runner.py +607 -0
- supervaizer/deploy/drivers/base.py +196 -0
- supervaizer/deploy/drivers/cloud_run.py +570 -0
- supervaizer/deploy/drivers/do_app_platform.py +504 -0
- supervaizer/deploy/health.py +404 -0
- supervaizer/deploy/state.py +210 -0
- supervaizer/deploy/templates/Dockerfile.template +44 -0
- supervaizer/deploy/templates/debug_env.py +69 -0
- supervaizer/deploy/templates/docker-compose.yml.template +37 -0
- supervaizer/deploy/templates/dockerignore.template +66 -0
- supervaizer/deploy/templates/entrypoint.sh +20 -0
- supervaizer/deploy/utils.py +52 -0
- supervaizer/event.py +181 -0
- supervaizer/examples/controller_template.py +196 -0
- supervaizer/instructions.py +145 -0
- supervaizer/job.py +392 -0
- supervaizer/job_service.py +156 -0
- supervaizer/lifecycle.py +417 -0
- supervaizer/parameter.py +233 -0
- supervaizer/protocol/__init__.py +11 -0
- supervaizer/protocol/a2a/__init__.py +21 -0
- supervaizer/protocol/a2a/model.py +227 -0
- supervaizer/protocol/a2a/routes.py +99 -0
- supervaizer/py.typed +1 -0
- supervaizer/routes.py +917 -0
- supervaizer/server.py +553 -0
- supervaizer/server_utils.py +54 -0
- supervaizer/storage.py +462 -0
- supervaizer/telemetry.py +81 -0
- supervaizer/utils/__init__.py +16 -0
- supervaizer/utils/version_check.py +56 -0
- supervaizer-0.10.5.dist-info/METADATA +317 -0
- supervaizer-0.10.5.dist-info/RECORD +76 -0
- supervaizer-0.10.5.dist-info/WHEEL +4 -0
- supervaizer-0.10.5.dist-info/entry_points.txt +2 -0
- supervaizer-0.10.5.dist-info/licenses/LICENSE.md +346 -0
supervaizer/case.py
ADDED
|
@@ -0,0 +1,432 @@
|
|
|
1
|
+
# Copyright (c) 2024-2025 Alain Prasquier - Supervaize.com. All rights reserved.
|
|
2
|
+
#
|
|
3
|
+
# This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0.
|
|
4
|
+
# If a copy of the MPL was not distributed with this file, you can obtain one at
|
|
5
|
+
# https://mozilla.org/MPL/2.0/.
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
from datetime import datetime
|
|
9
|
+
from enum import Enum
|
|
10
|
+
from typing import TYPE_CHECKING, Any, Dict, List, Optional
|
|
11
|
+
|
|
12
|
+
import shortuuid
|
|
13
|
+
from pydantic import ConfigDict, Field
|
|
14
|
+
from pydantic.json_schema import SkipJsonSchema
|
|
15
|
+
from typing import Callable
|
|
16
|
+
from supervaizer.common import SvBaseModel, log, singleton
|
|
17
|
+
from supervaizer.lifecycle import EntityEvents, EntityStatus
|
|
18
|
+
from supervaizer.storage import PersistentEntityLifecycle, StorageManager
|
|
19
|
+
|
|
20
|
+
if TYPE_CHECKING:
|
|
21
|
+
from supervaizer.account import Account
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class CaseNodeUpdate(SvBaseModel):
|
|
25
|
+
"""
|
|
26
|
+
CaseNodeUpdate is a class that represents an update to a case node.
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
Returns:
|
|
30
|
+
CaseNodeUpdate: CaseNodeUpdate object
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
index: int | None = None # added in Case.update
|
|
34
|
+
cost: float | None = None
|
|
35
|
+
name: str | None = None
|
|
36
|
+
# Todo: test with non-serializable objects. Make sure it works.
|
|
37
|
+
payload: Optional[Dict[str, Any]] = None
|
|
38
|
+
is_final: bool = False
|
|
39
|
+
error: Optional[str] = None
|
|
40
|
+
|
|
41
|
+
def __init__(
|
|
42
|
+
self,
|
|
43
|
+
cost: float | None = None,
|
|
44
|
+
name: str | None = None,
|
|
45
|
+
payload: Dict[str, Any] | None = None,
|
|
46
|
+
is_final: bool = False,
|
|
47
|
+
index: int | None = None,
|
|
48
|
+
error: Optional[str] = None,
|
|
49
|
+
) -> None:
|
|
50
|
+
"""Initialize a CaseNodeUpdate.
|
|
51
|
+
|
|
52
|
+
Args:
|
|
53
|
+
cost (float): Cost of the update
|
|
54
|
+
name (str): Name of the update
|
|
55
|
+
payload (Dict[str, Any]): Additional data for the update - when a question is requested to the user, the payload is the question
|
|
56
|
+
is_final (bool): Whether this is the final update. Default to False
|
|
57
|
+
index (int): Index of the node to update. This is set by Case.update()
|
|
58
|
+
error (Optional[str]): Error message if any. Default to None
|
|
59
|
+
|
|
60
|
+
When payload contains a question (supervaizer_form):
|
|
61
|
+
payload = {
|
|
62
|
+
"supervaizer_form": {
|
|
63
|
+
"question": str, # The question to ask
|
|
64
|
+
"answer": {
|
|
65
|
+
"fields": [
|
|
66
|
+
{
|
|
67
|
+
"name": str, # Field name
|
|
68
|
+
"description": str, # Field description
|
|
69
|
+
"type": type, # Field type (e.g. bool)
|
|
70
|
+
"field_type": str, # Field type name (e.g. "BooleanField")
|
|
71
|
+
"required": bool # Whether field is required
|
|
72
|
+
},
|
|
73
|
+
# ... additional fields
|
|
74
|
+
]
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
Returns:
|
|
80
|
+
CaseNodeUpdate: CaseNodeUpdate object
|
|
81
|
+
"""
|
|
82
|
+
# Use model_construct rather than passing arguments to __init__
|
|
83
|
+
values = {
|
|
84
|
+
"cost": cost,
|
|
85
|
+
"name": name,
|
|
86
|
+
"payload": payload,
|
|
87
|
+
"is_final": is_final,
|
|
88
|
+
"index": index,
|
|
89
|
+
"error": error,
|
|
90
|
+
}
|
|
91
|
+
object.__setattr__(self, "__dict__", {})
|
|
92
|
+
object.__setattr__(self, "__pydantic_fields_set__", set())
|
|
93
|
+
object.__setattr__(self, "__pydantic_extra__", None)
|
|
94
|
+
object.__setattr__(self, "__pydantic_private__", None)
|
|
95
|
+
|
|
96
|
+
# Update the model fields without calling the SvBaseModel.__init__
|
|
97
|
+
for key, value in values.items():
|
|
98
|
+
setattr(self, key, value)
|
|
99
|
+
|
|
100
|
+
@property
|
|
101
|
+
def registration_info(self) -> Dict[str, Any]:
|
|
102
|
+
"""Returns registration info for the case node update"""
|
|
103
|
+
# Serialize payload to convert type objects to strings for JSON serialization
|
|
104
|
+
serialized_payload = (
|
|
105
|
+
self.serialize_value(self.payload) if self.payload else None
|
|
106
|
+
)
|
|
107
|
+
return {
|
|
108
|
+
"index": self.index,
|
|
109
|
+
"name": self.name,
|
|
110
|
+
"error": self.error,
|
|
111
|
+
"cost": self.cost,
|
|
112
|
+
"payload": serialized_payload,
|
|
113
|
+
"is_final": self.is_final,
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class CaseNodeType(Enum):
|
|
118
|
+
"""
|
|
119
|
+
CaseNodeType is an enum that represents the type of a case note.
|
|
120
|
+
"""
|
|
121
|
+
|
|
122
|
+
CHAT = "chat"
|
|
123
|
+
TRIGGER = "trigger"
|
|
124
|
+
NOTIFICATION = "notification"
|
|
125
|
+
STATUS_UPDATE = "status_update"
|
|
126
|
+
INTERMEDIARY_DELIVERY = "intermediary_delivery"
|
|
127
|
+
HITL = "human_in_the_loop"
|
|
128
|
+
DELIVERABLE = "deliverable"
|
|
129
|
+
VALIDATION = "validation"
|
|
130
|
+
DELIVERY = "delivery"
|
|
131
|
+
ERROR = "error"
|
|
132
|
+
WARNING = "warning"
|
|
133
|
+
INFO = "info"
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
class CaseNode(SvBaseModel):
|
|
137
|
+
model_config = ConfigDict(arbitrary_types_allowed=True)
|
|
138
|
+
|
|
139
|
+
name: str
|
|
140
|
+
type: CaseNodeType
|
|
141
|
+
factory: SkipJsonSchema[Callable[..., CaseNodeUpdate]] = Field(
|
|
142
|
+
exclude=True, repr=False
|
|
143
|
+
) # Exclude from JSON schema generation and representation
|
|
144
|
+
description: str | None = None
|
|
145
|
+
can_be_confirmed: bool = False # Whether the user can decide that this node needs to be confirmed. This must be set in the job definition.
|
|
146
|
+
|
|
147
|
+
def __call__(self, *args: Any, **kwargs: Any) -> CaseNodeUpdate:
|
|
148
|
+
"""Make it callable directly."""
|
|
149
|
+
return self.factory(*args, **kwargs)
|
|
150
|
+
|
|
151
|
+
@property
|
|
152
|
+
def registration_info(self) -> Dict[str, Any]:
|
|
153
|
+
"""Returns registration info for the case node"""
|
|
154
|
+
return {
|
|
155
|
+
"name": self.name,
|
|
156
|
+
"type": self.type.value,
|
|
157
|
+
"description": self.description,
|
|
158
|
+
"can_be_confirmed": self.can_be_confirmed,
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
class CaseNodes(SvBaseModel):
|
|
163
|
+
nodes: List[CaseNode] = []
|
|
164
|
+
|
|
165
|
+
def get(self, name: str) -> CaseNode | None:
|
|
166
|
+
return next((node for node in self.nodes if node.name == name), None)
|
|
167
|
+
|
|
168
|
+
@property
|
|
169
|
+
def registration_info(self) -> Dict[str, Any]:
|
|
170
|
+
"""Returns registration info for the case nodes"""
|
|
171
|
+
return {
|
|
172
|
+
"nodes": [node.registration_info for node in self.nodes],
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
class CaseAbstractModel(SvBaseModel):
|
|
177
|
+
model_config = ConfigDict(arbitrary_types_allowed=True)
|
|
178
|
+
id: str
|
|
179
|
+
job_id: str
|
|
180
|
+
name: str
|
|
181
|
+
account: "Account"
|
|
182
|
+
description: str
|
|
183
|
+
status: EntityStatus
|
|
184
|
+
updates: List[CaseNodeUpdate] = []
|
|
185
|
+
total_cost: float = 0.0
|
|
186
|
+
final_delivery: Optional[Dict[str, Any]] = None
|
|
187
|
+
finished_at: Optional[datetime] = None
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
class Case(CaseAbstractModel):
|
|
191
|
+
def __init__(self, **kwargs: Any) -> None:
|
|
192
|
+
super().__init__(**kwargs)
|
|
193
|
+
# Register the case in the global registry
|
|
194
|
+
Cases().add_case(self)
|
|
195
|
+
# Persist case to storage
|
|
196
|
+
from supervaizer.storage import StorageManager
|
|
197
|
+
|
|
198
|
+
storage = StorageManager()
|
|
199
|
+
storage.save_object("Case", self.to_dict)
|
|
200
|
+
|
|
201
|
+
@property
|
|
202
|
+
def uri(self) -> str:
|
|
203
|
+
return f"case:{self.id}"
|
|
204
|
+
|
|
205
|
+
@property
|
|
206
|
+
def case_ref(self) -> str:
|
|
207
|
+
return f"{self.job_id}-{self.id}"
|
|
208
|
+
|
|
209
|
+
@property
|
|
210
|
+
def calculated_cost(self) -> float:
|
|
211
|
+
return sum(update.cost or 0.0 for update in self.updates)
|
|
212
|
+
|
|
213
|
+
def update(self, updateCaseNode: CaseNodeUpdate, **kwargs: Any) -> None:
|
|
214
|
+
updateCaseNode.index = len(self.updates) + 1
|
|
215
|
+
if updateCaseNode.error:
|
|
216
|
+
success, error = PersistentEntityLifecycle.handle_event(
|
|
217
|
+
self, EntityEvents.ERROR_ENCOUNTERED
|
|
218
|
+
)
|
|
219
|
+
log.warning(
|
|
220
|
+
f"[Case update] CaseRef {self.case_ref} has error {updateCaseNode.error}"
|
|
221
|
+
)
|
|
222
|
+
assert self.status == EntityStatus.FAILED # Just to be sure
|
|
223
|
+
self.account.send_update_case(self, updateCaseNode)
|
|
224
|
+
self.updates.append(updateCaseNode)
|
|
225
|
+
|
|
226
|
+
storage = StorageManager()
|
|
227
|
+
storage.save_object("Case", self.to_dict)
|
|
228
|
+
|
|
229
|
+
def request_human_input(
|
|
230
|
+
self, updateCaseNode: CaseNodeUpdate, message: str, **kwargs: Any
|
|
231
|
+
) -> None:
|
|
232
|
+
updateCaseNode.index = len(self.updates) + 1
|
|
233
|
+
log.info(
|
|
234
|
+
f"[Update case human_input] CaseRef {self.case_ref} with update {updateCaseNode}"
|
|
235
|
+
)
|
|
236
|
+
self.account.send_update_case(self, updateCaseNode)
|
|
237
|
+
from supervaizer.storage import PersistentEntityLifecycle
|
|
238
|
+
|
|
239
|
+
PersistentEntityLifecycle.handle_event(self, EntityEvents.AWAITING_ON_INPUT)
|
|
240
|
+
self.updates.append(updateCaseNode)
|
|
241
|
+
|
|
242
|
+
# Persist updated case to storage (for the updates list change)
|
|
243
|
+
|
|
244
|
+
storage = StorageManager()
|
|
245
|
+
storage.save_object("Case", self.to_dict)
|
|
246
|
+
|
|
247
|
+
def receive_human_input(
|
|
248
|
+
self, updateCaseNode: CaseNodeUpdate, **kwargs: Any
|
|
249
|
+
) -> None:
|
|
250
|
+
# Add the update to the case (this handles index, send_update_case, and persistence)
|
|
251
|
+
self.update(updateCaseNode)
|
|
252
|
+
# Transition from AWAITING to IN_PROGRESS
|
|
253
|
+
from supervaizer.storage import PersistentEntityLifecycle
|
|
254
|
+
|
|
255
|
+
PersistentEntityLifecycle.handle_event(self, EntityEvents.INPUT_RECEIVED)
|
|
256
|
+
|
|
257
|
+
def close(
|
|
258
|
+
self,
|
|
259
|
+
case_result: Dict[str, Any],
|
|
260
|
+
final_cost: Optional[float] = None,
|
|
261
|
+
**kwargs: Any,
|
|
262
|
+
) -> None:
|
|
263
|
+
"""
|
|
264
|
+
Close the case and send the final update to the account.
|
|
265
|
+
"""
|
|
266
|
+
if final_cost:
|
|
267
|
+
self.total_cost = final_cost
|
|
268
|
+
else:
|
|
269
|
+
self.total_cost = self.calculated_cost
|
|
270
|
+
log.info(
|
|
271
|
+
f"[Close case] CaseRef {self.case_ref} with result {case_result} - Case cost is {self.total_cost}"
|
|
272
|
+
)
|
|
273
|
+
# Transition from IN_PROGRESS to COMPLETED
|
|
274
|
+
from supervaizer.storage import PersistentEntityLifecycle
|
|
275
|
+
|
|
276
|
+
PersistentEntityLifecycle.handle_event(self, EntityEvents.SUCCESSFULLY_DONE)
|
|
277
|
+
|
|
278
|
+
update = CaseNodeUpdate(
|
|
279
|
+
payload=case_result,
|
|
280
|
+
is_final=True,
|
|
281
|
+
)
|
|
282
|
+
update.index = len(self.updates) + 1
|
|
283
|
+
|
|
284
|
+
self.final_delivery = case_result
|
|
285
|
+
self.finished_at = datetime.now()
|
|
286
|
+
self.account.send_update_case(self, update)
|
|
287
|
+
|
|
288
|
+
# Persist updated case to storage
|
|
289
|
+
from supervaizer.storage import StorageManager
|
|
290
|
+
|
|
291
|
+
storage = StorageManager()
|
|
292
|
+
storage.save_object("Case", self.to_dict)
|
|
293
|
+
|
|
294
|
+
@property
|
|
295
|
+
def registration_info(self) -> Dict[str, Any]:
|
|
296
|
+
"""Returns registration info for the case"""
|
|
297
|
+
return {
|
|
298
|
+
"case_id": self.id,
|
|
299
|
+
"job_id": self.job_id,
|
|
300
|
+
"case_ref": self.case_ref,
|
|
301
|
+
"name": self.name,
|
|
302
|
+
"description": self.description,
|
|
303
|
+
"status": self.status.value,
|
|
304
|
+
"updates": [update.registration_info for update in self.updates],
|
|
305
|
+
"total_cost": self.total_cost,
|
|
306
|
+
"final_delivery": self.final_delivery,
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
@classmethod
|
|
310
|
+
def start(
|
|
311
|
+
cls,
|
|
312
|
+
job_id: str,
|
|
313
|
+
name: str,
|
|
314
|
+
account: "Account",
|
|
315
|
+
description: str,
|
|
316
|
+
case_id: Optional[str] = None,
|
|
317
|
+
) -> "Case":
|
|
318
|
+
"""
|
|
319
|
+
Start a new case
|
|
320
|
+
|
|
321
|
+
Args:
|
|
322
|
+
case_id (str): The id of the case - should be unique for the job. If not provided, a shortuuid will be generated.
|
|
323
|
+
job_id (str): The id of the job
|
|
324
|
+
name (str): The name of the case
|
|
325
|
+
account (Account): The account
|
|
326
|
+
description (str): The description of the case
|
|
327
|
+
|
|
328
|
+
Returns:
|
|
329
|
+
Case: The case
|
|
330
|
+
"""
|
|
331
|
+
|
|
332
|
+
case = cls(
|
|
333
|
+
id=case_id or shortuuid.uuid(),
|
|
334
|
+
job_id=job_id,
|
|
335
|
+
account=account,
|
|
336
|
+
name=name,
|
|
337
|
+
description=description,
|
|
338
|
+
status=EntityStatus.STOPPED,
|
|
339
|
+
)
|
|
340
|
+
log.info(f"[Case created] {case.id}")
|
|
341
|
+
|
|
342
|
+
# Add case to job's case_ids for foreign key relationship
|
|
343
|
+
from supervaizer.job import Jobs
|
|
344
|
+
|
|
345
|
+
job = Jobs().get_job(job_id)
|
|
346
|
+
if job:
|
|
347
|
+
job.add_case_id(case.id)
|
|
348
|
+
|
|
349
|
+
# Transition from STOPPED to IN_PROGRESS
|
|
350
|
+
|
|
351
|
+
PersistentEntityLifecycle.handle_event(case, EntityEvents.START_WORK)
|
|
352
|
+
|
|
353
|
+
# Send case start event to Supervaize SaaS.
|
|
354
|
+
result = account.send_start_case(case=case)
|
|
355
|
+
if result:
|
|
356
|
+
log.debug(
|
|
357
|
+
f"[Case start] Case {case.id} send to Supervaize with result {result}"
|
|
358
|
+
)
|
|
359
|
+
else:
|
|
360
|
+
log.error(
|
|
361
|
+
f"[Case start] §SCCS01 Case {case.id} failed to send to Supervaize"
|
|
362
|
+
)
|
|
363
|
+
|
|
364
|
+
return case
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
@singleton
|
|
368
|
+
class Cases:
|
|
369
|
+
"""Global registry for all cases, organized by job."""
|
|
370
|
+
|
|
371
|
+
def __init__(self) -> None:
|
|
372
|
+
# Structure: {job_id: {case_id: Case}}
|
|
373
|
+
self.cases_by_job: dict[str, dict[str, "Case"]] = {}
|
|
374
|
+
|
|
375
|
+
def reset(self) -> None:
|
|
376
|
+
self.cases_by_job.clear()
|
|
377
|
+
|
|
378
|
+
def add_case(self, case: "Case") -> None:
|
|
379
|
+
"""Add a case to the registry under its job
|
|
380
|
+
|
|
381
|
+
Args:
|
|
382
|
+
case (Case): The case to add
|
|
383
|
+
|
|
384
|
+
Raises:
|
|
385
|
+
ValueError: If case with same ID already exists for this job
|
|
386
|
+
"""
|
|
387
|
+
job_id = case.job_id
|
|
388
|
+
|
|
389
|
+
# Initialize job's case dict if not exists
|
|
390
|
+
if job_id not in self.cases_by_job:
|
|
391
|
+
self.cases_by_job[job_id] = {}
|
|
392
|
+
|
|
393
|
+
# Check if case already exists for this job
|
|
394
|
+
if case.id in self.cases_by_job[job_id]:
|
|
395
|
+
raise ValueError(f"Case ID '{case.id}' already exists for job {job_id}")
|
|
396
|
+
|
|
397
|
+
self.cases_by_job[job_id][case.id] = case
|
|
398
|
+
|
|
399
|
+
def get_case(self, case_id: str, job_id: str | None = None) -> "Case | None":
|
|
400
|
+
"""Get a case by its ID and optionally job ID
|
|
401
|
+
|
|
402
|
+
Args:
|
|
403
|
+
case_id (str): The ID of the case to get
|
|
404
|
+
job_id (str | None): The ID of the job. If None, searches all jobs.
|
|
405
|
+
|
|
406
|
+
Returns:
|
|
407
|
+
Case | None: The case if found, None otherwise
|
|
408
|
+
"""
|
|
409
|
+
if job_id:
|
|
410
|
+
# Search in specific job's cases
|
|
411
|
+
return self.cases_by_job.get(job_id, {}).get(case_id)
|
|
412
|
+
|
|
413
|
+
# Search across all jobs
|
|
414
|
+
for job_cases in self.cases_by_job.values():
|
|
415
|
+
if case_id in job_cases:
|
|
416
|
+
return job_cases[case_id]
|
|
417
|
+
return None
|
|
418
|
+
|
|
419
|
+
def get_job_cases(self, job_id: str) -> dict[str, "Case"]:
|
|
420
|
+
"""Get all cases for a specific job
|
|
421
|
+
|
|
422
|
+
Args:
|
|
423
|
+
job_id (str): The ID of the job
|
|
424
|
+
|
|
425
|
+
Returns:
|
|
426
|
+
dict[str, Case]: Dictionary of cases for this job, empty if job not found
|
|
427
|
+
"""
|
|
428
|
+
return self.cases_by_job.get(job_id, {})
|
|
429
|
+
|
|
430
|
+
def __contains__(self, case_id: str) -> bool:
|
|
431
|
+
"""Check if case exists in any job's registry"""
|
|
432
|
+
return any(case_id in cases for cases in self.cases_by_job.values())
|