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.
Files changed (76) hide show
  1. supervaizer/__init__.py +97 -0
  2. supervaizer/__version__.py +10 -0
  3. supervaizer/account.py +308 -0
  4. supervaizer/account_service.py +93 -0
  5. supervaizer/admin/routes.py +1293 -0
  6. supervaizer/admin/static/js/job-start-form.js +373 -0
  7. supervaizer/admin/templates/agent_detail.html +145 -0
  8. supervaizer/admin/templates/agents.html +249 -0
  9. supervaizer/admin/templates/agents_grid.html +82 -0
  10. supervaizer/admin/templates/base.html +233 -0
  11. supervaizer/admin/templates/case_detail.html +230 -0
  12. supervaizer/admin/templates/cases_list.html +182 -0
  13. supervaizer/admin/templates/cases_table.html +134 -0
  14. supervaizer/admin/templates/console.html +389 -0
  15. supervaizer/admin/templates/dashboard.html +153 -0
  16. supervaizer/admin/templates/job_detail.html +192 -0
  17. supervaizer/admin/templates/job_start_test.html +109 -0
  18. supervaizer/admin/templates/jobs_list.html +180 -0
  19. supervaizer/admin/templates/jobs_table.html +122 -0
  20. supervaizer/admin/templates/navigation.html +163 -0
  21. supervaizer/admin/templates/recent_activity.html +81 -0
  22. supervaizer/admin/templates/server.html +105 -0
  23. supervaizer/admin/templates/server_status_cards.html +121 -0
  24. supervaizer/admin/templates/supervaize_instructions.html +212 -0
  25. supervaizer/agent.py +956 -0
  26. supervaizer/case.py +432 -0
  27. supervaizer/cli.py +395 -0
  28. supervaizer/common.py +324 -0
  29. supervaizer/deploy/__init__.py +16 -0
  30. supervaizer/deploy/cli.py +305 -0
  31. supervaizer/deploy/commands/__init__.py +9 -0
  32. supervaizer/deploy/commands/clean.py +294 -0
  33. supervaizer/deploy/commands/down.py +119 -0
  34. supervaizer/deploy/commands/local.py +460 -0
  35. supervaizer/deploy/commands/plan.py +167 -0
  36. supervaizer/deploy/commands/status.py +169 -0
  37. supervaizer/deploy/commands/up.py +281 -0
  38. supervaizer/deploy/docker.py +377 -0
  39. supervaizer/deploy/driver_factory.py +42 -0
  40. supervaizer/deploy/drivers/__init__.py +39 -0
  41. supervaizer/deploy/drivers/aws_app_runner.py +607 -0
  42. supervaizer/deploy/drivers/base.py +196 -0
  43. supervaizer/deploy/drivers/cloud_run.py +570 -0
  44. supervaizer/deploy/drivers/do_app_platform.py +504 -0
  45. supervaizer/deploy/health.py +404 -0
  46. supervaizer/deploy/state.py +210 -0
  47. supervaizer/deploy/templates/Dockerfile.template +44 -0
  48. supervaizer/deploy/templates/debug_env.py +69 -0
  49. supervaizer/deploy/templates/docker-compose.yml.template +37 -0
  50. supervaizer/deploy/templates/dockerignore.template +66 -0
  51. supervaizer/deploy/templates/entrypoint.sh +20 -0
  52. supervaizer/deploy/utils.py +52 -0
  53. supervaizer/event.py +181 -0
  54. supervaizer/examples/controller_template.py +196 -0
  55. supervaizer/instructions.py +145 -0
  56. supervaizer/job.py +392 -0
  57. supervaizer/job_service.py +156 -0
  58. supervaizer/lifecycle.py +417 -0
  59. supervaizer/parameter.py +233 -0
  60. supervaizer/protocol/__init__.py +11 -0
  61. supervaizer/protocol/a2a/__init__.py +21 -0
  62. supervaizer/protocol/a2a/model.py +227 -0
  63. supervaizer/protocol/a2a/routes.py +99 -0
  64. supervaizer/py.typed +1 -0
  65. supervaizer/routes.py +917 -0
  66. supervaizer/server.py +553 -0
  67. supervaizer/server_utils.py +54 -0
  68. supervaizer/storage.py +462 -0
  69. supervaizer/telemetry.py +81 -0
  70. supervaizer/utils/__init__.py +16 -0
  71. supervaizer/utils/version_check.py +56 -0
  72. supervaizer-0.10.5.dist-info/METADATA +317 -0
  73. supervaizer-0.10.5.dist-info/RECORD +76 -0
  74. supervaizer-0.10.5.dist-info/WHEEL +4 -0
  75. supervaizer-0.10.5.dist-info/entry_points.txt +2 -0
  76. supervaizer-0.10.5.dist-info/licenses/LICENSE.md +346 -0
