supervaizer 0.9.6__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 (50) hide show
  1. supervaizer/__init__.py +88 -0
  2. supervaizer/__version__.py +10 -0
  3. supervaizer/account.py +304 -0
  4. supervaizer/account_service.py +87 -0
  5. supervaizer/admin/routes.py +1254 -0
  6. supervaizer/admin/templates/agent_detail.html +145 -0
  7. supervaizer/admin/templates/agents.html +175 -0
  8. supervaizer/admin/templates/agents_grid.html +80 -0
  9. supervaizer/admin/templates/base.html +233 -0
  10. supervaizer/admin/templates/case_detail.html +230 -0
  11. supervaizer/admin/templates/cases_list.html +182 -0
  12. supervaizer/admin/templates/cases_table.html +134 -0
  13. supervaizer/admin/templates/console.html +389 -0
  14. supervaizer/admin/templates/dashboard.html +153 -0
  15. supervaizer/admin/templates/job_detail.html +192 -0
  16. supervaizer/admin/templates/jobs_list.html +180 -0
  17. supervaizer/admin/templates/jobs_table.html +122 -0
  18. supervaizer/admin/templates/navigation.html +153 -0
  19. supervaizer/admin/templates/recent_activity.html +81 -0
  20. supervaizer/admin/templates/server.html +105 -0
  21. supervaizer/admin/templates/server_status_cards.html +121 -0
  22. supervaizer/agent.py +816 -0
  23. supervaizer/case.py +400 -0
  24. supervaizer/cli.py +135 -0
  25. supervaizer/common.py +283 -0
  26. supervaizer/event.py +181 -0
  27. supervaizer/examples/controller-template.py +195 -0
  28. supervaizer/instructions.py +145 -0
  29. supervaizer/job.py +379 -0
  30. supervaizer/job_service.py +155 -0
  31. supervaizer/lifecycle.py +417 -0
  32. supervaizer/parameter.py +173 -0
  33. supervaizer/protocol/__init__.py +11 -0
  34. supervaizer/protocol/a2a/__init__.py +21 -0
  35. supervaizer/protocol/a2a/model.py +227 -0
  36. supervaizer/protocol/a2a/routes.py +99 -0
  37. supervaizer/protocol/acp/__init__.py +21 -0
  38. supervaizer/protocol/acp/model.py +198 -0
  39. supervaizer/protocol/acp/routes.py +74 -0
  40. supervaizer/py.typed +1 -0
  41. supervaizer/routes.py +667 -0
  42. supervaizer/server.py +554 -0
  43. supervaizer/server_utils.py +54 -0
  44. supervaizer/storage.py +436 -0
  45. supervaizer/telemetry.py +81 -0
  46. supervaizer-0.9.6.dist-info/METADATA +245 -0
  47. supervaizer-0.9.6.dist-info/RECORD +50 -0
  48. supervaizer-0.9.6.dist-info/WHEEL +4 -0
  49. supervaizer-0.9.6.dist-info/entry_points.txt +2 -0
  50. supervaizer-0.9.6.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,379 @@
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, List, 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 (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
+ job = cls(
366
+ id=job_id,
367
+ name=job_name,
368
+ agent_name=agent_name,
369
+ job_context=job_context,
370
+ status=EntityStatus.STOPPED,
371
+ agent_parameters=agent_parameters,
372
+ )
373
+
374
+ # Transition from STOPPED to IN_PROGRESS
375
+ from supervaizer.storage import PersistentEntityLifecycle
376
+
377
+ PersistentEntityLifecycle.handle_event(job, EntityEvents.START_WORK)
378
+
379
+ return job
@@ -0,0 +1,155 @@
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
+ from rich import inspect
11
+
12
+ from supervaizer.common import decrypt_value, log
13
+ from supervaizer.event import JobFinishedEvent
14
+ from supervaizer.job import Job, Jobs
15
+ from supervaizer.lifecycle import EntityStatus
16
+
17
+ if TYPE_CHECKING:
18
+ from fastapi import BackgroundTasks
19
+
20
+ from supervaizer.agent import Agent
21
+ from supervaizer.job import JobContext
22
+ from supervaizer.server import Server
23
+
24
+
25
+ async def service_job_start(
26
+ server: "Server",
27
+ background_tasks: "BackgroundTasks",
28
+ agent: "Agent",
29
+ sv_context: "JobContext",
30
+ job_fields: Dict[str, Any],
31
+ encrypted_agent_parameters: Optional[str] = None,
32
+ ) -> "Job":
33
+ """
34
+ Create a new job and schedule its execution.
35
+
36
+ Args:
37
+ server: The server instance
38
+ background_tasks: FastAPI background tasks
39
+ agent: The agent to run the job
40
+ sv_context: The supervaize context
41
+ job_fields: Fields for the job
42
+ encrypted_agent_parameters: Optional encrypted parameters
43
+
44
+ Returns:
45
+ The created job
46
+ """
47
+ agent_parameters: dict[str, Any] | None = None
48
+ # If agent has parameters_setup defined, validate parameters
49
+ if getattr(agent, "parameters_setup") and encrypted_agent_parameters:
50
+ agent_parameters_str = decrypt_value(
51
+ encrypted_agent_parameters, server.private_key
52
+ )
53
+ agent_parameters = (
54
+ json.loads(agent_parameters_str) if agent_parameters_str else None
55
+ )
56
+
57
+ inspect(agent)
58
+ log.debug(f"[Decrypted parameters] : parameters = {agent_parameters}")
59
+
60
+ # Create and prepare the job
61
+ new_saas_job = Job.new(
62
+ job_context=sv_context,
63
+ agent_name=agent.name,
64
+ agent_parameters=[agent_parameters] if agent_parameters else None,
65
+ name=sv_context.job_id,
66
+ )
67
+
68
+ # Start the background execution
69
+ background_tasks.add_task(
70
+ agent.job_start, new_saas_job, job_fields, sv_context, server
71
+ )
72
+ return new_saas_job
73
+
74
+
75
+ def service_job_finished(job: Job, server: "Server") -> None:
76
+ """
77
+ Service to handle the completion of a job.
78
+
79
+ Args:
80
+ job: The job that has finished
81
+ server: The server instance
82
+
83
+ Tested in tests/test_job_service.py
84
+ """
85
+ assert server.supervisor_account is not None, "No account defined"
86
+ account = server.supervisor_account
87
+ event = JobFinishedEvent(
88
+ job=job,
89
+ account=account,
90
+ )
91
+ account.send_event(sender=job, event=event)
92
+
93
+
94
+ async def service_job_custom(
95
+ method_name: str,
96
+ server: "Server",
97
+ background_tasks: "BackgroundTasks",
98
+ agent: "Agent",
99
+ sv_context: "JobContext",
100
+ job_fields: Dict[str, Any],
101
+ encrypted_agent_parameters: Optional[str] = None,
102
+ ) -> "Job":
103
+ """
104
+ Create a new job and schedule its execution for a custom method.
105
+
106
+ Args:
107
+ server: The server instance
108
+ background_tasks: FastAPI background tasks
109
+ agent: The agent to run the job
110
+ sv_context: The supervaize context
111
+ job_fields: Fields for the job
112
+ encrypted_agent_parameters: Optional encrypted parameters
113
+
114
+ Returns:
115
+ The created job
116
+ """
117
+ log.info(
118
+ f"[service_job_custom] /custom/{method_name} [custom job] {agent.name} with params {job_fields}"
119
+ )
120
+ _agent_parameters: dict[str, Any] | None = None
121
+ # If agent has parameters_setup defined, validate parameters
122
+ if getattr(agent, "parameters_setup") and encrypted_agent_parameters:
123
+ agent_parameters_str = decrypt_value(
124
+ encrypted_agent_parameters, server.private_key
125
+ )
126
+ _agent_parameters = (
127
+ json.loads(agent_parameters_str) if agent_parameters_str else None
128
+ )
129
+ log.debug("[Decrypted parameters] : parameters decrypted")
130
+
131
+ # Create and prepare the job
132
+ job_id = sv_context.job_id
133
+
134
+ if not job_id:
135
+ raise ValueError(
136
+ "[service_job_custom] Job ID is required to start a custom job"
137
+ )
138
+
139
+ job = Jobs().get_job(job_id) or Job(
140
+ id=job_id,
141
+ job_context=sv_context,
142
+ agent_name=agent.name,
143
+ name=sv_context.mission_name,
144
+ status=EntityStatus.STOPPED,
145
+ ) # TODO clean the name
146
+ # Start the background execution
147
+ background_tasks.add_task(
148
+ agent.job_start,
149
+ job,
150
+ job_fields,
151
+ sv_context,
152
+ server,
153
+ method_name,
154
+ )
155
+ return job