Skip to content

Commit 2199191

Browse files
authored
Merge branch 'main' into fix/job-delete-and-cli-commands
2 parents adc4bca + 1088e41 commit 2199191

6 files changed

Lines changed: 327 additions & 421 deletions

File tree

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

Lines changed: 77 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,10 @@ lab config set server https://your-server-url
3737
# Step 2: Login with an API key
3838
lab login --api-key YOUR_API_KEY --server https://your-server-url
3939

40-
# Step 3: Set the current experiment
40+
# Step 3: Set the current experiment.
41+
# Use `lab experiment list` to see existing experiments.
42+
# If yours doesn't exist yet, run `lab experiment create your_experiment_name` first.
43+
# `lab experiment set-default <id>` is a convenience equivalent to this command.
4144
lab config set current_experiment your_experiment_name
4245

4346
# Step 4: Verify connectivity
@@ -242,6 +245,54 @@ lab.finish(message="Hello world complete!")
242245
lab task add ./hello-world-task --no-interactive
243246
```
244247

248+
## Managing Experiments
249+
250+
Use `lab experiment` commands to list, create, delete, and set the default experiment. **Experiments are the container for tasks and jobs** — most `lab task` / `lab job` commands operate against the *current* experiment (the one stored in `~/.lab/config.json` as `current_experiment`).
251+
252+
```bash
253+
# List all experiments. The current default is marked with `*`.
254+
lab experiment list
255+
lab --format json experiment list
256+
257+
# Create a new experiment
258+
lab experiment create my-experiment
259+
260+
# Create and immediately set as the default
261+
lab experiment create my-experiment --set-default
262+
263+
# Delete an experiment (`--no-interactive` to skip confirmation)
264+
lab experiment delete my-experiment --no-interactive
265+
266+
# Switch which experiment is the default. This writes to ~/.lab/config.json.
267+
lab experiment set-default my-experiment
268+
```
269+
270+
### `lab experiment set-default` vs `lab config set current_experiment`
271+
272+
Both write the same key (`current_experiment`) to `~/.lab/config.json`. Differences:
273+
274+
- `lab experiment set-default <id>` validates that the experiment exists on the server before writing. Prefer this when scripting — it fails fast on a typo.
275+
- `lab config set current_experiment <id>` is a raw config write and does not validate. Useful when bootstrapping a config (e.g. before the server is reachable) or when you've already confirmed the experiment exists.
276+
277+
### Finding an experiment ID by name
278+
279+
`lab experiment list` (and the JSON form) is the only sanctioned way to discover experiment IDs. **Do not fall back to `curl /experiment/`** — even when you only have a name and need the ID, this CLI surface covers it:
280+
281+
```bash
282+
# Get just the ID for a given name
283+
lab --format json experiment list | jq -r '.experiments[] | select(.name=="my-experiment") | .id'
284+
```
285+
286+
`lab --format json experiment list` returns:
287+
```json
288+
{
289+
"current_experiment": "my-experiment",
290+
"experiments": [
291+
{"id": "my-experiment", "name": "my-experiment", "config": {}}
292+
]
293+
}
294+
```
295+
245296
## Managing Models
246297

247298
Use `lab model` commands to list, inspect, create, edit, and delete model groups on the server. Models are organized as **groups** — each group can contain multiple versions (e.g. v1, v2, …).
@@ -493,6 +544,25 @@ lab task queue abc123 -m "train model"
493544

494545
Don't restate the task name, full hyperparameter dict, or file paths — those are already on the job record. Don't copy the user's last message verbatim — synthesize. If the conversation is truly empty of signal, fall back to `"Rerun of <id>, no changes"`.
495546

547+
### Overriding task parameters per queue: `--param key=value`
548+
549+
`lab task queue` accepts repeatable `--param key=value` (alias `-p`) to override values from the task's `parameters:` block for a single job, without mutating `task.yaml`. Values are parsed as YAML scalars: `score=0.42` is a float, `enabled=true` is a bool, `tag=baseline` is a string. Unknown keys (not declared in the task's `parameters:`) fail hard so typos are caught at queue time.
550+
551+
```bash
552+
# Sweep the same task with different hyperparameters
553+
for i in $(seq 1 10); do
554+
lab task queue TASK_ID --no-interactive \
555+
--param description="iteration $i" \
556+
--param score=$(python -c "print(0.4 + 0.04*$i)") \
557+
-m "Iteration $i"
558+
done
559+
560+
# Quoting tip: values may contain '=' (split on first '=' only)
561+
lab task queue TASK_ID --no-interactive --param notes="key=value pairs OK"
562+
```
563+
564+
Use this instead of `lab task edit --from-file` between queue calls — editing the task affects already-queued-but-not-yet-dispatched jobs and is racy.
565+
496566
### Selecting a provider when queuing a task
497567

498568
`lab task queue` has no `--provider` flag. With `--no-interactive` it picks the default (usually Local). To pick a specific provider, drive the interactive prompts via stdin. The flow is:
@@ -571,14 +641,18 @@ This applies to launching jobs, fetching logs, checking cluster status, and ever
571641
| `lab logout` | Remove stored API key | No |
572642
| `lab whoami` | Show current user and team | No |
573643
| `lab version` | Show CLI version | No |
644+
| `lab experiment list` | List all experiments (current default marked with `*`) | No |
645+
| `lab experiment create <name>` | Create a new experiment (`--set-default` to also switch to it) | No |
646+
| `lab experiment delete <id>` | Delete an experiment (`--no-interactive` to skip prompt) | No |
647+
| `lab experiment set-default <id>` | Set the default experiment (validates server-side, then writes `current_experiment` to `~/.lab/config.json`) | No |
574648
| `lab task list` | List tasks in current experiment | Yes |
575649
| `lab task info <id>` | Get task details | Yes |
576650
| `lab task init` | Scaffold `task.yaml` + `main.py` in the current directory (`--interactive` to prompt) | No |
577651
| `lab task add [dir]` | Add task from directory or `--from-git` URL (`--no-interactive`, `--dry-run`) | Yes |
578652
| `lab task edit <id>` | Edit an existing task's `task.yaml` (`--from-file`, `--no-interactive`, `--timeout`) | Yes |
579653
| `lab task upload <id> <path>` | Upload files/directories into an existing task (`--no-interactive`) | Yes |
580654
| `lab task delete <id>` | Delete a task (`--no-interactive` to skip confirmation) | Yes |
581-
| `lab task queue <id>` | Queue task on compute provider (`-m/--description` for a markdown run note; required for agents, see "Always write a run description") | Yes |
655+
| `lab task queue <id>` | Queue task on compute provider (`-m/--description` for a markdown run note; `-p/--param key=value` to override task parameters per run; required for agents, see "Always write a run description") | Yes |
582656
| `lab task gallery` | Browse/import from task gallery | Yes |
583657
| `lab job list` | List jobs (`--running` for active only) | Yes |
584658
| `lab job info <id>` | Get detailed job information | Yes |
@@ -641,7 +715,7 @@ With non-zero exit code.
641715
- Commands exit with non-zero status on failure
642716
- With `--format json`, errors return `{"error": "<message>"}`
643717
- "config not set" errors → run `lab login` first
644-
- "current_experiment not set" → run `lab config set current_experiment <id>`
718+
- "current_experiment not set" → run `lab experiment list` to find an existing experiment, then `lab experiment set-default <id>` (or `lab experiment create <name> --set-default` if none exists)
645719
- Connection refused → check server URL with `lab config`, verify server is running
646720
- "No compute providers available" → add a provider in team settings first, or check `provider list`
647721

AGENTS.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,16 @@ curl -H "Authorization: Bearer $TOKEN" -H "X-Team-Id: <team-id>" http://localhos
158158
- **X-Team-Id**: Required on all protected endpoints. Get it from `GET /users/me/teams`.
159159
- **Unprotected endpoints**: `auth`, `api_keys`, `quota`, `compute_provider`, and the OpenAI-compatible API.
160160

161+
## package.json overrides
162+
163+
The `overrides` block in `package.json` forces secure transitive versions to clear known vulnerabilities. Revisit when:
164+
165+
- Bumping `eslint-config-erb` / `@typescript-eslint/*` (parent of `minimatch`)
166+
- Bumping `webpack` / `terser-webpack-plugin` (parent of `serialize-javascript`)
167+
- Bumping `webpack` / `css-loader` / `postcss-loader` (parents of `postcss`)
168+
169+
When upgrading those, try removing the override first and re-run a dependency vulnerability scan. If the parent now pulls a safe version on its own, drop the override entry and update this file if necessary.
170+
161171
## Agentic Performance Optimization
162172

163173
- **Context**: When making changes, look at similar existing files (e.g., "Implement the new `ModelService` following the pattern in `api/transformerlab/services/job_service.py`").

cli/src/transformerlab_cli/commands/task.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -956,11 +956,34 @@ def _prompt_parameters(parameters: dict) -> dict:
956956
return values
957957

958958

959+
def _parse_param_overrides(raw_params: list[str] | None) -> dict:
960+
"""Parse `key=value` strings into a dict, with values as YAML scalars.
961+
962+
Splits on the first `=` only so values may contain `=`. Raises
963+
typer.BadParameter on malformed input.
964+
"""
965+
if not raw_params:
966+
return {}
967+
parsed: dict = {}
968+
for raw in raw_params:
969+
if "=" not in raw:
970+
raise typer.BadParameter(f"Expected key=value, got: {raw!r}")
971+
key, _, value = raw.partition("=")
972+
if not key:
973+
raise typer.BadParameter(f"Empty key in: {raw!r}")
974+
try:
975+
parsed[key] = yaml.safe_load(value)
976+
except yaml.YAMLError as e:
977+
raise typer.BadParameter(f"Failed to parse value for {key!r}: {e}") from e
978+
return parsed
979+
980+
959981
def queue_task(
960982
task_id: str,
961983
experiment_id: str,
962984
interactive: bool = True,
963985
description: str | None = None,
986+
param_overrides: dict | None = None,
964987
) -> None:
965988
"""Queue a task on a compute provider."""
966989
with console.status("[bold success]Fetching task...[/bold success]", spinner="dots"):
@@ -996,10 +1019,21 @@ def queue_task(
9961019
console.print(f"[dim]Using provider: {provider.get('name')}[/dim]")
9971020

9981021
parameters = task.get("parameters", {})
1022+
overrides = param_overrides or {}
1023+
if overrides:
1024+
if not parameters:
1025+
raise typer.BadParameter(
1026+
"Task has no parameters declared, cannot use --param. Add a `parameters:` block to task.yaml first."
1027+
)
1028+
unknown = sorted(set(overrides) - set(parameters))
1029+
if unknown:
1030+
valid = ", ".join(sorted(parameters)) or "(none)"
1031+
raise typer.BadParameter(f"Unknown parameter(s): {', '.join(unknown)}. Valid keys: {valid}")
9991032
if interactive and parameters:
10001033
param_values = _prompt_parameters(parameters)
10011034
else:
10021035
param_values = {k: (v.get("default", "") if isinstance(v, dict) else v) for k, v in parameters.items()}
1036+
param_values.update(overrides)
10031037

10041038
payload = build_launch_payload(
10051039
task, provider.get("name"), param_values, resource_overrides, description=description
@@ -1029,18 +1063,30 @@ def command_task_queue(
10291063
"Pass '-' to read from stdin."
10301064
),
10311065
),
1066+
params: list[str] = typer.Option(
1067+
None,
1068+
"--param",
1069+
"-p",
1070+
metavar="KEY=VALUE",
1071+
help=(
1072+
"Override a task parameter for this queue (repeatable). Value is parsed as a YAML "
1073+
"scalar (e.g. score=0.42 -> float, enabled=true -> bool). Unknown keys fail."
1074+
),
1075+
),
10321076
):
10331077
"""Queue a task on a compute provider."""
10341078
current_experiment = require_current_experiment()
10351079
if description == "-":
10361080
if sys.stdin.isatty():
10371081
raise typer.BadParameter('-m - reads the description from stdin; pipe content in or pass -m "...".')
10381082
description = sys.stdin.read()
1083+
param_overrides = _parse_param_overrides(params)
10391084
queue_task(
10401085
task_id,
10411086
experiment_id=current_experiment,
10421087
interactive=not no_interactive,
10431088
description=description,
1089+
param_overrides=param_overrides,
10441090
)
10451091

10461092

cli/tests/commands/test_task.py

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,152 @@ def test_task_queue_sends_description(_mock_exp, _mock_get, _mock_providers, moc
100100
assert body["description"] == "hypothesis: larger batch"
101101

102102

103+
SAMPLE_TASK_WITH_PARAMS = {
104+
"id": "t1",
105+
"name": "finetune",
106+
"experiment_id": "exp1",
107+
"run": "python main.py",
108+
"parameters": {
109+
"description": "default description",
110+
"score": 0.0,
111+
"enabled": False,
112+
"tag": "baseline",
113+
},
114+
"config": {},
115+
}
116+
117+
118+
@patch("transformerlab_cli.commands.task.api.post_json", return_value=_mock_resp({"job_id": "j1"}))
119+
@patch("transformerlab_cli.commands.task.fetch_providers", return_value=SAMPLE_PROVIDERS)
120+
@patch("transformerlab_cli.commands.task.api.get", return_value=_mock_resp(SAMPLE_TASK_WITH_PARAMS))
121+
@patch("transformerlab_cli.commands.task.require_current_experiment", return_value="exp1")
122+
def test_task_queue_param_override_lands_in_config(_mock_exp, _mock_get, _mock_providers, mock_post):
123+
"""`lab task queue --param k=v` sends the override in the launch payload's config field."""
124+
result = runner.invoke(
125+
app,
126+
["task", "queue", "t1", "--no-interactive", "--param", "description=iteration 7"],
127+
)
128+
assert result.exit_code == 0, result.output
129+
_path, body = mock_post.call_args.args
130+
assert body["config"]["description"] == "iteration 7"
131+
132+
133+
@patch("transformerlab_cli.commands.task.api.post_json", return_value=_mock_resp({"job_id": "j1"}))
134+
@patch("transformerlab_cli.commands.task.fetch_providers", return_value=SAMPLE_PROVIDERS)
135+
@patch("transformerlab_cli.commands.task.api.get", return_value=_mock_resp(SAMPLE_TASK_WITH_PARAMS))
136+
@patch("transformerlab_cli.commands.task.require_current_experiment", return_value="exp1")
137+
def test_task_queue_param_yaml_coercion(_mock_exp, _mock_get, _mock_providers, mock_post):
138+
"""--param values are parsed as YAML scalars: floats, bools, and bare strings."""
139+
result = runner.invoke(
140+
app,
141+
[
142+
"task",
143+
"queue",
144+
"t1",
145+
"--no-interactive",
146+
"--param",
147+
"score=0.42",
148+
"--param",
149+
"enabled=true",
150+
"--param",
151+
"tag=baseline",
152+
],
153+
)
154+
assert result.exit_code == 0, result.output
155+
_path, body = mock_post.call_args.args
156+
cfg = body["config"]
157+
assert cfg["score"] == 0.42
158+
assert isinstance(cfg["score"], float)
159+
assert cfg["enabled"] is True
160+
assert cfg["tag"] == "baseline"
161+
162+
163+
@patch("transformerlab_cli.commands.task.api.post_json", return_value=_mock_resp({"job_id": "j1"}))
164+
@patch("transformerlab_cli.commands.task.fetch_providers", return_value=SAMPLE_PROVIDERS)
165+
@patch("transformerlab_cli.commands.task.api.get", return_value=_mock_resp(SAMPLE_TASK_WITH_PARAMS))
166+
@patch("transformerlab_cli.commands.task.require_current_experiment", return_value="exp1")
167+
def test_task_queue_param_short_flag_overrides_default(_mock_exp, _mock_get, _mock_providers, mock_post):
168+
"""`-p k=v` (short alias) overrides the default value from task.yaml."""
169+
result = runner.invoke(
170+
app,
171+
["task", "queue", "t1", "--no-interactive", "-p", "score=9.9"],
172+
)
173+
assert result.exit_code == 0, result.output
174+
_path, body = mock_post.call_args.args
175+
assert body["config"]["score"] == 9.9
176+
# Other params still get their defaults
177+
assert body["config"]["tag"] == "baseline"
178+
179+
180+
@patch("transformerlab_cli.commands.task.api.post_json", return_value=_mock_resp({"job_id": "j1"}))
181+
@patch("transformerlab_cli.commands.task.fetch_providers", return_value=SAMPLE_PROVIDERS)
182+
@patch("transformerlab_cli.commands.task.api.get", return_value=_mock_resp(SAMPLE_TASK_WITH_PARAMS))
183+
@patch("transformerlab_cli.commands.task.require_current_experiment", return_value="exp1")
184+
def test_task_queue_param_value_with_equals_sign(_mock_exp, _mock_get, _mock_providers, mock_post):
185+
"""Value containing '=' is preserved (split on first '=' only)."""
186+
result = runner.invoke(
187+
app,
188+
["task", "queue", "t1", "--no-interactive", "--param", "description=key=value pairs"],
189+
)
190+
assert result.exit_code == 0, result.output
191+
_path, body = mock_post.call_args.args
192+
assert body["config"]["description"] == "key=value pairs"
193+
194+
195+
@patch("transformerlab_cli.commands.task.api.post_json", return_value=_mock_resp({"job_id": "j1"}))
196+
@patch("transformerlab_cli.commands.task.fetch_providers", return_value=SAMPLE_PROVIDERS)
197+
@patch("transformerlab_cli.commands.task.api.get", return_value=_mock_resp(SAMPLE_TASK_WITH_PARAMS))
198+
@patch("transformerlab_cli.commands.task.require_current_experiment", return_value="exp1")
199+
def test_task_queue_param_missing_equals_errors(_mock_exp, _mock_get, _mock_providers, mock_post):
200+
"""`--param foo` (no '=') fails with a clear error and does not call the API."""
201+
result = runner.invoke(app, ["task", "queue", "t1", "--no-interactive", "--param", "foo"])
202+
assert result.exit_code != 0
203+
assert "key=value" in strip_ansi(result.output).lower()
204+
mock_post.assert_not_called()
205+
206+
207+
@patch("transformerlab_cli.commands.task.api.post_json", return_value=_mock_resp({"job_id": "j1"}))
208+
@patch("transformerlab_cli.commands.task.fetch_providers", return_value=SAMPLE_PROVIDERS)
209+
@patch("transformerlab_cli.commands.task.api.get", return_value=_mock_resp(SAMPLE_TASK_WITH_PARAMS))
210+
@patch("transformerlab_cli.commands.task.require_current_experiment", return_value="exp1")
211+
def test_task_queue_param_empty_key_errors(_mock_exp, _mock_get, _mock_providers, mock_post):
212+
"""`--param =5` fails with a clear error."""
213+
result = runner.invoke(app, ["task", "queue", "t1", "--no-interactive", "--param", "=5"])
214+
assert result.exit_code != 0
215+
assert "empty key" in strip_ansi(result.output).lower()
216+
mock_post.assert_not_called()
217+
218+
219+
@patch("transformerlab_cli.commands.task.api.post_json", return_value=_mock_resp({"job_id": "j1"}))
220+
@patch("transformerlab_cli.commands.task.fetch_providers", return_value=SAMPLE_PROVIDERS)
221+
@patch("transformerlab_cli.commands.task.api.get", return_value=_mock_resp(SAMPLE_TASK_WITH_PARAMS))
222+
@patch("transformerlab_cli.commands.task.require_current_experiment", return_value="exp1")
223+
def test_task_queue_param_unknown_key_errors(_mock_exp, _mock_get, _mock_providers, mock_post):
224+
"""`--param notdeclared=1` fails hard when key isn't in the task's parameters block."""
225+
result = runner.invoke(
226+
app,
227+
["task", "queue", "t1", "--no-interactive", "--param", "notdeclared=1"],
228+
)
229+
assert result.exit_code != 0
230+
out = strip_ansi(result.output)
231+
assert "notdeclared" in out
232+
# Error should list the valid keys so the user can spot a typo
233+
assert "score" in out or "description" in out
234+
mock_post.assert_not_called()
235+
236+
237+
@patch("transformerlab_cli.commands.task.api.post_json", return_value=_mock_resp({"job_id": "j1"}))
238+
@patch("transformerlab_cli.commands.task.fetch_providers", return_value=SAMPLE_PROVIDERS)
239+
@patch("transformerlab_cli.commands.task.api.get", return_value=_mock_resp(SAMPLE_TASK))
240+
@patch("transformerlab_cli.commands.task.require_current_experiment", return_value="exp1")
241+
def test_task_queue_param_when_task_has_no_parameters_errors(_mock_exp, _mock_get, _mock_providers, mock_post):
242+
"""Passing `--param` when the task declares no parameters fails clearly."""
243+
result = runner.invoke(app, ["task", "queue", "t1", "--no-interactive", "--param", "x=1"])
244+
assert result.exit_code != 0
245+
assert "no parameters" in strip_ansi(result.output).lower()
246+
mock_post.assert_not_called()
247+
248+
103249
@patch(
104250
"transformerlab_cli.commands.task.api.post_json",
105251
side_effect=[

0 commit comments

Comments
 (0)