Skip to content

Commit 0f670c3

Browse files
authored
Merge branch 'main' into fix/cli-api-bugs
2 parents f33afa5 + e05dbc6 commit 0f670c3

18 files changed

Lines changed: 913 additions & 49 deletions

File tree

.agents/skills/transformerlab-cli/SKILL.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -594,9 +594,10 @@ Provider configs (`api_token`, `api_key`, `ssh_key_path`) contain secrets. If th
594594
8. **Never use `task interactive`** unless the user specifically requests an interactive session
595595
9. **`job task-logs --follow`** streams continuously and blocks until the job finishes — use when the user wants real-time monitoring
596596
10. **Never use the deprecated `lab job logs`** — see the "Job logs: three real commands" section below.
597-
11. **After queuing a task, ASK the user if they'd like you to watch the logs.** Don't start streaming or polling automatically — jobs can take minutes to hours, and `--follow` blocks. Report the Job ID and ask: "Want me to watch the logs and report back?"
598-
12. **Never create API keys programmatically** — if auth fails, ask the user to provide an API key from the web UI
599-
13. **Always pass `--description/-m` when queuing a task. Generate it yourself — never ask the user.** See "Always write a run description" below.
597+
11. **Before queuing a task, CONFIRM the experiment with the user.** Run `lab config` to read the current default and `lab --format json experiment list` to verify it exists, then ask: "I'm about to queue this under experiment `<name>` (your current default). OK, or pick another?" Show 2–3 alternatives from `experiment list` if the current one looks stale or missing. Skip the confirmation only when the user has already named the experiment in this turn.
598+
12. **After queuing a task, ASK the user if they'd like you to watch the logs.** Don't start streaming or polling automatically — jobs can take minutes to hours, and `--follow` blocks. Report the Job ID and ask: "Want me to watch the logs and report back?"
599+
13. **Never create API keys programmatically** — if auth fails, ask the user to provide an API key from the web UI
600+
14. **Always pass `--description/-m` when queuing a task. Generate it yourself — never ask the user.** See "Always write a run description" below.
600601

