gpu4agents 0.5.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.
- gpu4agents/__init__.py +5 -0
- gpu4agents/__main__.py +4 -0
- gpu4agents/agent/__init__.py +1 -0
- gpu4agents/agent/install.py +173 -0
- gpu4agents/agent/jobs.py +193 -0
- gpu4agents/agent/mcp.py +646 -0
- gpu4agents/agent/skill/SKILL.md +68 -0
- gpu4agents/cli/__init__.py +0 -0
- gpu4agents/cli/_common.py +131 -0
- gpu4agents/cli/account.py +330 -0
- gpu4agents/cli/agent.py +74 -0
- gpu4agents/cli/inspect.py +207 -0
- gpu4agents/cli/lifecycle.py +127 -0
- gpu4agents/cli/main.py +163 -0
- gpu4agents/cli/providers.py +96 -0
- gpu4agents/cli/run.py +38 -0
- gpu4agents/cli/setup.py +164 -0
- gpu4agents/cli/status.py +224 -0
- gpu4agents/cli/timer.py +143 -0
- gpu4agents/cli/validate.py +57 -0
- gpu4agents/core/__init__.py +0 -0
- gpu4agents/core/beacon.py +133 -0
- gpu4agents/core/config.py +647 -0
- gpu4agents/core/credentials.py +95 -0
- gpu4agents/core/durations.py +47 -0
- gpu4agents/core/engine.py +1335 -0
- gpu4agents/core/errors.py +94 -0
- gpu4agents/core/events.py +117 -0
- gpu4agents/core/execution.py +169 -0
- gpu4agents/core/health.py +62 -0
- gpu4agents/core/hosts.py +58 -0
- gpu4agents/core/model_inspect.py +368 -0
- gpu4agents/core/models.py +313 -0
- gpu4agents/core/paths.py +42 -0
- gpu4agents/core/policy.py +264 -0
- gpu4agents/core/state.py +406 -0
- gpu4agents/core/sweep.py +478 -0
- gpu4agents/core/tool_parsers.py +63 -0
- gpu4agents/providers/__init__.py +4 -0
- gpu4agents/providers/base.py +137 -0
- gpu4agents/providers/registry.py +63 -0
- gpu4agents/providers/vast/__init__.py +3 -0
- gpu4agents/providers/vast/client.py +255 -0
- gpu4agents/providers/vast/config.py +56 -0
- gpu4agents/providers/vast/mapper.py +292 -0
- gpu4agents/providers/vast/provider.py +438 -0
- gpu4agents/runtime/__init__.py +6 -0
- gpu4agents/runtime/base.py +184 -0
- gpu4agents/runtime/vllm.py +295 -0
- gpu4agents/watchdog/__init__.py +3 -0
- gpu4agents/watchdog/agent_body.py +959 -0
- gpu4agents/watchdog/builder.py +248 -0
- gpu4agents-0.5.0.dist-info/METADATA +157 -0
- gpu4agents-0.5.0.dist-info/RECORD +59 -0
- gpu4agents-0.5.0.dist-info/WHEEL +4 -0
- gpu4agents-0.5.0.dist-info/entry_points.txt +2 -0
- gpu4agents-0.5.0.dist-info/licenses/LICENSE +21 -0
- jambu_gpu/__init__.py +9 -0
- jambu_gpu/__main__.py +4 -0
gpu4agents/cli/setup.py
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"""`gpu setup` / `init` / `offers` - provisioning entry points."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
from rich.table import Table
|
|
9
|
+
from rich.text import Text
|
|
10
|
+
|
|
11
|
+
from ..core.config import (
|
|
12
|
+
DEFAULT_CONFIG_TEMPLATE,
|
|
13
|
+
DEFAULT_CONFIG_TEMPLATE_FLAT,
|
|
14
|
+
CONFIG_FILENAMES,
|
|
15
|
+
)
|
|
16
|
+
from ..core.errors import JambuError
|
|
17
|
+
from ._common import console, fmt_money, get_context, handle, print_json, register_project
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def init_command(
|
|
21
|
+
ctx: typer.Context,
|
|
22
|
+
model: str = typer.Option("empero-ai/Qwythos-9B-v2", "--model", help="HuggingFace model id."),
|
|
23
|
+
force: bool = typer.Option(False, "--force", help="Overwrite an existing config file."),
|
|
24
|
+
flat: bool = typer.Option(
|
|
25
|
+
False,
|
|
26
|
+
"--flat",
|
|
27
|
+
help=(
|
|
28
|
+
"Write a legacy, gpu-runtime-only config.yml (no gpu_runtime: "
|
|
29
|
+
"wrapper) instead of the shared jambu.yaml."
|
|
30
|
+
),
|
|
31
|
+
),
|
|
32
|
+
) -> None:
|
|
33
|
+
"""Write a starter jambu.yaml (and .env.example) in the current directory."""
|
|
34
|
+
if flat:
|
|
35
|
+
target = Path.cwd() / "config.yml"
|
|
36
|
+
content = DEFAULT_CONFIG_TEMPLATE_FLAT.format(model_id=model)
|
|
37
|
+
else:
|
|
38
|
+
target = Path.cwd() / CONFIG_FILENAMES[0] # jambu.yaml
|
|
39
|
+
content = DEFAULT_CONFIG_TEMPLATE.format(model_id=model)
|
|
40
|
+
|
|
41
|
+
if target.exists() and not force:
|
|
42
|
+
console.print(Text(f"! {target} already exists (use --force)", style="yellow"))
|
|
43
|
+
raise typer.Exit(1)
|
|
44
|
+
target.write_text(content)
|
|
45
|
+
console.print(Text(f"+ wrote {target}", style="green"))
|
|
46
|
+
if not flat:
|
|
47
|
+
console.print(
|
|
48
|
+
Text(
|
|
49
|
+
" gpu-runtime config lives under the gpu_runtime: key - other "
|
|
50
|
+
"Jambu Lab tools can share this file",
|
|
51
|
+
style="dim",
|
|
52
|
+
)
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
env_example = Path.cwd() / ".env.example"
|
|
56
|
+
if not env_example.exists():
|
|
57
|
+
env_example.write_text(
|
|
58
|
+
"# Credentials never belong in jambu.yaml / config.yml.\n"
|
|
59
|
+
"VAST_API_KEY=\n"
|
|
60
|
+
"# Optional, for gated HuggingFace models:\n"
|
|
61
|
+
"HF_TOKEN=\n"
|
|
62
|
+
)
|
|
63
|
+
console.print(Text(f"+ wrote {env_example}", style="green"))
|
|
64
|
+
console.print("next: export VAST_API_KEY=... && gpu validate")
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def setup_command(
|
|
68
|
+
ctx: typer.Context,
|
|
69
|
+
recreate: bool = typer.Option(
|
|
70
|
+
False, "--recreate", help="Replace an existing instance that no longer matches jambu.yaml."
|
|
71
|
+
),
|
|
72
|
+
allow_cost_override: bool = typer.Option(
|
|
73
|
+
False, "--allow-cost-override", help="Provision even above budget.max_hourly_cost_usd."
|
|
74
|
+
),
|
|
75
|
+
no_wait: bool = typer.Option(
|
|
76
|
+
False, "--no-wait", help="Return as soon as the instance is created."
|
|
77
|
+
),
|
|
78
|
+
) -> None:
|
|
79
|
+
"""Provision the instance, install the watchdog and start the model server."""
|
|
80
|
+
context = get_context(ctx)
|
|
81
|
+
try:
|
|
82
|
+
engine = context.engine()
|
|
83
|
+
register_project(context)
|
|
84
|
+
state = engine.setup(
|
|
85
|
+
recreate=recreate, allow_cost_override=allow_cost_override, wait=not no_wait
|
|
86
|
+
)
|
|
87
|
+
except JambuError as exc:
|
|
88
|
+
handle(exc)
|
|
89
|
+
return
|
|
90
|
+
|
|
91
|
+
payload = engine.connection(state)
|
|
92
|
+
watchdog = state.watchdog.to_dict()
|
|
93
|
+
watchdog.pop("token", None)
|
|
94
|
+
payload["watchdog"] = watchdog
|
|
95
|
+
if context.json:
|
|
96
|
+
print_json(payload)
|
|
97
|
+
return
|
|
98
|
+
if state.endpoint:
|
|
99
|
+
console.print(
|
|
100
|
+
f"\n endpoint [bold]{state.endpoint}[/bold]\n"
|
|
101
|
+
f" openai [bold]{payload['base_url']}[/bold]\n"
|
|
102
|
+
f" model [bold]{payload['model']}[/bold]\n"
|
|
103
|
+
" api key [dim]gpu env[/dim]"
|
|
104
|
+
)
|
|
105
|
+
console.print("\nnext: gpu run python your_experiment.py")
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def offers_command(
|
|
109
|
+
ctx: typer.Context,
|
|
110
|
+
limit: int = typer.Option(10, "--limit", "-n", help="How many offers to show."),
|
|
111
|
+
) -> None:
|
|
112
|
+
"""Show matching capacity and its price before committing to it."""
|
|
113
|
+
context = get_context(ctx)
|
|
114
|
+
try:
|
|
115
|
+
engine = context.engine()
|
|
116
|
+
engine.provider.require_capability("pricing_query")
|
|
117
|
+
spec = engine.build_spec()
|
|
118
|
+
offers = engine.provider.find_offers(spec, limit=limit)
|
|
119
|
+
except JambuError as exc:
|
|
120
|
+
handle(exc)
|
|
121
|
+
return
|
|
122
|
+
|
|
123
|
+
rows = [
|
|
124
|
+
{
|
|
125
|
+
"id": o.id,
|
|
126
|
+
"gpu": o.gpu_name,
|
|
127
|
+
"count": o.gpu_count,
|
|
128
|
+
"vram_gb": o.gpu_vram_gb,
|
|
129
|
+
"disk_gb": o.disk_gb,
|
|
130
|
+
"hourly_cost_usd": o.hourly_cost_usd,
|
|
131
|
+
"region": o.region,
|
|
132
|
+
"cuda_max": o.cuda_max,
|
|
133
|
+
}
|
|
134
|
+
for o in offers
|
|
135
|
+
]
|
|
136
|
+
if context.json:
|
|
137
|
+
print_json({"offers": rows})
|
|
138
|
+
return
|
|
139
|
+
|
|
140
|
+
if not rows:
|
|
141
|
+
console.print(Text("no matching offers", style="yellow"))
|
|
142
|
+
return
|
|
143
|
+
|
|
144
|
+
ceiling = engine.config.budget.max_hourly_cost_usd
|
|
145
|
+
table = Table(title=f"{engine.provider.display_name} offers", title_justify="left")
|
|
146
|
+
for column in ("OFFER", "GPU", "N", "VRAM", "DISK", "$/H", "REGION"):
|
|
147
|
+
table.add_column(column)
|
|
148
|
+
for row in rows:
|
|
149
|
+
over = ceiling is not None and row["hourly_cost_usd"] > float(ceiling)
|
|
150
|
+
table.add_row(
|
|
151
|
+
row["id"],
|
|
152
|
+
row["gpu"],
|
|
153
|
+
str(row["count"]),
|
|
154
|
+
f"{row['vram_gb']:.0f} GB",
|
|
155
|
+
f"{row['disk_gb']:.0f} GB",
|
|
156
|
+
Text(
|
|
157
|
+
fmt_money(row["hourly_cost_usd"]) or "-",
|
|
158
|
+
style="red" if over else "green",
|
|
159
|
+
),
|
|
160
|
+
row["region"] or "-",
|
|
161
|
+
)
|
|
162
|
+
console.print(table)
|
|
163
|
+
if ceiling:
|
|
164
|
+
console.print(Text(f" budget ceiling: ${float(ceiling):.3f}/h", style="dim"))
|
gpu4agents/cli/status.py
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
"""`gpu status` / `logs` / `events` - reconciled observability."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
from rich.table import Table
|
|
9
|
+
from rich.text import Text
|
|
10
|
+
|
|
11
|
+
from ..core.durations import format_duration
|
|
12
|
+
from ..core.errors import JambuError
|
|
13
|
+
from ._common import (
|
|
14
|
+
console,
|
|
15
|
+
fmt_money,
|
|
16
|
+
fmt_time,
|
|
17
|
+
get_context,
|
|
18
|
+
handle,
|
|
19
|
+
kv_table,
|
|
20
|
+
print_json,
|
|
21
|
+
state_text,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def status_command(
|
|
26
|
+
ctx: typer.Context,
|
|
27
|
+
watch: float = typer.Option(
|
|
28
|
+
0, "--watch", "-w", help="Refresh every N seconds (0 = once)."
|
|
29
|
+
),
|
|
30
|
+
) -> None:
|
|
31
|
+
"""Reconcile local and provider state, then report the normalized result."""
|
|
32
|
+
context = get_context(ctx)
|
|
33
|
+
while True:
|
|
34
|
+
try:
|
|
35
|
+
payload = context.engine().status()
|
|
36
|
+
except JambuError as exc:
|
|
37
|
+
handle(exc)
|
|
38
|
+
return
|
|
39
|
+
|
|
40
|
+
if context.json:
|
|
41
|
+
print_json(payload)
|
|
42
|
+
return
|
|
43
|
+
|
|
44
|
+
_render(payload)
|
|
45
|
+
if not watch:
|
|
46
|
+
return
|
|
47
|
+
time.sleep(max(2.0, watch))
|
|
48
|
+
console.rule(style="dim")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _render(payload: dict) -> None:
|
|
52
|
+
decision = payload.get("decision") or {}
|
|
53
|
+
watchdog = payload.get("watchdog") or {}
|
|
54
|
+
rows = [
|
|
55
|
+
("state", state_text(payload.get("state", "unknown"))),
|
|
56
|
+
("provider", payload.get("provider")),
|
|
57
|
+
("instance", payload.get("instance_id") or "-"),
|
|
58
|
+
("model", payload.get("model")),
|
|
59
|
+
("endpoint", payload.get("endpoint") or "-"),
|
|
60
|
+
("created", fmt_time(payload.get("created_at"))),
|
|
61
|
+
(
|
|
62
|
+
"age",
|
|
63
|
+
format_duration(payload.get("age_seconds"))
|
|
64
|
+
if payload.get("age_seconds") is not None
|
|
65
|
+
else None,
|
|
66
|
+
),
|
|
67
|
+
("idle", format_duration(payload.get("idle_seconds"))),
|
|
68
|
+
("cost", _cost(payload)),
|
|
69
|
+
("locked", _lock(payload)),
|
|
70
|
+
(
|
|
71
|
+
"watchdog",
|
|
72
|
+
_watchdog(watchdog),
|
|
73
|
+
),
|
|
74
|
+
("guard", _guard(decision, payload)),
|
|
75
|
+
]
|
|
76
|
+
console.print(kv_table("Runtime", rows))
|
|
77
|
+
|
|
78
|
+
workloads = [w for w in payload.get("workloads", []) if w["state"] in ("running", "queued")]
|
|
79
|
+
recent = sorted(
|
|
80
|
+
payload.get("workloads", []), key=lambda w: w.get("started_at") or 0, reverse=True
|
|
81
|
+
)[:5]
|
|
82
|
+
if recent:
|
|
83
|
+
table = Table(title="Workloads", title_justify="left", box=None)
|
|
84
|
+
for column in ("ID", "STATE", "LABEL", "STARTED", "RC"):
|
|
85
|
+
table.add_column(column)
|
|
86
|
+
for record in recent:
|
|
87
|
+
table.add_row(
|
|
88
|
+
record["id"],
|
|
89
|
+
Text(
|
|
90
|
+
record["state"],
|
|
91
|
+
style="cyan" if record["state"] == "running" else "dim",
|
|
92
|
+
),
|
|
93
|
+
(record.get("label") or "")[:48],
|
|
94
|
+
fmt_time(record.get("started_at")) or "-",
|
|
95
|
+
"-" if record.get("returncode") is None else str(record["returncode"]),
|
|
96
|
+
)
|
|
97
|
+
console.print(table)
|
|
98
|
+
if workloads:
|
|
99
|
+
console.print(
|
|
100
|
+
Text(f" {len(workloads)} active workload(s) hold the instance open", style="cyan")
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _cost(payload: dict) -> str:
|
|
105
|
+
hourly = payload.get("hourly_cost_usd")
|
|
106
|
+
session = payload.get("session_cost_usd")
|
|
107
|
+
if hourly is None and session is None:
|
|
108
|
+
return "-"
|
|
109
|
+
parts = []
|
|
110
|
+
if hourly is not None:
|
|
111
|
+
parts.append(f"{fmt_money(hourly)}/h")
|
|
112
|
+
if session:
|
|
113
|
+
parts.append(f"{fmt_money(session)} this session")
|
|
114
|
+
return " ".join(parts) or "-"
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _lock(payload: dict) -> Text:
|
|
118
|
+
if not payload.get("locked"):
|
|
119
|
+
return Text("no", style="dim")
|
|
120
|
+
until = fmt_time(payload.get("locked_until"))
|
|
121
|
+
return Text(f"yes (until {until})" if until else "yes (indefinite)", style="yellow")
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _watchdog(watchdog: dict) -> Text:
|
|
125
|
+
if not watchdog.get("enabled"):
|
|
126
|
+
return Text("disabled - shutdown depends on this CLI staying alive", style="yellow")
|
|
127
|
+
if watchdog.get("reachable"):
|
|
128
|
+
return Text(f"live at {watchdog.get('url')}", style="green")
|
|
129
|
+
if watchdog.get("installed"):
|
|
130
|
+
return Text(
|
|
131
|
+
f"unreachable ({watchdog.get('last_error') or 'no answer'})", style="yellow"
|
|
132
|
+
)
|
|
133
|
+
return Text("not installed", style="yellow")
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _guard(decision: dict, payload: dict) -> Text:
|
|
137
|
+
action = decision.get("action", "none")
|
|
138
|
+
reason = decision.get("reason") or "-"
|
|
139
|
+
if action == "none":
|
|
140
|
+
policy = payload.get("policy") or {}
|
|
141
|
+
idle_timeout = policy.get("idle_timeout_s")
|
|
142
|
+
idle = payload.get("idle_seconds") or 0
|
|
143
|
+
if idle_timeout and reason == "within_policy":
|
|
144
|
+
remaining = max(0.0, float(idle_timeout) - float(idle))
|
|
145
|
+
return Text(
|
|
146
|
+
f"ok ({reason}); auto-stop in {format_duration(remaining)} if idle",
|
|
147
|
+
style="green",
|
|
148
|
+
)
|
|
149
|
+
if (decision.get("detail") or {}).get("idle_skipped"):
|
|
150
|
+
return Text(f"ok ({reason}); idle left to the unreachable watchdog", style="yellow")
|
|
151
|
+
return Text(f"ok ({reason})", style="green")
|
|
152
|
+
return Text(f"{action} pending: {reason}", style="bold yellow")
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def logs_command(
|
|
156
|
+
ctx: typer.Context,
|
|
157
|
+
lines: int = typer.Option(200, "--lines", "-n", help="How many lines to show."),
|
|
158
|
+
source: str = typer.Option(
|
|
159
|
+
"",
|
|
160
|
+
"--source",
|
|
161
|
+
"-s",
|
|
162
|
+
help="runtime | onstart | watchdog (read on the instance through the "
|
|
163
|
+
"watchdog) or provider (container log). Default: runtime when the "
|
|
164
|
+
"watchdog answers, else provider.",
|
|
165
|
+
),
|
|
166
|
+
) -> None:
|
|
167
|
+
"""Show the instance's logs: the model server's by default."""
|
|
168
|
+
context = get_context(ctx)
|
|
169
|
+
try:
|
|
170
|
+
used, output = context.engine().read_logs(limit=lines, source=source or None)
|
|
171
|
+
except JambuError as exc:
|
|
172
|
+
handle(exc)
|
|
173
|
+
return
|
|
174
|
+
if context.json:
|
|
175
|
+
print_json({"source": used, "lines": output})
|
|
176
|
+
return
|
|
177
|
+
for line in output:
|
|
178
|
+
console.print(line, markup=False, highlight=False)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def events_command(
|
|
182
|
+
ctx: typer.Context,
|
|
183
|
+
limit: int = typer.Option(40, "--limit", "-n", help="How many events to show."),
|
|
184
|
+
event: str = typer.Option("", "--event", help="Filter by event name."),
|
|
185
|
+
) -> None:
|
|
186
|
+
"""Show the structured lifecycle event log (spec section 24)."""
|
|
187
|
+
context = get_context(ctx)
|
|
188
|
+
try:
|
|
189
|
+
engine = context.engine()
|
|
190
|
+
except JambuError as exc:
|
|
191
|
+
handle(exc)
|
|
192
|
+
return
|
|
193
|
+
records = engine.events.tail(limit=limit, event=event or None)
|
|
194
|
+
if context.json:
|
|
195
|
+
print_json({"events": records})
|
|
196
|
+
return
|
|
197
|
+
if not records:
|
|
198
|
+
console.print(Text("no events recorded yet", style="dim"))
|
|
199
|
+
return
|
|
200
|
+
table = Table(title="Lifecycle events", title_justify="left", box=None)
|
|
201
|
+
for column in ("TS", "EVENT", "DETAIL"):
|
|
202
|
+
table.add_column(column)
|
|
203
|
+
for record in records:
|
|
204
|
+
detail = {
|
|
205
|
+
k: v
|
|
206
|
+
for k, v in record.items()
|
|
207
|
+
if k not in ("ts", "event", "pid", "provider", "source")
|
|
208
|
+
}
|
|
209
|
+
table.add_row(
|
|
210
|
+
str(record.get("ts", "")),
|
|
211
|
+
Text(str(record.get("event", "")), style=_event_style(record.get("event", ""))),
|
|
212
|
+
", ".join(f"{k}={v}" for k, v in detail.items())[:110],
|
|
213
|
+
)
|
|
214
|
+
console.print(table)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _event_style(event: str) -> str:
|
|
218
|
+
if "error" in event or "failed" in event:
|
|
219
|
+
return "red"
|
|
220
|
+
if "guard" in event or "stopped" in event or "destroyed" in event:
|
|
221
|
+
return "yellow"
|
|
222
|
+
if "ready" in event or "created" in event:
|
|
223
|
+
return "green"
|
|
224
|
+
return "white"
|
gpu4agents/cli/timer.py
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""systemd *user* units that run `gpu sweep --apply` on a timer.
|
|
2
|
+
|
|
3
|
+
One ``ExecStart`` covers every project: the sweep guards each project in
|
|
4
|
+
``$GPU_HOME/projects.json`` with its own config, so the unit does not depend
|
|
5
|
+
on its working directory. A user unit needs no root, but it does not inherit
|
|
6
|
+
the shell's environment: credentials must live in ``~/.gpu/credentials`` or
|
|
7
|
+
the project ``.env``. Writing the files is all this module does; enabling them
|
|
8
|
+
is left to the printed ``systemctl`` command.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import os
|
|
14
|
+
import shlex
|
|
15
|
+
import sys
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Optional
|
|
18
|
+
|
|
19
|
+
UNIT_NAME = "jambu-gpu-sweep"
|
|
20
|
+
# A stuck provider API must not wedge the oneshot (and with it every later run).
|
|
21
|
+
START_TIMEOUT = "10min"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def unit_dir() -> Path:
|
|
25
|
+
base = os.environ.get("XDG_CONFIG_HOME")
|
|
26
|
+
root = Path(base).expanduser() if base else Path.home() / ".config"
|
|
27
|
+
return root / "systemd" / "user"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def unit_paths() -> tuple[Path, Path]:
|
|
31
|
+
directory = unit_dir()
|
|
32
|
+
return directory / f"{UNIT_NAME}.service", directory / f"{UNIT_NAME}.timer"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _specifiers(value: str) -> str:
|
|
36
|
+
# systemd expands %h, %u, ... in unit values; a literal % must be doubled.
|
|
37
|
+
return value.replace("%", "%%")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _quote(arg: str) -> str:
|
|
41
|
+
"""One ExecStart= argument: % and $ escaped, quoted when it needs it."""
|
|
42
|
+
arg = _specifiers(arg).replace("$", "$$")
|
|
43
|
+
if not arg or any(c in arg for c in " \t\"'\\;"):
|
|
44
|
+
return '"' + arg.replace("\\", "\\\\").replace('"', '\\"') + '"'
|
|
45
|
+
return arg
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _environment(name: str, value: str) -> str:
|
|
49
|
+
text = _specifiers(f"{name}={value}").replace("\\", "\\\\").replace('"', '\\"')
|
|
50
|
+
return f'Environment="{text}"'
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def render_units(
|
|
54
|
+
*, every_s: float, python: str, jambu_home: Optional[str] = None
|
|
55
|
+
) -> tuple[str, str]:
|
|
56
|
+
lines = [
|
|
57
|
+
"# Generated by `gpu sweep --install-timer`. Remove with --uninstall-timer.",
|
|
58
|
+
"[Unit]",
|
|
59
|
+
"Description=gpu4agents: guard every registered project and clean up orphans",
|
|
60
|
+
"",
|
|
61
|
+
"[Service]",
|
|
62
|
+
"Type=oneshot",
|
|
63
|
+
"WorkingDirectory=~",
|
|
64
|
+
f"TimeoutStartSec={START_TIMEOUT}",
|
|
65
|
+
]
|
|
66
|
+
if jambu_home:
|
|
67
|
+
lines.append(_environment("GPU_HOME", jambu_home))
|
|
68
|
+
# No "-" prefix: a failing sweep must show up as a failed unit.
|
|
69
|
+
lines.append("ExecStart=" + " ".join(_quote(a) for a in [python, "-m", "gpu4agents", "sweep", "--apply"]))
|
|
70
|
+
lines.append("")
|
|
71
|
+
service = "\n".join(lines)
|
|
72
|
+
seconds = max(60, int(every_s))
|
|
73
|
+
timer = "\n".join(
|
|
74
|
+
[
|
|
75
|
+
"# Generated by `gpu sweep --install-timer`.",
|
|
76
|
+
"[Unit]",
|
|
77
|
+
f"Description=Run {UNIT_NAME}.service every {seconds}s",
|
|
78
|
+
"",
|
|
79
|
+
"[Timer]",
|
|
80
|
+
"OnActiveSec=60s",
|
|
81
|
+
f"OnUnitActiveSec={seconds}s",
|
|
82
|
+
"AccuracySec=30s",
|
|
83
|
+
f"Unit={UNIT_NAME}.service",
|
|
84
|
+
"",
|
|
85
|
+
"[Install]",
|
|
86
|
+
"WantedBy=timers.target",
|
|
87
|
+
"",
|
|
88
|
+
]
|
|
89
|
+
)
|
|
90
|
+
return service, timer
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def legacy_guard_configs(service: Path) -> list[Path]:
|
|
94
|
+
"""Config paths of the per-project `guard` lines older units carried."""
|
|
95
|
+
if not service.is_file():
|
|
96
|
+
return []
|
|
97
|
+
found = []
|
|
98
|
+
for line in service.read_text().splitlines():
|
|
99
|
+
if not line.startswith("ExecStart="):
|
|
100
|
+
continue
|
|
101
|
+
try:
|
|
102
|
+
args = shlex.split(line[len("ExecStart="):].lstrip("-"))
|
|
103
|
+
except ValueError:
|
|
104
|
+
continue
|
|
105
|
+
if args and args[-1] == "guard" and "--config" in args:
|
|
106
|
+
index = args.index("--config") + 1
|
|
107
|
+
if index < len(args):
|
|
108
|
+
found.append(Path(args[index].replace("%%", "%")))
|
|
109
|
+
return found
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def install_timer(every_s: float, python: Optional[str] = None) -> dict:
|
|
113
|
+
"""Write (or rewrite) the units."""
|
|
114
|
+
python = python or sys.executable
|
|
115
|
+
service_path, timer_path = unit_paths()
|
|
116
|
+
legacy = legacy_guard_configs(service_path)
|
|
117
|
+
service, timer = render_units(
|
|
118
|
+
every_s=every_s,
|
|
119
|
+
python=python,
|
|
120
|
+
jambu_home=os.environ.get("GPU_HOME") or os.environ.get("JAMBU_HOME") or None,
|
|
121
|
+
)
|
|
122
|
+
service_path.parent.mkdir(parents=True, exist_ok=True)
|
|
123
|
+
service_path.write_text(service)
|
|
124
|
+
timer_path.write_text(timer)
|
|
125
|
+
return {
|
|
126
|
+
"service": str(service_path),
|
|
127
|
+
"timer": str(timer_path),
|
|
128
|
+
"every_s": max(60, int(every_s)),
|
|
129
|
+
"legacy_guard_configs": [str(p) for p in legacy],
|
|
130
|
+
"enable": f"systemctl --user daemon-reload && systemctl --user enable --now {UNIT_NAME}.timer",
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def uninstall_timer() -> dict:
|
|
135
|
+
removed = []
|
|
136
|
+
for path in unit_paths():
|
|
137
|
+
if path.exists():
|
|
138
|
+
path.unlink()
|
|
139
|
+
removed.append(str(path))
|
|
140
|
+
return {
|
|
141
|
+
"removed": removed,
|
|
142
|
+
"disable": f"systemctl --user disable --now {UNIT_NAME}.timer; systemctl --user daemon-reload",
|
|
143
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""`gpu validate` - fail before provisioning (spec sections 5, 15)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
from rich.text import Text
|
|
7
|
+
|
|
8
|
+
from ..core.errors import JambuError
|
|
9
|
+
from ._common import console, get_context, handle, print_json
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def validate_command(
|
|
13
|
+
ctx: typer.Context,
|
|
14
|
+
offline: bool = typer.Option(
|
|
15
|
+
False, "--offline", help="Skip provider API checks (schema and policy only)."
|
|
16
|
+
),
|
|
17
|
+
) -> None:
|
|
18
|
+
"""Validate jambu.yaml, credentials, capabilities and available capacity."""
|
|
19
|
+
context = get_context(ctx)
|
|
20
|
+
try:
|
|
21
|
+
engine = context.engine()
|
|
22
|
+
result = engine.validate(check_provider=not offline)
|
|
23
|
+
except JambuError as exc:
|
|
24
|
+
if context.json:
|
|
25
|
+
print_json({"ok": False, "errors": [{"message": str(exc)}]})
|
|
26
|
+
raise typer.Exit(getattr(exc, "exit_code", 1)) from exc
|
|
27
|
+
handle(exc)
|
|
28
|
+
return
|
|
29
|
+
|
|
30
|
+
payload = {
|
|
31
|
+
"ok": result.ok,
|
|
32
|
+
"provider": engine.provider.name,
|
|
33
|
+
"model": engine.config.model.id,
|
|
34
|
+
"runtime": engine.config.runtime.engine,
|
|
35
|
+
"fingerprint": engine.config.fingerprint(),
|
|
36
|
+
"errors": [{"field": i.field, "message": i.message} for i in result.errors],
|
|
37
|
+
"warnings": [{"field": i.field, "message": i.message} for i in result.warnings],
|
|
38
|
+
}
|
|
39
|
+
if context.json:
|
|
40
|
+
print_json(payload)
|
|
41
|
+
raise typer.Exit(0 if result.ok else 2)
|
|
42
|
+
|
|
43
|
+
console.print(
|
|
44
|
+
f"config: [bold]{engine.config.source_path}[/bold]\n"
|
|
45
|
+
f"provider: [bold]{engine.provider.display_name}[/bold] "
|
|
46
|
+
f"runtime: [bold]{engine.runtime.describe()}[/bold]"
|
|
47
|
+
)
|
|
48
|
+
for issue in result.warnings:
|
|
49
|
+
console.print(Text(f"! {issue.field or 'config'}: {issue.message}", style="yellow"))
|
|
50
|
+
for issue in result.errors:
|
|
51
|
+
console.print(Text(f"x {issue.field or 'config'}: {issue.message}", style="bold red"))
|
|
52
|
+
|
|
53
|
+
if result.ok:
|
|
54
|
+
console.print(Text("+ configuration is valid", style="bold green"))
|
|
55
|
+
else:
|
|
56
|
+
console.print(Text(f"{len(result.errors)} error(s) must be fixed", style="bold red"))
|
|
57
|
+
raise typer.Exit(2)
|
|
File without changes
|