simrun 0.1.0__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.
simrun/mcp.py ADDED
@@ -0,0 +1,161 @@
1
+ """The MCP server of simrun, a local stdio process of an agent.
2
+
3
+ The tools go through the `Client`; they return the JSON of the API and `store`, the
4
+ absolute local prefix of the paths in it, which are relative to the store, so an
5
+ agent opens a result with `store / path`. A refusal is returned as
6
+ `{"error": detail, "status": code}` instead of raised, so an agent can read it.
7
+ """
8
+
9
+ import logging
10
+ import subprocess
11
+ from collections.abc import Callable
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+ import httpx
16
+ from fastmcp import FastMCP
17
+ from pydantic import BaseModel
18
+
19
+ from simrun.client import Client, ClientError
20
+ from simrun.service.logs import DEFAULT_TAIL_LINES
21
+ from simrun.store import RunState
22
+ from simrun.workflow import SplitMode
23
+
24
+ logger = logging.getLogger(__name__)
25
+
26
+ DEFAULT_WAIT_S = 600.0
27
+ # status of an error which did not come from the service
28
+ CLIENT_SIDE = 400
29
+ UNREACHABLE = 503
30
+
31
+
32
+ def _plain(value: Any) -> Any:
33
+ if isinstance(value, BaseModel):
34
+ return value.model_dump(mode="json")
35
+ if isinstance(value, dict):
36
+ return {k: _plain(v) for k, v in value.items()}
37
+ if isinstance(value, list):
38
+ return [_plain(v) for v in value]
39
+ return value
40
+
41
+
42
+ def build_server(client: Client) -> FastMCP:
43
+ """The MCP server whose tools call the service through `client`."""
44
+ server = FastMCP("simrun")
45
+ store = str(client.store.resolve())
46
+
47
+ def answer(call: Callable[[], dict[str, Any]]) -> dict[str, Any]:
48
+ try:
49
+ return {**_plain(call()), "store": store}
50
+ except ClientError as error:
51
+ return {"error": error.detail, "status": error.status}
52
+ except httpx.TransportError as error:
53
+ return {
54
+ "error": f"the service is not reachable: {error}",
55
+ "status": UNREACHABLE,
56
+ }
57
+ except (ValueError, RuntimeError, OSError, subprocess.SubprocessError) as error:
58
+ return {"error": str(error), "status": CLIENT_SIDE}
59
+
60
+ @server.tool
61
+ def submit_workflow(
62
+ workflow_path: str,
63
+ sources: dict[str, str] | None = None,
64
+ group: str | None = None,
65
+ single_server: bool = False,
66
+ ) -> dict[str, Any]:
67
+ """Snapshot the working trees and submit a workflow.
68
+
69
+ `sources` maps a source name to the path of its git working tree, the model
70
+ first. Returns the summary of the run at once, in the state `submitting`
71
+ while its environment is resolved and built; follow it with `wait_for_run`.
72
+ """
73
+ paths = {name: Path(p).expanduser() for name, p in (sources or {}).items()}
74
+ split = SplitMode.SINGLE_SERVER if single_server else None
75
+ return answer(
76
+ lambda: {
77
+ "run": client.submit(
78
+ Path(workflow_path).expanduser(), paths, group, split
79
+ )
80
+ }
81
+ )
82
+
83
+ @server.tool
84
+ def wait_for_run(run_id: str, timeout_s: float = DEFAULT_WAIT_S) -> dict[str, Any]:
85
+ """Wait for a run to end or for `timeout_s` seconds; returns its summary."""
86
+ return answer(lambda: {"run": client.wait(run_id, timeout_s)})
87
+
88
+ @server.tool
89
+ def run_status(run_id: str) -> dict[str, Any]:
90
+ """The record of a run: state, tasks, plans and the provenance of its sources."""
91
+ return answer(lambda: {"run": client.status(run_id)})
92
+
93
+ @server.tool
94
+ def run_progress(run_id: str) -> dict[str, Any]:
95
+ """The progress of every task of a run: ledger counts, first and last progress lines."""
96
+ return answer(lambda: {"progress": client.progress(run_id)})
97
+
98
+ @server.tool
99
+ def list_runs(
100
+ group: str | None = None,
101
+ agent: str | None = None,
102
+ state: str | None = None,
103
+ owner: str | None = None,
104
+ ) -> dict[str, Any]:
105
+ """The runs, filtered by group, agent, state and owner.
106
+
107
+ The runs of an owner include the ones of their agents.
108
+ """
109
+ return answer(
110
+ lambda: {
111
+ "runs": client.list_runs(
112
+ group, agent, RunState(state) if state else None, owner
113
+ )
114
+ }
115
+ )
116
+
117
+ @server.tool
118
+ def compare_runs(run_ids: list[str]) -> dict[str, Any]:
119
+ """Provenance and task summaries of runs side by side."""
120
+ return answer(lambda: {"table": client.compare(run_ids)})
121
+
122
+ @server.tool
123
+ def cancel_run(run_id: str) -> dict[str, Any]:
124
+ """Cancel a run."""
125
+ return answer(lambda: {"run": client.cancel(run_id)})
126
+
127
+ @server.tool
128
+ def run_logs(
129
+ run_id: str, task_id: str, tail_lines: int = DEFAULT_TAIL_LINES
130
+ ) -> dict[str, Any]:
131
+ """The logs of the pods of a task, the last `tail_lines` of each (0: all)."""
132
+ return answer(lambda: {"logs": client.logs(run_id, task_id, tail_lines)})
133
+
134
+ @server.tool
135
+ def run_outputs(run_id: str, task_id: str) -> dict[str, Any]:
136
+ """The output files of a task, paths relative to `store`."""
137
+ return answer(lambda: {"files": client.outputs(run_id, task_id)})
138
+
139
+ @server.tool
140
+ def estimate_workflow(
141
+ workflow_path: str,
142
+ sources: dict[str, str] | None = None,
143
+ single_server: bool = False,
144
+ ) -> dict[str, Any]:
145
+ """The plan and the expected duration of every task of a submission."""
146
+ paths = {name: Path(p).expanduser() for name, p in (sources or {}).items()}
147
+ split = SplitMode.SINGLE_SERVER if single_server else None
148
+ return answer(
149
+ lambda: {
150
+ "tasks": client.estimate(
151
+ Path(workflow_path).expanduser(), paths, None, split
152
+ )
153
+ }
154
+ )
155
+
156
+ return server
157
+
158
+
159
+ def main() -> None:
160
+ """Run the server on stdio with the client of the environment."""
161
+ build_server(Client.from_env()).run()
simrun/planner.py ADDED
@@ -0,0 +1,303 @@
1
+ """The planner: the shape of a worker pod and the number of pieces of a task."""
2
+
3
+ import logging
4
+ import math
5
+ from collections import Counter
6
+ from dataclasses import dataclass
7
+
8
+ from simrun.plugins import TaskType
9
+ from simrun.plugins_builtin import COMMAND, CommandSettings
10
+ from simrun.resources import GIB, RETRY_FACTOR, ResolvedResources
11
+ from simrun.worker import processes
12
+ from simrun.workflow import Priority, SplitMode, TaskSpec
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+ MAX_PIECES = 8
17
+ PRIORITY_VALUES = {
18
+ "simrun-owner-base": 1000,
19
+ "simrun-owner-extra": 900,
20
+ "simrun-standard-base": 500,
21
+ "simrun-standard-extra": 400,
22
+ "simrun-agent-base": 200,
23
+ "simrun-agent-extra": 100,
24
+ }
25
+
26
+
27
+ class PlanError(ValueError):
28
+ """A task which cannot be planned on the nodes."""
29
+
30
+
31
+ @dataclass(frozen=True, init=False)
32
+ class Node:
33
+ """A node of the cluster.
34
+
35
+ Attributes:
36
+ name: Name of the node.
37
+ cores: The whole cores a worker pod of a run can have on the node, what the
38
+ planner plans with.
39
+ memory_gb: The GiB of memory a worker pod of a run can have on the node,
40
+ what the planner plans with.
41
+ gpus: Number of GPUs.
42
+ reserved_by: Name of the reservation holding the node, None if free.
43
+ allocatable_cores: The cores the scheduler gives the pods of the runs
44
+ (sidecars included), as it counts their requests.
45
+ allocatable_memory_gb: The GiB of memory the scheduler gives the pods of
46
+ the runs, as it counts their requests.
47
+ """
48
+
49
+ name: str
50
+ cores: int
51
+ memory_gb: float
52
+ gpus: int
53
+ reserved_by: str | None
54
+ allocatable_cores: float
55
+ allocatable_memory_gb: float
56
+
57
+ def __init__(
58
+ self,
59
+ name: str,
60
+ cores: int,
61
+ memory_gb: float,
62
+ gpus: int = 0,
63
+ reserved_by: str | None = None,
64
+ allocatable_cores: float | None = None,
65
+ allocatable_memory_gb: float | None = None,
66
+ ) -> None:
67
+ """Create the node; the allocatable cores and memory default to the others."""
68
+ values = {
69
+ "name": name,
70
+ "cores": cores,
71
+ "memory_gb": memory_gb,
72
+ "gpus": gpus,
73
+ "reserved_by": reserved_by,
74
+ "allocatable_cores": float(
75
+ cores if allocatable_cores is None else allocatable_cores
76
+ ),
77
+ "allocatable_memory_gb": float(
78
+ memory_gb if allocatable_memory_gb is None else allocatable_memory_gb
79
+ ),
80
+ }
81
+ for key, value in values.items():
82
+ # a frozen dataclass sets its fields past its own __setattr__
83
+ object.__setattr__(self, key, value)
84
+
85
+
86
+ @dataclass(frozen=True)
87
+ class PodShape:
88
+ """The resources of a worker pod.
89
+
90
+ Attributes:
91
+ cores: Number of cores.
92
+ memory_gb: Memory in GiB, rounded up.
93
+ processes: Number of worker processes.
94
+ per_worker_gb: Memory per worker process in GiB.
95
+ retry_memory_gb: Memory of a retry in GiB.
96
+ retry_per_worker_gb: Memory per worker process of a retry in GiB.
97
+ scratch_gb: Local scratch in GiB.
98
+ gpus: Number of GPUs.
99
+ """
100
+
101
+ cores: int
102
+ memory_gb: int
103
+ processes: int
104
+ per_worker_gb: float
105
+ retry_memory_gb: int
106
+ retry_per_worker_gb: float
107
+ scratch_gb: float
108
+ gpus: int
109
+
110
+
111
+ @dataclass(frozen=True)
112
+ class TaskPlan:
113
+ """The plan of a task.
114
+
115
+ Attributes:
116
+ task_id: Id of the task.
117
+ pods: Number of worker pods.
118
+ shape: Shape of a pod.
119
+ base_priority: Priority class of the first pods.
120
+ extra_priority: Priority class of the further pods.
121
+ units: Number of units, None for an unsplit task.
122
+ """
123
+
124
+ task_id: str
125
+ pods: int
126
+ shape: PodShape
127
+ base_priority: str
128
+ extra_priority: str
129
+ units: int | None
130
+
131
+
132
+ def _usable(nodes: list[Node], gpus: int) -> list[Node]:
133
+ return [n for n in nodes if n.reserved_by is None and n.gpus >= gpus]
134
+
135
+
136
+ def _common_node(nodes: list[Node], gpus: int) -> Node:
137
+ """Returns the most common node by cores and memory, the larger on ties."""
138
+ usable = _usable(nodes, gpus)
139
+ if not usable:
140
+ raise PlanError(f"no node which is not reserved has {gpus} GPUs")
141
+ counts = Counter((n.cores, n.memory_gb) for n in usable)
142
+ cores, memory = max(counts, key=lambda k: (counts[k], k[1], k[0]))
143
+ return next(n for n in usable if (n.cores, n.memory_gb) == (cores, memory))
144
+
145
+
146
+ def _largest(nodes: list[Node], gpus: int) -> Node:
147
+ usable = _usable(nodes, gpus)
148
+ if not usable:
149
+ raise PlanError(f"no node which is not reserved has {gpus} GPUs")
150
+ return max(usable, key=lambda n: (n.memory_gb, n.cores))
151
+
152
+
153
+ def _fit(node: Node, base: float, per_worker: float) -> int | None:
154
+ """Returns the number of processes on the node, None if one does not fit."""
155
+ try:
156
+ return processes(node.cores, int(node.memory_gb * GIB), base, per_worker)
157
+ except ValueError:
158
+ return None
159
+
160
+
161
+ def shape(
162
+ resources: ResolvedResources, nodes: list[Node], max_processes: int | None = None
163
+ ) -> PodShape:
164
+ """Sizes a pod for the most common node which is not reserved.
165
+
166
+ When one worker does not fit that node, the largest usable node is taken.
167
+
168
+ Args:
169
+ resources: The resources of the task.
170
+ nodes: The nodes of the cluster.
171
+ max_processes: At most this many processes, as many as fit by default.
172
+
173
+ Returns:
174
+ The shape of a pod.
175
+
176
+ Raises:
177
+ PlanError: One worker fits on no node.
178
+ """
179
+ base = resources.memory_base_gb
180
+ per_worker = resources.memory_per_worker_gb
181
+ node = _common_node(nodes, resources.gpus)
182
+ n = _fit(node, base, per_worker)
183
+ if n is None:
184
+ node = _largest(nodes, resources.gpus)
185
+ n = _fit(node, base, per_worker)
186
+ if n is None:
187
+ raise PlanError(
188
+ f"one worker needs {per_worker} GiB besides {base} GiB base, more than "
189
+ f"the largest node {node.name} with {node.memory_gb} GiB"
190
+ )
191
+ if max_processes is not None:
192
+ n = min(n, max_processes)
193
+ retry_per_worker = per_worker * RETRY_FACTOR
194
+ retry_node = node
195
+ retry_n = _fit(retry_node, base, retry_per_worker)
196
+ if retry_n is None:
197
+ retry_node = _largest(nodes, resources.gpus)
198
+ retry_n = _fit(retry_node, base, retry_per_worker)
199
+ if retry_n is not None:
200
+ retry_n = min(retry_n, n)
201
+ retry_memory = math.ceil(base + retry_per_worker * retry_n)
202
+ else:
203
+ logger.warning(
204
+ "a retry with %s GiB per worker fits on no node, "
205
+ "it takes the whole memory of %s with one process",
206
+ retry_per_worker,
207
+ retry_node.name,
208
+ )
209
+ retry_memory = int(retry_node.memory_gb)
210
+ retry_per_worker = max(0.0, retry_memory - base)
211
+ return PodShape(
212
+ cores=n,
213
+ memory_gb=math.ceil(base + per_worker * n),
214
+ processes=n,
215
+ per_worker_gb=per_worker,
216
+ retry_memory_gb=retry_memory,
217
+ retry_per_worker_gb=retry_per_worker,
218
+ scratch_gb=resources.scratch_gb,
219
+ gpus=resources.gpus,
220
+ )
221
+
222
+
223
+ def _command_shape(settings: CommandSettings, nodes: list[Node]) -> PodShape:
224
+ memory = math.ceil(settings.memory_gb)
225
+ fitting = [
226
+ n
227
+ for n in _usable(nodes, settings.gpus)
228
+ if n.cores >= settings.cores and n.memory_gb >= memory
229
+ ]
230
+ if not fitting:
231
+ largest = _largest(nodes, settings.gpus)
232
+ raise PlanError(
233
+ f"the command needs {settings.cores} cores, {memory} GiB and "
234
+ f"{settings.gpus} GPUs, more than the largest node {largest.name} "
235
+ f"with {largest.cores} cores and {largest.memory_gb} GiB"
236
+ )
237
+ node_memory = max(n.memory_gb for n in fitting)
238
+ retry = max(
239
+ memory, min(math.ceil(settings.memory_gb * RETRY_FACTOR), int(node_memory))
240
+ )
241
+ return PodShape(
242
+ cores=settings.cores,
243
+ memory_gb=memory,
244
+ processes=1,
245
+ per_worker_gb=settings.memory_gb,
246
+ retry_memory_gb=retry,
247
+ # processes is 1, so the memory per worker of a retry is its memory
248
+ retry_per_worker_gb=float(retry),
249
+ scratch_gb=settings.scratch_gb,
250
+ gpus=settings.gpus,
251
+ )
252
+
253
+
254
+ def _slots(pod: PodShape, nodes: list[Node]) -> int:
255
+ total = 0
256
+ for n in _usable(nodes, pod.gpus):
257
+ fit = min(int(n.cores // pod.cores), int(n.memory_gb // pod.memory_gb))
258
+ if pod.gpus:
259
+ fit = min(fit, n.gpus // pod.gpus)
260
+ total += max(fit, 0)
261
+ return total
262
+
263
+
264
+ def plan_task(
265
+ task: TaskSpec,
266
+ task_type: TaskType,
267
+ resources: ResolvedResources,
268
+ nodes: list[Node],
269
+ mode: SplitMode,
270
+ priority: Priority,
271
+ units: int | None,
272
+ ) -> TaskPlan:
273
+ """Plans the pods of a task.
274
+
275
+ Args:
276
+ task: The task.
277
+ task_type: Its type.
278
+ resources: The resolved resources.
279
+ nodes: The nodes of the cluster.
280
+ mode: The split mode.
281
+ priority: The priority of the run.
282
+ units: The number of units, None for an unsplit task.
283
+
284
+ Returns:
285
+ The plan; extra pods are not limited by idle capacity, Kueue queues them.
286
+
287
+ Raises:
288
+ PlanError: The task fits on no node.
289
+ """
290
+ base = f"simrun-{priority.value}-base"
291
+ extra = f"simrun-{priority.value}-extra"
292
+ if not task_type.splittable:
293
+ if task_type.name == COMMAND.name:
294
+ pod = _command_shape(CommandSettings.model_validate(task.settings), nodes)
295
+ else:
296
+ # a single command is one process, unless it uses the cores of its pod
297
+ pod = shape(resources, nodes, None if task_type.uses_cores else 1)
298
+ return TaskPlan(task.id, 1, pod, base, extra, None)
299
+ pod = shape(resources, nodes)
300
+ pods = 1
301
+ if mode == SplitMode.FASTEST and units is not None:
302
+ pods = min(MAX_PIECES, units, _slots(pod, nodes))
303
+ return TaskPlan(task.id, max(1, pods), pod, base, extra, units)
simrun/plugins.py ADDED
@@ -0,0 +1,149 @@
1
+ """The plugin interface: task types, their resource hints and their discovery."""
2
+
3
+ from collections.abc import Callable
4
+ from dataclasses import dataclass
5
+ from importlib import import_module
6
+ from importlib.metadata import entry_points
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ from pydantic import BaseModel
11
+
12
+ ENTRY_POINT_GROUP = "simrun.task_types"
13
+
14
+
15
+ @dataclass(frozen=True)
16
+ class ResourceHint:
17
+ """Default resources of a task type.
18
+
19
+ Attributes:
20
+ memory_base_gb: Base memory of a task in GiB.
21
+ memory_per_worker_gb: Memory per worker in GiB.
22
+ scratch_gb: Local scratch in GiB.
23
+ gpus: Number of GPUs.
24
+ """
25
+
26
+ memory_base_gb: float = 2.0
27
+ memory_per_worker_gb: float = 6.0
28
+ scratch_gb: float = 0.0
29
+ gpus: int = 0
30
+
31
+
32
+ @dataclass(frozen=True)
33
+ class TaskPaths:
34
+ """The locations a task works in.
35
+
36
+ Attributes:
37
+ store: Root of the run store.
38
+ run_dir: Directory of the run.
39
+ task_dir: Directory of the task.
40
+ out: Output directory of the task, `task_dir / "out"`.
41
+ inputs: Input port to the directory of the output it reads.
42
+ scratch: Local scratch directory.
43
+ """
44
+
45
+ store: Path
46
+ run_dir: Path
47
+ task_dir: Path
48
+ out: Path
49
+ inputs: dict[str, Path]
50
+ scratch: Path
51
+
52
+
53
+ @dataclass(frozen=True)
54
+ class TaskType:
55
+ """A type of task, registered as an entry point of `ENTRY_POINT_GROUP`.
56
+
57
+ The callables are `"module:function"` strings, so a worker process resolves
58
+ them by import, see `resolve`. Signatures: `count_units(settings) -> int`,
59
+ `setup(settings, paths) -> None` (once per process),
60
+ `compute_unit(settings, index, paths) -> None`, `merge(settings, paths) -> None`
61
+ and `plan(settings) -> settings`, which the client calls before a submission
62
+ when the type is installed locally (`simrun.client.plan_tasks`).
63
+
64
+ A unit may be computed more than once: by a worker which is evicted before it
65
+ completes the unit, or by a restarted incarnation of a worker whose earlier one
66
+ still runs. So `compute_unit` writes outputs which depend on the settings and the
67
+ index only (deterministic), each written atomically (a temporary file in the same
68
+ directory, then renamed), so a reader never sees a partial unit.
69
+
70
+ Attributes:
71
+ name: Name of the type, as used in a workflow.
72
+ settings: Pydantic model of the settings.
73
+ inputs: Names of the input ports.
74
+ outputs: Names of the output ports.
75
+ hint: Default resources.
76
+ checkpoints: Whether the task resumes from checkpoints.
77
+ command: Argv template of unsplit tasks with `{settings}`, `{out}` and
78
+ `{input}`: a part with `{input}` is repeated once per input port of the
79
+ task, sorted by port, with `<port>=<path>` (`<scratch>/<port>` for a
80
+ staged port), and dropped without inputs.
81
+ It runs through `simrun-exec` (see `simrun.worker.exec_main`).
82
+ uses_cores: Whether the command of an unsplit task uses the cores of its pod
83
+ (a pool of processes); else the pod is sized for one process.
84
+ count_units: Reference to the function counting the units of work.
85
+ setup: Reference to the function preparing a process.
86
+ compute_unit: Reference to the function computing one unit.
87
+ merge: Reference to the function merging the units.
88
+ plan: Reference to the function which completes the settings of a task
89
+ before it is submitted, e.g. with values derived from its model.
90
+ image: Image of the task, None for the environment image of the run.
91
+ cost_setting: Name of the setting which changes the cost of a task, e.g. the
92
+ subset of the data of a fit; the resources are learned per value of it
93
+ (`simrun.resources.resource_key`).
94
+ description: What a task of the type does in a few words, the subtitle of
95
+ the type in the palette of the Editor.
96
+ """
97
+
98
+ name: str
99
+ settings: type[BaseModel]
100
+ inputs: tuple[str, ...] = ()
101
+ outputs: tuple[str, ...] = ("out",)
102
+ hint: ResourceHint = ResourceHint()
103
+ checkpoints: bool = False
104
+ command: str | None = None
105
+ uses_cores: bool = False
106
+ count_units: str | None = None
107
+ setup: str | None = None
108
+ compute_unit: str | None = None
109
+ merge: str | None = None
110
+ plan: str | None = None
111
+ image: str | None = None
112
+ cost_setting: str | None = None
113
+ description: str = ""
114
+
115
+ @property
116
+ def splittable(self) -> bool:
117
+ """Whether the task can be split into pieces."""
118
+ return None not in (self.count_units, self.compute_unit, self.merge)
119
+
120
+
121
+ def resolve(ref: str) -> Callable[..., Any]:
122
+ """Import the function a `"module:function"` string names.
123
+
124
+ Args:
125
+ ref: Reference of the form `module:function`.
126
+
127
+ Returns:
128
+ The function.
129
+ """
130
+ module, _, name = ref.partition(":")
131
+ return getattr(import_module(module), name)
132
+
133
+
134
+ def load_task_types() -> dict[str, TaskType]:
135
+ """Load the task types of all entry points.
136
+
137
+ Returns:
138
+ The task types by name.
139
+
140
+ Raises:
141
+ ValueError: A name is registered twice.
142
+ """
143
+ types: dict[str, TaskType] = {}
144
+ for entry in entry_points(group=ENTRY_POINT_GROUP):
145
+ task_type = entry.load()
146
+ if task_type.name in types:
147
+ raise ValueError(f"duplicate task type {task_type.name!r}")
148
+ types[task_type.name] = task_type
149
+ return types
@@ -0,0 +1,33 @@
1
+ """The built-in task types."""
2
+
3
+ from pydantic import BaseModel
4
+
5
+ from simrun.plugins import TaskType
6
+
7
+
8
+ class CommandSettings(BaseModel):
9
+ """Settings of a plain command task.
10
+
11
+ Attributes:
12
+ image: Image the command runs in.
13
+ args: Argument vector of the command.
14
+ cores: Number of cores.
15
+ memory_gb: Memory in GiB.
16
+ gpus: Number of GPUs.
17
+ scratch_gb: Local scratch in GiB.
18
+ """
19
+
20
+ image: str
21
+ args: list[str]
22
+ cores: int
23
+ memory_gb: float
24
+ gpus: int = 0
25
+ scratch_gb: float = 0
26
+
27
+
28
+ # `command` is None: the settings carry the image and the args.
29
+ COMMAND = TaskType(
30
+ name="command",
31
+ settings=CommandSettings,
32
+ description="A command in the image of the run",
33
+ )