601602
### Always write a run description
602603

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -231,3 +231,4 @@ docs/superpowers/*
231231

232232
/tmp/
233233
/temp/
234+
.claude/scheduled_tasks.lock

api/api.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -370,6 +370,10 @@ async def healthz():
370370
"storage": {
371371
"provider": _effective_storage_provider(),
372372
},
373+
# Where the React app is served. In single-origin prod deploys this is
374+
# the same as the API origin; in dev it's typically http://localhost:1212.
375+
# Used by the CLI to open the /#/cli-auth page during browser login.
376+
"frontend_url": os.getenv("FRONTEND_URL") or None,
373377
}
374378

375379

api/test/api/test_experiment_jobs.py

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,101 @@ def test_jobs_list_all_endpoints(client):
1414
resp = client.get("/experiment/alpha/jobs/list?type=TRAIN&status=QUEUED")
1515
assert resp.status_code in (200, 404)
1616

17+
# slim mode is opt-in and should still return a 2xx (or 404) response shape
18+
resp = client.get("/experiment/alpha/jobs/list?slim=true")
19+
assert resp.status_code in (200, 404)
20+
21+
22+
def test_slim_job_drops_heavy_keys_only():
23+
"""_slim_job should remove the denylisted keys but preserve everything else."""
24+
from transformerlab.routers.experiment.jobs import _slim_job, _SLIM_JOB_DATA_DROPPED_KEYS
25+
26+
job = {
27+
"id": "abc",
28+
"status": "COMPLETE",
29+
"type": "TRAIN",
30+
"job_data": {
31+
# Heavy keys that the list view does not need
32+
"cluster_config": {"x": "y"},
33+
"env_vars": {"TOKEN": "secret"},
34+
"setup": "long shell script",
35+
"run": "another long shell script",
36+
"parameters": {"lr": 0.001, "batch": 32},
37+
"config": {"big": "blob"},
38+
"file_mounts": {"/foo": "/bar"},
39+
"accelerators": "A100",
40+
"cpus": 8,
41+
"memory": "32GB",
42+
"disk_space": "200GB",
43+
"num_nodes": 2,
44+
"command": "bash run.sh",
45+
"provider_jobs_seen_once": ["a", "b"],
46+
"provider_empty_jobs_polls": 5,
47+
# Keys the list view does need — these must survive
48+
"description": "hello",
49+
"start_time": "2025-01-01T00:00:00Z",
50+
"score": {"acc": 0.9},
51+
"subtype": "interactive",
52+
"favorite": True,
53+
"hidden": False,
54+
"tags": ["a"],
55+
"wandb_run_url": "https://wandb.ai/x/y/runs/z",
56+
"error_msg": None,
57+
"stop_requested": False,
58+
"eval_results": ["/path/to/results.csv"],
59+
"cached_tunnel_info": {"is_ready": True, "url": "https://x"},
60+
"provider_launch_result": {"request_id": "req-1", "instance_ids": ["i-1"]},
61+
},
62+
}
63+
64+
slim = _slim_job(job)
65+
66+
# Top-level keys are untouched
67+
assert slim["id"] == "abc"
68+
assert slim["status"] == "COMPLETE"
69+
assert slim["type"] == "TRAIN"
70+
71+
slim_data = slim["job_data"]
72+
# Every denylisted key must be gone
73+
for key in _SLIM_JOB_DATA_DROPPED_KEYS:
74+
assert key not in slim_data, f"slim mode leaked heavy key: {key}"
75+
76+
# Frontend-relevant keys must be preserved verbatim
77+
for key in (
78+
"description",
79+
"start_time",
80+
"score",
81+
"subtype",
82+
"favorite",
83+
"hidden",
84+
"tags",
85+
"wandb_run_url",
86+
"error_msg",
87+
"stop_requested",
88+
"eval_results",
89+
"cached_tunnel_info",
90+
"provider_launch_result",
91+
):
92+
assert key in slim_data, f"slim mode dropped expected key: {key}"
93+
assert slim_data[key] == job["job_data"][key]
94+
95+
# The original job dict is not mutated (defensive copy)
96+
assert "cluster_config" in job["job_data"]
97+
assert "env_vars" in job["job_data"]
98+
99+
100+
def test_slim_job_handles_non_dict_job_data():
101+
"""_slim_job should be a no-op when job_data is missing or not a dict."""
102+
from transformerlab.routers.experiment.jobs import _slim_job
103+
104+
# Missing job_data
105+
job = {"id": "1", "status": "RUNNING"}
106+
assert _slim_job(job) == job
107+
108+
# job_data is a string (legacy/mis-serialized) — leave it alone rather than crash
109+
job_str = {"id": "2", "job_data": '{"cluster_config": "x"}'}
110+
assert _slim_job(job_str) == job_str
111+
17112

18113
def test_job_creation_and_management(client):
19114
"""Test job creation and management endpoints"""

api/transformerlab/routers/experiment/jobs.py

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,15 +97,55 @@ def _job_logs_not_ready_payload(
9797
return payload
9898

9999

100+
# Heavy job_data keys that polling list-views never need. When ?slim=true is set
101+
# on /jobs/list, these are stripped from each job's job_data to keep the response
102+
# small. Per-job detail endpoints still return the full payload. New heavy keys
103+
# should be added here rather than allowlisting fields, so that adding metadata
104+
# elsewhere doesn't silently shrink list responses.
105+
_SLIM_JOB_DATA_DROPPED_KEYS: frozenset[str] = frozenset(
106+
{
107+
"accelerators",
108+
"cluster_config",
109+
"command",
110+
"config",
111+
"cpus",
112+
"disk_space",
113+
"env_vars",
114+
"file_mounts",
115+
"memory",
116+
"num_nodes",
117+
"parameters",
118+
"provider_empty_jobs_polls",
119+
"provider_jobs_seen_once",
120+
"run",
121+
"setup",
122+
}
123+
)
124+
125+
126+
def _slim_job(job: dict) -> dict:
127+
"""Return a shallow copy of ``job`` with heavy keys removed from job_data."""
128+
job_data = job.get("job_data")
129+
if not isinstance(job_data, dict):
130+
return job
131+
trimmed = {k: v for k, v in job_data.items() if k not in _SLIM_JOB_DATA_DROPPED_KEYS}
132+
return {**job, "job_data": trimmed}
133+
134+
100135
@router.get("/list")
101136
async def jobs_get_all(
102137
experimentId: str,
103138
type: str = "",
104139
status: str = "",
105140
subtype: str = "",
141+
slim: bool = False,
106142
):
107143
"""
108144
Return the list of jobs for an experiment, optionally filtered by type/status/subtype.
145+
146+
When ``slim=true``, heavy provisioning/config fields are stripped from each
147+
job's ``job_data`` to keep polling payloads small. The set of stripped keys
148+
is defined by ``_SLIM_JOB_DATA_DROPPED_KEYS``.
109149
"""
110150
jobs = await job_service.jobs_get_all(type=type, status=status, experiment_id=experimentId)
111151

@@ -121,7 +161,10 @@ async def jobs_get_all(
121161
job_data = {}
122162
if job_data.get("subtype") == subtype:
123163
filtered.append(job)
124-
return filtered
164+
jobs = filtered
165+
166+
if slim:
167+
jobs = [_slim_job(job) for job in jobs]
125168

126169
return jobs
127170

cli/src/transformerlab_cli/commands/login.py

Lines changed: 74 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,43 @@
1+
import os
2+
13
import typer
24

35
from transformerlab_cli.util.auth import set_api_key, fetch_user_info, fetch_user_teams
6+
from transformerlab_cli.util.browser_login import (
7+
BrowserLoginError,
8+
_resolve_frontend_url,
9+
run_browser_login,
10+
)
411
from transformerlab_cli.util.config import load_config, set_config
512
from transformerlab_cli.util.shared import DEFAULT_BASE_URL, set_base_url
613
from transformerlab_cli.util.ui import console
714

815

16+
def _is_ssh_session() -> bool:
17+
return bool(os.environ.get("SSH_CONNECTION") or os.environ.get("SSH_CLIENT") or os.environ.get("SSH_TTY"))
18+
19+
920
app = typer.Typer()
1021

1122

1223
@app.command()
1324
def login(
14-
api_key: str = typer.Option(None, "--api-key", help="Your API key"),
25+
api_key: str = typer.Option(None, "--api-key", help="API key (skips browser flow; for CI/headless)."),
1526
server: str = typer.Option(None, "--server", help="Server URL"),
27+
no_browser: bool = typer.Option(False, "--no-browser", help="Print the login URL instead of opening a browser."),
28+
paste: bool = typer.Option(
29+
False,
30+
"--paste",
31+
help="Skip the loopback server; create an API key in the web UI and paste it here. Auto-enabled over SSH.",
32+
),
1633
):
1734
"""Log in to Transformer Lab."""
18-
# Load config to set the base URL before attempting login
1935
config = load_config()
2036

21-
# Prompt for server URL, showing current value as default so user can confirm or change it
2237
if not server:
2338
current_server = config.get("server") or DEFAULT_BASE_URL
2439
server = typer.prompt("Server URL", default=current_server)
2540

26-
# Validate and set server URL
2741
from transformerlab_cli.util.config import _validate_url
2842

2943
normalized_url = _validate_url(server)
@@ -35,60 +49,89 @@ def login(
3549
server = normalized_url
3650
set_base_url(server)
3751

38-
# Save server to config if it changed
3952
if config.get("server") != server:
4053
set_config("server", server)
4154

42-
# Ask for API key if not provided
55+
console.print(f"\n[label]Current server:[/label] [value]{server}[/value]")
56+
57+
browser_team_id = None
58+
browser_team_name = None
59+
4360
if not api_key:
44-
console.print(
45-
f"\n[yellow]You can create an API key at:[/yellow] [bold]{server.rstrip('/')}/#/user/api-keys[/bold]"
46-
)
47-
api_key = typer.prompt("Please enter your API key", hide_input=True)
61+
use_paste = paste or _is_ssh_session()
62+
if use_paste:
63+
if paste:
64+
console.print("[label]Paste mode:[/label] open the API keys page on any device.")
65+
else:
66+
console.print(
67+
"[label]SSH session detected.[/label] The loopback flow won't work over SSH; "
68+
"switching to paste mode."
69+
)
70+
frontend_url = _resolve_frontend_url(server)
71+
api_keys_url = f"{frontend_url}/#/user/api-keys"
72+
console.print(f"\n[label]1.[/label] Open this URL in a browser: [bold]{api_keys_url}[/bold]")
73+
console.print("[label]2.[/label] Create a new API key (scoped to the team you want).")
74+
console.print("[label]3.[/label] Paste the key below.\n")
75+
api_key = typer.prompt("API key", hide_input=True).strip()
76+
if not api_key:
77+
console.print("[error]Error:[/error] No API key provided.")
78+
raise typer.Exit(1)
79+
else:
80+
# Default path: browser-based login via loopback server.
81+
try:
82+
result = run_browser_login(server_url=server, open_browser=not no_browser)
83+
except BrowserLoginError as e:
84+
console.print(f"[error]Error:[/error] {e}")
85+
console.print(
86+
"[warning]Tip:[/warning] use [bold]lab login --paste[/bold] (or [bold]--api-key <KEY>[/bold]) "
87+
"for SSH/headless/CI."
88+
)
89+
raise typer.Exit(1)
4890

49-
# Attempt login
50-
login_success = set_api_key(api_key)
91+
api_key = result["api_key"]
92+
browser_team_id = result.get("team_id")
93+
browser_team_name = result.get("team_name")
5194

52-
if not login_success:
53-
# Even if login fails, show how to change server
95+
# Validate the key (works for both paths).
96+
if not set_api_key(api_key):
5497
console.print("\n[warning]To change the server URL, run:[/warning]")
5598
console.print("[bold] lab config server <SERVER_URL>[/bold]\n")
56-
console.print("[dim]Example: lab config server http://localhost:8000[/dim]")
5799
raise typer.Exit(1)
58100

59-
# Fetch user info and teams after successful login
60101
with console.status("[bold info]Fetching user information...", spinner="dots"):
61102
user_info = fetch_user_info(api_key)
62103
teams_info = fetch_user_teams(api_key)
63104

64105
if user_info and teams_info:
65-
# Save user email
66106
user_email = user_info.get("email")
67107
if user_email:
68108
set_config("user_email", user_email)
69109
console.print(f"[success]✓[/success] User email: [label]{user_email}[/label]")
70110

71-
# Save team info
72111
teams = teams_info.get("teams", [])
73-
if teams:
74-
# Prefer the team the API key is scoped to, if provided by the server
75-
api_key_team_id = user_info.get("api_key_team_id")
76-
selected_team = None
112+
selected_team = None
113+
114+
# 1. Prefer team selected in the browser flow.
115+
if browser_team_id:
116+
selected_team = next((t for t in teams if t.get("id") == browser_team_id), None)
77117

118+
# 2. Fall back to api_key_team_id from the server (paste flow).
119+
if not selected_team:
120+
api_key_team_id = user_info.get("api_key_team_id")
78121
if api_key_team_id:
79122
selected_team = next((t for t in teams if t.get("id") == api_key_team_id), None)
80123

81-
if not selected_team:
82-
# Look for owner role first (case-insensitive)
83-
owner_team = next(
84-
(t for t in teams if str(t.get("role", "")).lower() == "owner"),
85-
None,
86-
)
87-
selected_team = owner_team if owner_team else teams[0]
124+
# 3. Fall back to owner team, then first team.
125+
if not selected_team and teams:
126+
owner_team = next(
127+
(t for t in teams if str(t.get("role", "")).lower() == "owner"),
128+
None,
129+
)
130+
selected_team = owner_team if owner_team else teams[0]
88131

132+
if selected_team:
89133
team_id = selected_team.get("id")
90-
team_name = selected_team.get("name")
91-
134+
team_name = selected_team.get("name") or browser_team_name
92135
if team_id:
93136
set_config("team_id", team_id)
94137
console.print(f"[success]✓[/success] Team ID: [label]{team_id}[/label]")

0 commit comments

Comments
 (0)