@@ -0,0 +1,145 @@
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
+ import sys
8
+
9
+ from art import text2art
10
+ from rich import box, print
11
+ from rich.align import Align
12
+ from rich.console import Console, Group
13
+ from rich.layout import Layout
14
+ from rich.panel import Panel
15
+ from rich.syntax import Syntax
16
+ from rich.table import Table
17
+
18
+ from supervaizer.__version__ import VERSION
19
+
20
+ console = Console()
21
+
22
+
23
+ def make_layout() -> Layout:
24
+ """Define the layout."""
25
+ layout = Layout(name="root")
26
+
27
+ layout.split(
28
+ Layout(name="header", size=10),
29
+ Layout(name="main", ratio=1),
30
+ Layout(name="footer", size=7),
31
+ )
32
+ layout["main"].split_row(
33
+ Layout(name="side"),
34
+ Layout(name="body", ratio=2, minimum_size=100),
35
+ )
36
+ return layout
37
+
38
+
39
+ def make_documentation_message(server_url: str) -> Panel:
40
+ """Some example content."""
41
+ sponsor_message = Table.grid(padding=1)
42
+ sponsor_message.add_column(style="green", justify="right")
43
+ sponsor_message.add_column(no_wrap=True)
44
+ sponsor_message.add_row(
45
+ "Integration documentation",
46
+ "[u blue link=https://supervaize.com/docs/integration]https://supervaize.com/docs/integration[/]",
47
+ )
48
+ sponsor_message.add_row()
49
+ sponsor_message.add_row(
50
+ "Swagger",
51
+ f"[u blue link={server_url}/docs]{server_url}/docs[/]",
52
+ )
53
+ sponsor_message.add_row(
54
+ "Redoc",
55
+ f"[u blue link={server_url}/redoc]{server_url}/redoc[/]",
56
+ )
57
+ sponsor_message.add_row(
58
+ "API",
59
+ f"[u blue link={server_url}/api]{server_url}/api[/]",
60
+ )
61
+
62
+ message = Table.grid(padding=1)
63
+ message.add_column()
64
+ message.add_column(no_wrap=True)
65
+ message.add_row(sponsor_message)
66
+
67
+ message_panel = Panel(
68
+ Align.center(
69
+ Group("\n", Align.center(sponsor_message)),
70
+ vertical="middle",
71
+ ),
72
+ box=box.ROUNDED,
73
+ padding=(1, 2),
74
+ title="[b red]Check Supervaize documentation",
75
+ border_style="bright_blue",
76
+ )
77
+ return message_panel
78
+
79
+
80
+ class Header:
81
+ def __rich__(self) -> Panel:
82
+ grid = Table.grid(expand=True)
83
+ grid.add_column(justify="center", ratio=1)
84
+ grid.add_column(justify="right")
85
+ logo = text2art("Supervaize Control")
86
+ grid.add_row(logo, f"[b]v{VERSION}[/]")
87
+ return Panel(grid, border_style="blue")
88
+
89
+
90
+ def make_syntax() -> Panel:
91
+ """Create a syntax-highlighted code panel."""
92
+ code = """\
93
+ from supervaizer Server, Account, Agent
94
+ sv_account = Account(
95
+ name="CUSTOMERFIRST",
96
+ id="xxxxxxxxxxxx",
97
+ api_key=os.getenv("SUPERVAIZE_API_KEY"),
98
+ api_url=os.getenv("SUPERVAIZE_API_URL"),
99
+ )
100
+
101
+ job_start = AgentMethod(
102
+ name="job_start",
103
+ method="control.job_start",
104
+ params={"action": "run"},
105
+ description="Start the job",
106
+ )
107
+ agent = Agent(
108
+ name="my_agent",
109
+ account=sv_account,
110
+ method=job_start_method,
111
+ )
112
+ server = Server()
113
+ server.launch()
114
+ """
115
+ syntax = Syntax(code, "python", line_numbers=True)
116
+ panel = Panel(syntax, border_style="green", title="Sample integration code")
117
+ return panel
118
+
119
+
120
+ def make_footer(status_message: str) -> Panel:
121
+ """Create a footer panel with status message."""
122
+ return Panel(status_message, border_style="green")
123
+
124
+
125
+ def display_instructions(server_url: str, status_message: str) -> None:
126
+ """Display the full instructions layout.
127
+
128
+ Args:
129
+ server_url: The URL where the server is running
130
+ status_message: Status message to display in footer
131
+ """
132
+ layout = make_layout()
133
+ layout["header"].update(Header())
134
+ layout["body"].update(make_documentation_message(server_url))
135
+ layout["side"].update(make_syntax())
136
+ layout["footer"].update(make_footer(status_message))
137
+
138
+ print(layout)
139
+ sys.exit(0) # This function never returns normally
140
+
141
+
142
+ if __name__ == "__main__":
143
+ display_instructions(
144
+ "http://127.0.0.1:8000", "Starting server on http://127.0.0.1:8000"
145
+ )
supervaizer/job.py ADDED
@@ -0,0 +1,392 @@
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
+ import time
8
+ import traceback
9
+ import uuid
10
+ from datetime import datetime
11
+ from typing import TYPE_CHECKING, Any, ClassVar, Dict, Optional
12
+
13
+ from supervaizer.__version__ import VERSION
14
+ from supervaizer.common import SvBaseModel, log, singleton
15
+ from supervaizer.lifecycle import (
16
+ EntityEvents,
17
+ EntityStatus,
18
+ Lifecycle,
19
+ )
20
+ from supervaizer.storage import storage_manager
21
+
22
+ if TYPE_CHECKING:
23
+ pass
24
+
25
+
26
+ @singleton
27
+ class Jobs:
28
+ """Global registry for all jobs, organized by agent."""
29
+
30
+ def __init__(self) -> None:
31
+ # Structure: {agent_name: {job_id: Job}}
32
+ self.jobs_by_agent: dict[str, dict[str, "Job"]] = {}
33
+
34
+ def reset(self) -> None:
35
+ self.jobs_by_agent.clear()
36
+
37
+ def add_job(self, job: "Job") -> None:
38
+ """Add a job to the registry under its agent
39
+
40
+ Args:
41
+ job (Job): The job to add
42
+
43
+ Raises:
44
+ ValueError: If job with same ID already exists for this agent
45
+ """
46
+ agent_name = job.agent_name
47
+
48
+ # Initialize agent's job dict if not exists
49
+ if agent_name not in self.jobs_by_agent:
50
+ self.jobs_by_agent[agent_name] = {}
51
+
52
+ # Check if job already exists for this agent
53
+ if job.id in self.jobs_by_agent[agent_name]:
54
+ log.warning(f"Job ID '{job.id}' already exists for agent {agent_name}.")
55
+
56
+ self.jobs_by_agent[agent_name][job.id] = job
57
+
58
+ def get_job(
59
+ self,
60
+ job_id: str,
61
+ agent_name: str | None = None,
62
+ include_persisted: bool = False,
63
+ ) -> "Job | None":
64
+ """Get a job by its ID and optionally agent name
65
+
66
+ Args:
67
+ job_id (str): The ID of the job to get
68
+ agent_name (str | None): The name of the agent. If None, searches all agents.
69
+ include_persisted (bool): Whether to include persisted jobs. Defaults to False.
70
+
71
+ Returns:
72
+ Job | None: The job if found, None otherwise
73
+ """
74
+ found_job = None
75
+
76
+ if agent_name:
77
+ # Search in specific agent's jobs
78
+ found_job = self.jobs_by_agent.get(agent_name, {}).get(job_id)
79
+
80
+ # Search across all agents
81
+ for agent_jobs in self.jobs_by_agent.values():
82
+ if job_id in agent_jobs:
83
+ found_job = agent_jobs[job_id]
84
+
85
+ if include_persisted:
86
+ job_from_storage = storage_manager.get_object_by_id("Job", job_id)
87
+ if job_from_storage:
88
+ found_job = Job(**job_from_storage)
89
+ return found_job
90
+
91
+ def get_agent_jobs(self, agent_name: str) -> dict[str, "Job"]:
92
+ """Get all jobs for a specific agent
93
+
94
+ Args:
95
+ agent_name (str): The name of the agent
96
+
97
+ Returns:
98
+ dict[str, Job]: Dictionary of jobs for this agent, empty if agent not found
99
+ """
100
+ return self.jobs_by_agent.get(agent_name, {})
101
+
102
+ def __contains__(self, job_id: str) -> bool:
103
+ """Check if job exists in any agent's registry"""
104
+ return any(job_id in jobs for jobs in self.jobs_by_agent.values())
105
+
106
+
107
+ class JobInstructions(SvBaseModel):
108
+ max_cases: int | None = None
109
+ max_duration: int | None = None # in seconds
110
+ max_cost: float | None = None
111
+ stop_on_warning: bool = False
112
+ stop_on_error: bool = True
113
+
114
+ job_start_time: float | None = None
115
+
116
+ def check(self, cases: int, cost: float) -> tuple[bool, str]:
117
+ """Check if the job conditions are met
118
+
119
+ Args:
120
+ cases (int): Number of cases processed so far
121
+ start_time (float): Start time of the job - using time.perf_counter()
122
+
123
+ Returns:
124
+ tuple[bool, str]: True if job can continue, False if it should stop,
125
+ with explanation message
126
+ """
127
+ if not self.job_start_time:
128
+ self.job_start_time = time.perf_counter()
129
+ if self.max_cases and cases >= self.max_cases:
130
+ return (False, f"Max cases {self.max_cases} reached")
131
+
132
+ duration = time.perf_counter() - self.job_start_time
133
+ if self.max_duration and duration >= self.max_duration:
134
+ return (False, f"Max duration {self.max_duration} seconds reached")
135
+
136
+ if self.max_cost and cost >= self.max_cost:
137
+ return (False, f"Max cost {self.max_cost} reached")
138
+
139
+ return (True, "")
140
+
141
+ @property
142
+ def registration_info(self) -> Dict[str, Any]:
143
+ """Returns registration info for the job instructions"""
144
+ return {
145
+ "max_cases": self.max_cases,
146
+ "max_duration": self.max_duration,
147
+ "max_cost": self.max_cost,
148
+ "stop_on_warning": self.stop_on_warning,
149
+ "stop_on_error": self.stop_on_error,
150
+ }
151
+
152
+
153
+ class JobContext(SvBaseModel):
154
+ workspace_id: str
155
+ job_id: str
156
+ started_by: str
157
+ started_at: datetime
158
+ mission_id: str
159
+ mission_name: str
160
+ mission_context: Any = None
161
+ job_instructions: Optional[JobInstructions] = None
162
+
163
+ @property
164
+ def registration_info(self) -> Dict[str, Any]:
165
+ """Returns registration info for the job context"""
166
+ return {
167
+ "workspace_id": self.workspace_id,
168
+ "job_id": self.job_id,
169
+ "started_by": self.started_by,
170
+ "started_at": self.started_at.isoformat() if self.started_at else "",
171
+ "mission_id": self.mission_id,
172
+ "mission_name": self.mission_name,
173
+ "mission_context": self.mission_context,
174
+ "job_instructions": self.job_instructions.registration_info
175
+ if self.job_instructions
176
+ else None,
177
+ }
178
+
179
+
180
+ class JobResponse(SvBaseModel):
181
+ job_id: str
182
+ status: EntityStatus
183
+ message: str
184
+ payload: Optional[dict[str, Any]] = None
185
+ error_message: Optional[str] = None
186
+ error_traceback: Optional[str] = None
187
+
188
+ def __init__(
189
+ self,
190
+ job_id: str,
191
+ status: EntityStatus,
192
+ message: str,
193
+ payload: Optional[dict[str, Any]] = None,
194
+ error: Optional[Exception] = None,
195
+ **kwargs: Any,
196
+ ) -> None:
197
+ log.debug(
198
+ f"[JobResponse __init__] job_id={job_id}, status={status}, message={message}, payload={payload}, error={error}, kwargs={kwargs}"
199
+ )
200
+ if error:
201
+ error_message = str(error)
202
+ error_traceback = traceback.format_exc()
203
+ else:
204
+ error_message = error_traceback = ""
205
+ kwargs["job_id"] = job_id
206
+ kwargs["status"] = status
207
+ kwargs["message"] = message
208
+ kwargs["payload"] = payload
209
+ kwargs["error_message"] = error_message
210
+ kwargs["error_traceback"] = error_traceback
211
+ super().__init__(**kwargs)
212
+
213
+ if error:
214
+ log.error(
215
+ f"[Job Response] Execution failed - Job ID <{self.job_id}>: {self.error_message}"
216
+ )
217
+ log.error(self.error_traceback)
218
+
219
+ @property
220
+ def registration_info(self) -> Dict[str, Any]:
221
+ """Returns registration info for the job response"""
222
+ return {
223
+ "job_id": self.job_id,
224
+ "status": self.status.value,
225
+ "message": self.message,
226
+ "payload": self.payload,
227
+ "error_message": self.error_message,
228
+ "error_traceback": self.error_traceback,
229
+ }
230
+
231
+
232
+ class AbstractJob(SvBaseModel):
233
+ supervaizer_VERSION: ClassVar[str] = VERSION
234
+ id: str
235
+ name: str
236
+ agent_name: str
237
+ status: EntityStatus
238
+ job_context: JobContext
239
+ payload: Any | None = None
240
+ result: Any | None = None
241
+ error: str | None = None
242
+ responses: list["JobResponse"] = []
243
+ finished_at: datetime | None = None
244
+ created_at: datetime | None = None
245
+ agent_parameters: list[dict[str, Any]] | None = None
246
+ case_ids: list[str] = [] # Foreign key relationship to cases
247
+
248
+
249
+ class Job(AbstractJob):
250
+ """
251
+ Jobs are typically created by the platform and are not created by the agent.
252
+
253
+ Args:
254
+ id (str): Unique identifier for the job - provided by the platform
255
+ agent_name (str): Name (slug) of the agent running the job
256
+ status (EntityStatus): Current status of the job
257
+ job_context (JobContext): Context information for the job
258
+ payload (Any, optional): Job payload data. Defaults to None
259
+ result (Any, optional): Job result data. Defaults to None
260
+ error (str, optional): Error message if job failed. Defaults to None
261
+ responses (list[JobResponse], optional): List of job responses. Defaults to empty list
262
+ finished_at (datetime, optional): When job completed. Defaults to None
263
+ created_at (datetime, optional): When job was created. Defaults to None
264
+ """
265
+
266
+ def __init__(self, **kwargs: Any) -> None:
267
+ super().__init__(**kwargs)
268
+ self.created_at = datetime.now()
269
+ Jobs().add_job(
270
+ job=self,
271
+ )
272
+ # Persist job to storage
273
+
274
+ storage_manager.save_object("Job", self.to_dict)
275
+
276
+ def add_response(self, response: JobResponse) -> None:
277
+ """Add a response to the job and update status based on the event lifecycle.
278
+
279
+ Args:
280
+ response: The response to add
281
+ """
282
+ if response.status in Lifecycle.get_terminal_states():
283
+ self.finished_at = datetime.now()
284
+
285
+ # Update payload
286
+ self.payload = response.payload
287
+ self.status = response.status
288
+ # Additional handling for completed or failed jobs
289
+ if response.status == EntityStatus.COMPLETED:
290
+ self.result = response.payload
291
+
292
+ if response.status == EntityStatus.FAILED:
293
+ self.error = response.message
294
+
295
+ self.responses.append(response)
296
+
297
+ # Persist updated job to storage
298
+
299
+ storage_manager.save_object("Job", self.to_dict)
300
+
301
+ def add_case_id(self, case_id: str) -> None:
302
+ """Add a case ID to this job's case list.
303
+
304
+ Args:
305
+ case_id: The case ID to add
306
+ """
307
+ if case_id not in self.case_ids:
308
+ self.case_ids.append(case_id)
309
+ log.debug(f"[Job add_response] Added case {case_id} to job {self.id}")
310
+ # Persist updated job to storage
311
+ storage_manager.save_object("Job", self.to_dict)
312
+
313
+ def remove_case_id(self, case_id: str) -> None:
314
+ """Remove a case ID from this job's case list.
315
+
316
+ Args:
317
+ case_id: The case ID to remove
318
+ """
319
+ if case_id in self.case_ids:
320
+ self.case_ids.remove(case_id)
321
+ log.debug(f"Removed case {case_id} from job {self.id}")
322
+ # Persist updated job to storage
323
+ storage_manager.save_object("Job", self.to_dict)
324
+
325
+ @property
326
+ def registration_info(self) -> Dict[str, Any]:
327
+ """Returns registration info for the job"""
328
+ return {
329
+ "id": self.id,
330
+ "agent_name": self.agent_name,
331
+ "status": self.status.value,
332
+ "job_context": self.job_context.registration_info,
333
+ "payload": self.payload,
334
+ "result": self.result,
335
+ "error": self.error,
336
+ "responses": [response.registration_info for response in self.responses],
337
+ "finished_at": self.finished_at.isoformat() if self.finished_at else "",
338
+ "created_at": self.created_at.isoformat() if self.created_at else "",
339
+ "case_ids": self.case_ids,
340
+ }
341
+
342
+ @classmethod
343
+ def new(
344
+ cls,
345
+ job_context: "JobContext",
346
+ agent_name: str,
347
+ agent_parameters: Optional[list[dict[str, Any]]] = None,
348
+ name: Optional[str] = None,
349
+ ) -> "Job":
350
+ """Create a new job
351
+
352
+ Args:
353
+ job_context (JobContext): The context of the job
354
+ agent_name (str): The name of the agent
355
+ agent_parameters (list[dict[str, Any]] | None): Optional parameters for the job
356
+ name (str | None): Optional name for the job, defaults to mission name if not provided
357
+
358
+ Returns:
359
+ Job: The new job
360
+ """
361
+ job_id = job_context.job_id or str(uuid.uuid4())
362
+ # Use provided name or fallback to mission name from context
363
+ job_name = name or job_context.mission_name
364
+
365
+ # Ensure agent_parameters is a list of dicts, not nested incorrectly
366
+ if agent_parameters is not None:
367
+ # If it's a list but the first element is also a list, unwrap it
368
+ if isinstance(agent_parameters, list) and len(agent_parameters) > 0:
369
+ if isinstance(agent_parameters[0], list):
370
+ # Unwrap nested list: [[{...}, {...}]] -> [{...}, {...}]
371
+ agent_parameters = agent_parameters[0]
372
+ # Ensure all elements are dicts
373
+ if not all(isinstance(p, dict) for p in agent_parameters):
374
+ raise ValueError(
375
+ f"agent_parameters must be a list of dictionaries, got: {type(agent_parameters)}"
376
+ )
377
+
378
+ job = cls(
379
+ id=job_id,
380
+ name=job_name,
381
+ agent_name=agent_name,
382
+ job_context=job_context,
383
+ status=EntityStatus.STOPPED,
384
+ agent_parameters=agent_parameters,
385
+ )
386
+
387
+ # Transition from STOPPED to IN_PROGRESS
388
+ from supervaizer.storage import PersistentEntityLifecycle
389
+
390
+ PersistentEntityLifecycle.handle_event(job, EntityEvents.START_WORK)
391
+
392
+ return job
@@ -0,0 +1,156 @@
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
+ import json
8
+ from typing import TYPE_CHECKING, Any, Dict, Optional
9
+
10
+
11
+ from supervaizer.common import decrypt_value, log
12
+ from supervaizer.event import JobFinishedEvent
13
+ from supervaizer.job import Job, Jobs
14
+ from supervaizer.lifecycle import EntityStatus
15
+
16
+ if TYPE_CHECKING:
17
+ from fastapi import BackgroundTasks
18
+
19
+ from supervaizer.agent import Agent
20
+ from supervaizer.job import JobContext
21
+ from supervaizer.server import Server
22
+
23
+
24
+ async def service_job_start(
25
+ server: "Server",
26
+ background_tasks: "BackgroundTasks",
27
+ agent: "Agent",
28
+ sv_context: "JobContext",
29
+ job_fields: Dict[str, Any],
30
+ encrypted_agent_parameters: Optional[str] = None,
31
+ ) -> "Job":
32
+ """
33
+ Create a new job and schedule its execution.
34
+
35
+ Args:
36
+ server: The server instance
37
+ background_tasks: FastAPI background tasks
38
+ agent: The agent to run the job
39
+ sv_context: The supervaize context
40
+ job_fields: Fields for the job
41
+ encrypted_agent_parameters: Optional encrypted parameters
42
+
43
+ Returns:
44
+ The created job
45
+ """
46
+ agent_parameters = None
47
+ # If agent has parameters_setup defined, validate parameters
48
+ if getattr(agent, "parameters_setup") and encrypted_agent_parameters:
49
+ agent_parameters_str = decrypt_value(
50
+ encrypted_agent_parameters, server.private_key
51
+ )
52
+ agent_parameters = (
53
+ json.loads(agent_parameters_str) if agent_parameters_str else None
54
+ )
55
+
56
+ # inspect(agent)
57
+ log.debug(
58
+ f"[service_job_start Decrypted parameters] : parameters = {agent_parameters}"
59
+ )
60
+
61
+ # Create and prepare the job
62
+ new_saas_job = Job.new(
63
+ job_context=sv_context,
64
+ agent_name=agent.name,
65
+ agent_parameters=agent_parameters,
66
+ name=sv_context.job_id,
67
+ )
68
+
69
+ # Start the background execution
70
+ background_tasks.add_task(
71
+ agent.job_start, new_saas_job, job_fields, sv_context, server
72
+ )
73
+ return new_saas_job
74
+
75
+
76
+ def service_job_finished(job: Job, server: "Server") -> None:
77
+ """
78
+ Service to handle the completion of a job.
79
+
80
+ Args:
81
+ job: The job that has finished
82
+ server: The server instance
83
+
84
+ Tested in tests/test_job_service.py
85
+ """
86
+ assert server.supervisor_account is not None, "No account defined"
87
+ account = server.supervisor_account
88
+ event = JobFinishedEvent(
89
+ job=job,
90
+ account=account,
91
+ )
92
+ account.send_event(sender=job, event=event)
93
+
94
+
95
+ async def service_job_custom(
96
+ method_name: str,
97
+ server: "Server",
98
+ background_tasks: "BackgroundTasks",
99
+ agent: "Agent",
100
+ sv_context: "JobContext",
101
+ job_fields: Dict[str, Any],
102
+ encrypted_agent_parameters: Optional[str] = None,
103
+ ) -> "Job":
104
+ """
105
+ Create a new job and schedule its execution for a custom method.
106
+
107
+ Args:
108
+ server: The server instance
109
+ background_tasks: FastAPI background tasks
110
+ agent: The agent to run the job
111
+ sv_context: The supervaize context
112
+ job_fields: Fields for the job
113
+ encrypted_agent_parameters: Optional encrypted parameters
114
+
115
+ Returns:
116
+ The created job
117
+ """
118
+ log.info(
119
+ f"[service_job_custom] /custom/{method_name} [custom job] {agent.name} with params {job_fields}"
120
+ )
121
+ _agent_parameters: dict[str, Any] | None = None
122
+ # If agent has parameters_setup defined, validate parameters
123
+ if getattr(agent, "parameters_setup") and encrypted_agent_parameters:
124
+ agent_parameters_str = decrypt_value(
125
+ encrypted_agent_parameters, server.private_key
126
+ )
127
+ _agent_parameters = (
128
+ json.loads(agent_parameters_str) if agent_parameters_str else None
129
+ )
130
+ log.debug("[Decrypted parameters] : parameters decrypted")
131
+
132
+ # Create and prepare the job
133
+ job_id = sv_context.job_id
134
+
135
+ if not job_id:
136
+ raise ValueError(
137
+ "[service_job_custom] Job ID is required to start a custom job"
138
+ )
139
+
140
+ job = Jobs().get_job(job_id) or Job(
141
+ id=job_id,
142
+ job_context=sv_context,
143
+ agent_name=agent.name,
144
+ name=sv_context.mission_name,
145
+ status=EntityStatus.STOPPED,
146
+ ) # TODO clean the name
147
+ # Start the background execution
148
+ background_tasks.add_task(
149
+ agent.job_start,
150
+ job,
151
+ job_fields,
152
+ sv_context,
153
+ server,
154
+ method_name,
155
+ )
156
+ return job