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.
- supervaizer/__init__.py +88 -0
- supervaizer/__version__.py +10 -0
- supervaizer/account.py +304 -0
- supervaizer/account_service.py +87 -0
- supervaizer/admin/routes.py +1254 -0
- supervaizer/admin/templates/agent_detail.html +145 -0
- supervaizer/admin/templates/agents.html +175 -0
- supervaizer/admin/templates/agents_grid.html +80 -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/jobs_list.html +180 -0
- supervaizer/admin/templates/jobs_table.html +122 -0
- supervaizer/admin/templates/navigation.html +153 -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/agent.py +816 -0
- supervaizer/case.py +400 -0
- supervaizer/cli.py +135 -0
- supervaizer/common.py +283 -0
- supervaizer/event.py +181 -0
- supervaizer/examples/controller-template.py +195 -0
- supervaizer/instructions.py +145 -0
- supervaizer/job.py +379 -0
- supervaizer/job_service.py +155 -0
- supervaizer/lifecycle.py +417 -0
- supervaizer/parameter.py +173 -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/protocol/acp/__init__.py +21 -0
- supervaizer/protocol/acp/model.py +198 -0
- supervaizer/protocol/acp/routes.py +74 -0
- supervaizer/py.typed +1 -0
- supervaizer/routes.py +667 -0
- supervaizer/server.py +554 -0
- supervaizer/server_utils.py +54 -0
- supervaizer/storage.py +436 -0
- supervaizer/telemetry.py +81 -0
- supervaizer-0.9.6.dist-info/METADATA +245 -0
- supervaizer-0.9.6.dist-info/RECORD +50 -0
- supervaizer-0.9.6.dist-info/WHEEL +4 -0
- supervaizer-0.9.6.dist-info/entry_points.txt +2 -0
- 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
|