From d0d1b6256c74ba121e4b8ff43c5e15ea0bd97de6 Mon Sep 17 00:00:00 2001 From: luyd Date: Fri, 30 Oct 2026 13:21:24 +0800 Subject: [PATCH 1/2] feat: improve CLI and web onboarding --- .github/workflows/ci.yml | 4 + README.md | 8 +- README_zh.md | 8 +- docs/configuration-wizard.md | 87 ++++-- docs/web-console.md | 48 ++-- examples/options/embedded-local.answers.yaml | 26 +- examples/options/service-sandbox.answers.yaml | 6 +- examples/options/service-ssh.answers.yaml | 4 +- harborrl/cli.py | 6 +- harborrl/hello.py | 138 ++++++++++ harborrl/options/cli.py | 212 +++++++++++---- harborrl/options/fingerprints.py | 65 +++++ harborrl/options/onboarding.py | 147 +++++++++++ harborrl/options/presets.py | 48 +++- harborrl/trajectories/native.py | 11 +- harborrl/webui/jobs.py | 37 +++ harborrl/webui/server.py | 62 ++++- harborrl/webui/static/app.js | 208 ++++++++++++--- harborrl/webui/static/index.html | 20 +- harborrl/webui/static/style.css | 37 +++ scripts/audit_public_tree.sh | 2 +- tests/harborrl/test_onboarding.py | 248 ++++++++++++++++++ tests/harborrl/test_options.py | 11 +- tests/harborrl/test_webui.py | 79 ++++++ 24 files changed, 1358 insertions(+), 164 deletions(-) create mode 100644 harborrl/hello.py create mode 100644 harborrl/options/fingerprints.py create mode 100644 harborrl/options/onboarding.py create mode 100644 tests/harborrl/test_onboarding.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c8c90eead..5b22594a0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,6 +26,8 @@ jobs: run: python -m pytest - name: Example contract smoke run: bash examples/native/cpu_contract_smoke.sh + - name: New user Hello World + run: harborrl hello --out-dir /tmp/harborrl-hello - name: Training dry run run: python -m harborrl.cli train --config examples/native/train_qwen_native.yaml --dry-run - name: Public tree audit @@ -77,6 +79,8 @@ jobs: python -m venv /tmp/harborrl-wheel /tmp/harborrl-wheel/bin/pip install dist/*.whl /tmp/harborrl-wheel/bin/harborrl --help + cd /tmp + /tmp/harborrl-wheel/bin/harborrl hello --out-dir /tmp/harborrl-wheel-hello - uses: actions/upload-artifact@v4 with: name: harborrl-dist diff --git a/README.md b/README.md index 71ca79609..22efe363e 100644 --- a/README.md +++ b/README.md @@ -44,10 +44,14 @@ HarborRL supports flexible combinations of **Models × Algorithms × Tasks × Ha For the offline options catalog, interactive configuration, and answer-file setup, see the [configuration CLI guide](docs/configuration-wizard.md). -Start with `harborrl options` or `harborrl init --help`. +Start with `harborrl hello` for a CPU walkthrough requiring no model, GPU or task +JSON. It executes scripted attempts and a real verifier, then exports a GRPO batch +with fixture tokens. Continue with `harborrl options` or `harborrl init --help`. +The wizard detects one shared Local environment, calculates model fingerprints, +and supports incomplete drafts (`:save` / `--resume`). Run `harborrl web` to open the local browser console for configuration, validation, -dry-runs, training launch, and logs. See the [web console guide](docs/web-console.md) +drafts, filled Local/Sandbox examples, Hello World, dry-runs, training launch, and logs. See the [web console guide](docs/web-console.md) for remote-controller access and lifecycle details. If you want an AI agent to help deploy this repository and verify training, diff --git a/README_zh.md b/README_zh.md index 347b1f1e4..bce055ca6 100644 --- a/README_zh.md +++ b/README_zh.md @@ -46,8 +46,12 @@ HarborRL 支持 **Models × Algorithms × Tasks × Harnesses (MATH)** 的灵活 [docs/agents/environment-check.md](docs/agents/environment-check.md)。 离线选项目录、交互式配置和 answer-file 配置方式见[配置 CLI 指南](docs/configuration-wizard.md)。 -可以从 `harborrl options` 或 `harborrl init --help` 开始。 -运行 `harborrl web` 可打开本地浏览器控制台,完成配置、校验、dry-run、训练启动和日志查看; +首次使用可以先运行 `harborrl hello`:无需模型、GPU 或已有 task JSON, +即可执行脚本任务、真实 verifier、0/1 奖励收集和使用示例 token 的 GRPO batch 导出。 +接着使用 `harborrl options` 或 `harborrl init --help`。Local 模式会检测并共用 +一套源码目录与 Python;模型指纹自动计算,未填完的草稿支持 `:save` 保存和 `--resume` 恢复。 +运行 `harborrl web` 可打开浏览器控制台,查看或套用 Local/Sandbox 示例、 +随时保存草稿、一键运行 Hello World,并完成校验、dry-run、训练启动和日志查看; 远程访问与生命周期说明见 [Web 控制台指南](docs/web-console.md)。 推荐的实际操作流程是把下面这段提示词交给 agent,并把尖括号内容替换成你的机器信息: diff --git a/docs/configuration-wizard.md b/docs/configuration-wizard.md index 441a6138b..61a7e93f1 100644 --- a/docs/configuration-wizard.md +++ b/docs/configuration-wizard.md @@ -2,8 +2,28 @@ The configuration tools run with the core package (Python 3.10+ and PyYAML). They enumerate implemented capabilities, check configuration offline, and create -consistent training schema 2 / deployment schema 1 files. They do not install -software, download tasks or models, connect over SSH, or allocate Sandbox/GPU jobs. +consistent training schema 2 / deployment schema 1 files. Catalog, preview and +validation stay offline. Model preparation explicitly runs the selected Python +locally or over SSH to fingerprint an existing tokenizer. It does not download +models or allocate Sandbox/GPU jobs. + +## First run without a task JSON or GPU + +Install the core package and try the bundled CPU walkthrough: + +```bash +python -m pip install -e . +harborrl hello --out-dir ./hello-first +harborrl web --workspace ./harborrl-web +``` + +Hello World executes two scripted attempts at creating a file, runs the actual +task verifier, collects rewards 0 and 1, assembles native IR and exports a GRPO +batch. It uses fixture token evidence, marked `CONTRACT_READY`; it does not perform +model inference or update GPU weights. All files stay under the new output +directory. The generated `catalog.json` and `claude-code-profile.json` can be reused +when configuring real training. The command also works from an installed wheel +outside a source checkout, on Windows or Linux. ## Browse and validate @@ -58,12 +78,13 @@ compatibility is additionally checked by the existing training dry-run. 2. Prepare a pinned Claude Code profile. See [the native examples](../examples/native) for profile structure. It must match the agent and task requirements of your deployment. -3. Stage checkpoint and reference directories on the GPU target. In its existing - training environment, load the actual local tokenizer with the same options as - the runtime and call `harborrl.rollout.runtime.tokenizer_fingerprint(tokenizer)`. - Its return values are `tokenizer_digest, template_digest`. This is an explicit - preparation step, not an operation performed by the wizard. Hashing a filename - or guessing an arbitrary 64-character value does not establish compatibility. +3. Stage checkpoint and reference directories on the GPU target. The wizard + calculates both tokenizer fingerprints automatically, using that target's + Python with Transformers installed. No manual SHA-256 entry is needed. In Web, + choose **Prepare model** after setting the checkpoint and environment. The + operation loads tokenizer files offline with `trust_remote_code=False` and calls + the same fingerprint function as the rollout runtime. A missing dependency or + checkpoint produces an actionable error; the draft can still be saved. 4. Identify controller/GPU/runner Python executables, checkout paths, SSH aliases and shared storage. Service origins must include their declared ports and be reachable by the participating machines. @@ -79,13 +100,36 @@ harborrl init --out-dir ./site-new harborrl init --answers my-answers.yaml --out-dir ./site-new ``` -Interactive mode asks for deployment roles, existing inputs, model fingerprints, -GPU layout and a few sampling settings, then previews both configurations and -requires `yes` to publish. Entering no or EOF cancels. Non-terminal use requires -`--answers`; missing required answers never fall back to prompting. +Interactive mode explains each required field. Local asks for one shared source +directory and Python interpreter, detected from the host running the wizard; +Controller, GPU and rollout all use those values. Storage is configured +separately. Service modes ask for each remote role and its reachable origins. +The wizard prepares model fingerprints automatically, previews both +configurations and requires `yes` to publish. + +Progress is saved after every answer to `OUT_DIR.draft.json`. Type `?` for help, +`:skip` to leave a field empty, or `:save` to save and exit. Ctrl+C or EOF after +choosing a deployment also keeps the draft. Resume it later: + +```bash +harborrl init --out-dir ./site-new --resume ./site-new.draft.json +harborrl init --out-dir ./empty-site --save-draft +harborrl init --out-dir ./local-example --example local +harborrl init --out-dir ./sandbox-example --example sandbox +``` + +`--example` saves a filled reference draft using the current host's detected +environment. Replace example paths and hosts before resuming. `--draft-file` +chooses a different progress file. Incomplete drafts never become runnable +bundles. Draft updates check revisions and reject stale writers. + +Non-terminal generation accepts `--answers` or `--resume`. Missing fingerprints +trigger explicit tokenizer preparation; `--prepare-model` recalculates existing +values. Missing required answers never fall back to prompting. Use `--save-draft` +to store incomplete answers without running preparation or final validation. -Start from one of these complete answer templates and replace every `REPLACE_*` -value and example path before running: +Start from one of these reference answer templates and replace example paths +before running: - [embedded-local](../examples/options/embedded-local.answers.yaml): one machine, local Harbor runner, colocated training/serving by default. @@ -94,13 +138,18 @@ value and example path before running: - [service-sandbox-local-runner](../examples/options/service-sandbox.answers.yaml): controller and runner here, SSH GPU target, Sandbox task execution. -Templates deliberately contain invalid digest placeholders. They cannot claim -training readiness without your real inputs. Default one-GPU colocate is a -configuration starting point, not a Qwen model memory-fit recommendation. +Templates leave fingerprints empty for automatic preparation. Web also offers +filled Local and Sandbox recipes with notes, download and apply actions. +GPU allocations illustrate topology; confirm memory requirements for your model +before training. For Local Docker, the gateway advertised origin must be +reachable inside task containers. On Linux, configure the host-gateway mapping +if using `host.docker.internal`, or choose a reachable host address. Answers allow only `answers_version: 1`, `preset`, `training`, `deployment`, and -optional `sandbox_config`. Overrides use the existing schema section/field names; -unknown keys fail. No expressions or shell commands are evaluated. Gateway fields, +optional `sandbox_config` and `local_environment`. Local's `local_environment` +contains `source_dir` and `python` and applies to all three roles, taking precedence +over advanced per-role overrides. Overrides use the existing schema field names; +unknown keys fail. Answer values are not evaluated as shell expressions. Gateway fields, shared run root, GPU budget and worker destinations are derived when only one side is provided. Contradictory explicit values fail. Supply matching origin URLs when changing ports. Paths in training overrides are relative to the **final diff --git a/docs/web-console.md b/docs/web-console.md index 3d9603ccc..885486493 100644 --- a/docs/web-console.md +++ b/docs/web-console.md @@ -6,32 +6,48 @@ harborrl web --workspace ./harborrl-web This opens a browser on an available loopback port. Keep the terminal running. `--port 8765` chooses a fixed port; `--no-browser` prints the URL without opening -it. The workspace stores saved configuration bundles and execution records. +it. The workspace stores drafts, saved configuration bundles and execution records. One console process owns a workspace at a time. The web console uses the core Python dependencies; Node, a frontend build and CDN access are not required. ## Configure and launch -1. Select Local, SSH workers, or Sandbox. Fill controller/GPU/runner paths and - service origins. Source and interpreter paths refer to the selected machine. - The controller source directory and Python executable select the checkout and - environment used by the launched CLI. -2. Provide existing checkpoint/reference directories, a pinned task catalog, - harness profile and real tokenizer/template fingerprints. The - [configuration CLI guide](configuration-wizard.md) describes preparing these - inputs. The console never downloads or generates model inputs. -3. Choose GPU layout, tensor parallelism, rollout batches and sampling settings. +1. New users can first select **Run Hello World**. It needs no task JSON, model + or GPU and opens the Runs view with actual execution logs. This CPU walkthrough + runs scripted attempts and a real verifier, then exports a GRPO batch using + clearly marked fixture tokens; it does not run model inference or training. +2. Open **Local example** or **Sandbox example** to view, download or apply a + filled best-practice recipe. Replace example model paths and service hostnames. + In Local, source directory and Python are detected on the machine running the + console and edited once for Controller, GPU and rollout. Storage is separate. + SSH workers and Sandbox expose the paths belonging to their remote roles. +3. Provide checkpoint/reference directories. **Use bundled Hello World task & + profile** creates valid input files locally, or supply your own pinned catalog + and profile. Select **Prepare model** to calculate both fingerprints using the + real tokenizer and selected GPU Python, over SSH when needed. No manual SHA-256 + entry is shown. Changing the checkpoint or target environment clears the old + fingerprints. See [the CLI guide](configuration-wizard.md) for prerequisites. +4. Choose GPU layout, tensor parallelism, rollout batches and sampling settings. Allocation defaults are configuration values, not a model memory-fit estimate. -4. Review the generated YAML and validation. **Check local files** additionally +5. Review the generated YAML and validation. Missing fields appear in a prominent + list with bold labels, help and links that focus the relevant input. Required + inputs also have inline explanations. **Check local files** additionally checks references on the controller. Deferred GPU, connectivity, model and - Sandbox checks remain explicit. Save the bundle under a new configuration name. -5. Run **Dry-run** to build the actual CLI launch plan. **Doctor** performs live + Sandbox checks remain explicit. **Create runnable configuration** publishes the + validated bundle. +6. Run **Dry-run** to build the actual CLI launch plan. **Doctor** performs live checks after confirmation. **Start training** requires a separate confirmation and invokes the existing `harborrl train` flow, including its preflight. Configuration names are limited to letters, digits, underscores and hyphens. -Saved configurations are not overwritten. Reopen one to launch it again, or edit -and save under a new name. The advanced answers editor and YAML/JSON import use +Use **Save draft** at any step, including an empty or incomplete configuration. +Drafts appear separately from Runnable configurations in the sidebar; reopen one +to continue and save another revision under the same name. They survive server +restarts. Stale revisions from another tab are rejected so work is not silently +overwritten. Drafts cannot start Dry-run, Doctor or training. + +Runnable configurations are immutable. Reopen one to launch it again, or edit it +and publish a new numbered version. The advanced answers editor and YAML/JSON import use the existing `answers_version: 1` contract. Download an individual generated file or export answers from the editor. Advanced remote Sandbox runners can be specified through imported answers; the form preset defaults to a local runner. @@ -48,7 +64,7 @@ the project's supported Linux training environment. ## Runs and shutdown -Only one execution is active per workspace, including doctor and dry-run. This +Only one execution is active per workspace, including Hello World, doctor and dry-run. This prevents duplicate launches within this console; it is not a cluster-wide GPU scheduler. Other CLI processes or different workspaces can still use the same resources, and operators must coordinate those allocations. diff --git a/examples/options/embedded-local.answers.yaml b/examples/options/embedded-local.answers.yaml index 7a4bc5db1..ad9f10c98 100644 --- a/examples/options/embedded-local.answers.yaml +++ b/examples/options/embedded-local.answers.yaml @@ -1,30 +1,32 @@ -# Replace every example path and REPLACE_* value. No model fit is implied. +# Reference recipe. Replace example paths; init calculates model fingerprints. answers_version: 1 preset: embedded-local +local_environment: + source_dir: /srv/harborrl/source + python: /opt/harborrl/bin/python training: tasks: catalog: /srv/harborrl/inputs/tasks.jsonl harness: profile: /srv/harborrl/inputs/claude-code.json - harbor: - python: /opt/harbor/bin/python model: checkpoint: /srv/models/qwen reference: /srv/models/qwen-reference args_file: qwen3-8B gateway: - tokenizer_digest: REPLACE_WITH_ACTUAL_TOKENIZER_SHA256 - template_digest: REPLACE_WITH_ACTUAL_TEMPLATE_SHA256 + host: 0.0.0.0 + advertised_url: http://host.docker.internal:49184 + tokenizer_digest: null + template_digest: null deployment: layout: colocate - num_gpus: 1 + num_gpus: 4 sampling: group_size: 2 groups_per_batch: 1 deployment: - manager: - source_dir: /srv/harborrl/source - controller_python: /opt/controller/bin/python - gpu_target: - source_dir: /srv/harborrl/source - train_python: /opt/training/bin/python + storage: + run_root: /srv/harborrl/runs +# Linux Docker must map host.docker.internal to host-gateway, or use a reachable host address. +# The interactive wizard detects the source and Python on its own host. +# Four GPUs is a layout example; confirm model memory requirements with Doctor. diff --git a/examples/options/service-sandbox.answers.yaml b/examples/options/service-sandbox.answers.yaml index a55de6d2f..8e9a1fd4d 100644 --- a/examples/options/service-sandbox.answers.yaml +++ b/examples/options/service-sandbox.answers.yaml @@ -14,11 +14,11 @@ training: reference: /srv/models/qwen-reference args_file: qwen3-8B gateway: - tokenizer_digest: REPLACE_WITH_ACTUAL_TOKENIZER_SHA256 - template_digest: REPLACE_WITH_ACTUAL_TEMPLATE_SHA256 + tokenizer_digest: null # Calculated automatically with the GPU Python over SSH. + template_digest: null deployment: layout: colocate - num_gpus: 1 + num_gpus: 4 deployment: manager: source_dir: /srv/shared/harborrl diff --git a/examples/options/service-ssh.answers.yaml b/examples/options/service-ssh.answers.yaml index 7a5851da4..ad9576258 100644 --- a/examples/options/service-ssh.answers.yaml +++ b/examples/options/service-ssh.answers.yaml @@ -11,8 +11,8 @@ training: reference: /srv/models/qwen-reference args_file: qwen3-8B gateway: - tokenizer_digest: REPLACE_WITH_ACTUAL_TOKENIZER_SHA256 - template_digest: REPLACE_WITH_ACTUAL_TEMPLATE_SHA256 + tokenizer_digest: null # Calculated automatically with the GPU Python over SSH. + template_digest: null deployment: layout: split num_gpus: 8 diff --git a/harborrl/cli.py b/harborrl/cli.py index 0ea67a8a0..59af49567 100644 --- a/harborrl/cli.py +++ b/harborrl/cli.py @@ -9,6 +9,10 @@ def main(argv=None): argv = list(sys.argv[1:] if argv is None else argv) + if argv and argv[0] == "hello": + from harborrl.hello import main as hello_main + + return hello_main(argv[1:]) if argv and argv[0] == "web": from harborrl.webui.cli import main as web_main @@ -32,7 +36,7 @@ def main(argv=None): return hub_main(argv[1:]) parser = argparse.ArgumentParser( prog="harborrl", description=__doc__, allow_abbrev=False, - epilog="Configuration tools: harborrl options --help; harborrl validate --help; harborrl init --help; harborrl web --help." + epilog="New here? harborrl hello. Configuration tools: harborrl options --help; harborrl validate --help; harborrl init --help; harborrl web --help." ) parser.add_argument("-model", help="Slime model preset to select") parser.add_argument("-harness", help="agent harness to select") diff --git a/harborrl/hello.py b/harborrl/hello.py new file mode 100644 index 000000000..51d45ef6f --- /dev/null +++ b/harborrl/hello.py @@ -0,0 +1,138 @@ +"""A portable CPU walkthrough with real task/verifier execution and fixture tokens.""" +import argparse +import json +import subprocess +import sys +from dataclasses import asdict +from datetime import datetime, timezone +from pathlib import Path + +from harborrl.data.harbor.native_inspector import inspect_native +from harborrl.export.native import export_training_batch +from harborrl.export.validate import batch_weight_sum +from harborrl.rollout.harbor_job.collector import collect +from harborrl.trajectories.native import Identity, RewardProfile, assemble, digest, publish + +PROFILE = {"agent_timeout_sec": 120, "cli_version": "2.1.141", "max_turns": 4, "model": "policy", + "setup_timeout_sec": 300, "verifier_timeout_sec": 60} +VERIFIER = r'''import json, sys +from pathlib import Path +target, output = map(Path, sys.argv[1:]) +reward = int(target.is_file() and target.read_text().strip() == 'ok') +output.mkdir(parents=True, exist_ok=True) +(output / 'reward.json').write_text(json.dumps({'reward': reward}) + '\n') +print(json.dumps({'reward': reward})) +''' + + +def create_inputs(root): + """Materialize a native task, its lock and a valid agent profile. No external dependencies.""" + root = Path(root).expanduser().resolve() + root.mkdir(parents=True, exist_ok=False) + task = root / "tasks" / "hello_world" + for part in ("tests", "environment"): + (task / part).mkdir(parents=True, exist_ok=True) + (task / "instruction.md").write_text("Create /hello containing exactly ok.\n", encoding="utf-8") + (task / "task.toml").write_text( + 'version = "1.0"\n[environment]\nos = "linux"\ncpus = 1\nmemory_mb = 4096\nstorage_mb = 4096\ngpus = 0\nallow_internet = true\n' + '[agent]\nname = "claude-code"\nmax_turns = 4\n[verifier]\ntype = "shared"\ncommand = "tests/test.sh"\n', encoding="utf-8") + (task / "tests" / "verify.py").write_text(VERIFIER, encoding="utf-8") + script = task / "tests" / "test.sh" + script.write_bytes(b'#!/bin/sh\nset -eu\npython3 /tests/verify.py /hello /logs/verifier\n') + script.chmod(0o755) + (task / "environment" / "Dockerfile").write_text('FROM python:3.12-slim\nWORKDIR /\n', encoding="utf-8") + report = inspect_native(task) + if report["status"] != "NEEDS_PROBE": + raise ValueError(f"Hello World task inspection failed: {report}") + publish(root / "catalog.json", [{"id": "hello_world", "revision": "hello-v1", + "path": str(task), "task_digest": report["task_digest"], + "reward_profile": asdict(RewardProfile())}]) + publish(root / "claude-code-profile.json", PROFILE) + return task, report + + +def _trajectory(root, task, slot, content, task_digest): + identity = Identity(run_id="hello-world", batch_id="cpu-walkthrough", group_id="hello-group", + slot_id=str(slot), attempt_id=f"attempt-{slot}", trajectory_id=f"trajectory-{slot}", + task_digest=task_digest, policy_version="hello-fixture-v1", harness_digest=digest(PROFILE), + reward_digest=RewardProfile().digest, sampling_digest=digest(SAMPLING)) + trial = root / identity.attempt_id + trial.mkdir() + target = trial / "hello" + # Execute the fixed lesson in a child process; all writes stay inside this run directory. + subprocess.run([sys.executable, "-c", "import sys; from pathlib import Path; Path(sys.argv[1]).write_text(sys.argv[2])", + str(target), content], check=True, timeout=15) + subprocess.run([sys.executable, str(task / "tests" / "verify.py"), str(target), str(trial / "verifier")], + check=True, timeout=15, capture_output=True, text=True) + rewards = json.loads((trial / "verifier" / "reward.json").read_text()) + trial_id = f"hello-trial-{slot}" + publish(trial / "result.json", {"id": trial_id, "finished_at": datetime.now(timezone.utc).isoformat(), + "verifier_environment_mode": "shared", "verifier_result": {"rewards": rewards}}) + evaluation = collect(identity, trial_id, trial / "result.json", trial / "verifier", RewardProfile(), + evidence_kind="hello_world_verifier") + publish(trial / "reward-receipt.json", evaluation) + turn = {"identity": identity.to_dict(), "harbor_trial_id": trial_id, + "request_id": f"request-{slot}", "response_id": f"response-{slot}", "engine_id": "hello-fixture", + "input_ids": [1, 2, 3], "output_ids": [4, 5], "logprobs": [-0.2, -0.3], + "logprob_semantics": "raw_model", "policy_version": identity.policy_version, + "tokenizer_digest": digest("hello-fixture-tokenizer"), "template_digest": digest("hello-fixture-template"), + "delivery": "consumed_confirmed", "consumption_ref": "hello-fixture#consumed", "role": "policy", + "sampling": SAMPLING, "client_request": {"model": "hello-fixture"}, + "serving_input": {"input_ids": [1, 2, 3]}, + "response": {"content": [{"type": "text", "text": content}]}, + "finish_reason": "end_turn", "evidence_kind": "hello_world_fixture"} + for key in ("client_request", "serving_input", "response"): + turn[key + "_digest"] = digest(turn[key]) + seal = {"identity": identity.to_dict(), "harbor_trial_id": trial_id, "pending_requests": 0, + "status": "sealed", "request_ids": [turn["request_id"]], "turns_digest": digest([turn]), "errors": []} + ir = assemble(identity, trial_id, [turn], seal, evaluation) + publish(trial / "native-ir.json", ir) + print(f" Attempt {slot + 1}: wrote {content!r}; verifier reward={rewards['reward']}; {ir['readiness']}", flush=True) + return ir + + +SAMPLING = {"temperature": 0.0, "top_p": 1.0, "top_k": -1, "max_new_tokens": 8, "stop": []} + + +def run(root): + root = Path(root).expanduser().resolve() + print("HarborRL Hello World | CPU walkthrough", flush=True) + print("Runs a scripted task and its real verifier, then exports a GRPO batch with fixture tokens.", flush=True) + print("No model inference, Sandbox allocation or GPU weight update is performed.", flush=True) + task, report = create_inputs(root) + print(f"[1/3] Created native task, locked catalog and agent profile: {root}", flush=True) + print("[2/3] Execute incorrect and correct attempts, collect verifier receipts", flush=True) + group = [_trajectory(root, task, index, content, report["task_digest"]) + for index, content in enumerate(("try again", "ok"))] + batch = export_training_batch([group], allow_synthetic=True) + data = asdict(batch) + data["policy_versions"] = sorted(data["policy_versions"]) + publish(root / "training-batch.json", data) + rewards = [unit.reward for unit in batch.groups[0].trajectories] + advantages = [unit.advantage for unit in batch.groups[0].trajectories] + if rewards != [0.0, 1.0] or not advantages[0] < 0 < advantages[1] or batch_weight_sum(batch) != 1.0: + raise ValueError("Hello World reward/export contract failed") + summary = {"status": "passed", "mode": "cpu-walkthrough", "evidence": "scripted-task-and-fixture-tokens", + "rewards": rewards, "advantages": advantages, "output_root": str(root), + "catalog": str(root / "catalog.json"), "profile": str(root / "claude-code-profile.json")} + publish(root / "hello-result.json", summary) + print("[3/3] PASS: exported training-batch.json; rewards=[0, 1]; group-normalized advantages verified", flush=True) + print(f"Artifacts: {root}\nNext: harborrl init --out-dir (use the catalog and profile above).", flush=True) + return summary + + +def main(argv=None): + parser = argparse.ArgumentParser(prog="harborrl hello", description=__doc__) + parser.add_argument("--out-dir", default=None, help="New artifact directory; defaults to ./harborrl-hello-") + args = parser.parse_args(argv) + root = args.out_dir or "harborrl-hello-" + datetime.now().strftime("%Y%m%d-%H%M%S-%f") + try: + run(root) + return 0 + except (OSError, ValueError, subprocess.SubprocessError) as exc: + print(f"error: {exc}", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/harborrl/options/cli.py b/harborrl/options/cli.py index 7438628dc..e5817d257 100644 --- a/harborrl/options/cli.py +++ b/harborrl/options/cli.py @@ -9,6 +9,7 @@ from .catalog import build_catalog from .presets import PRESETS, worker_defaults +from .onboarding import load_draft, reference_examples, save_draft from .validation import read_mapping, validate_paths from .wizard import build_config_bundle, publish_bundle @@ -20,6 +21,8 @@ def ask(label, default=None, convert=str): while True: value = input(f"{label}" + (f" [{default}]" if default is not None else "") + ": ").strip() + if value == ":save": + raise SaveDraft if not value and default is not None: return default try: @@ -40,56 +43,105 @@ def choose(label, choices): print("Choose a listed number", file=sys.stderr) -def interactive_answers(): - preset = choose("Deployment", [p["id"] for p in PRESETS]) - t, d = {}, {} - service = preset != "embedded-local" - print("Components: Qwen / GRPO / Harbor tasks / Claude Code. Advanced fields use an answers file.") - if service: - d["manager"] = { - "source_dir": ask("Controller source directory"), - "controller_python": ask("Controller Python", sys.executable), - "control_url": ask("Controller origin (include port 49200)"), - "gateway_url": ask("Gateway origin (include port 49184)"), - } - d["storage"] = {"run_root": ask("Shared run root (absolute)")} - d["gpu_target"] = {"ssh": {"host": ask("GPU SSH alias/destination")}, - "source_dir": ask("GPU source directory"), "train_python": ask("GPU training Python")} - else: - d["gpu_target"] = {"train_python": ask("Training Python", sys.executable)} - if preset == "service-ssh": - workers = [] - count = ask("Number of SSH runners", 1, int) - if count < 1: - raise ValueError("At least one SSH runner is required") - for index in range(count): - worker = worker_defaults() - worker.update(id=f"worker-{index}", ssh={"host": ask(f"Runner {index} SSH destination")}, - source_dir=ask("Runner source directory"), runner_python=ask("Runner Python"), - task_roots=[ask("Runner task root")]) - workers.append(worker) - d["rollout"] = {"workers": workers} +class SaveDraft(Exception): + pass + + +def interactive_answers(answers=None, *, output_directory=".", on_change=None): + from copy import deepcopy + + from harborrl.config import ROOT + + from .onboarding import describe_field + from .presets import defaults + + answers = deepcopy(answers) if answers else {"answers_version": 1, + "preset": choose("Deployment", [p["id"] for p in PRESETS]), "training": {}, "deployment": {}} + preset = answers["preset"] + training, deployment = defaults(preset, output_directory) + base = {"training": training, "deployment": deployment, + "local_environment": {"source_dir": str(ROOT), "python": sys.executable}} + print("Type ? for help, :skip to leave a field empty, :save to save and exit.") + print("Progress is saved after every answer. Ctrl+C keeps your draft. New here? Run harborrl hello first.") + if on_change: + on_change(answers) + + def get(mapping, path): + for key in path.split("."): + mapping = (mapping.get(key, {}) if isinstance(mapping, dict) else + mapping[int(key)] if isinstance(mapping, list) and key.isdigit() and int(key) < len(mapping) else {}) + return mapping if mapping != {} else None + + def set_value(path, value): + parent = answers + keys = path.split(".") + for key in keys[:-1]: + parent = parent[int(key)] if isinstance(parent, list) else parent.setdefault(key, {}) + parent[int(keys[-1]) if isinstance(parent, list) else keys[-1]] = value + if on_change: + on_change(answers) + + def prompt(path, fallback=None, convert=str, label=None): + info = describe_field(path) + value = get(answers, path) + if value is None: + value = fallback if fallback is not None else get(base, path) + print(" " + info["help"]) + while True: + text = input(f"{label or info['label']}" + (f" [{value}]" if value is not None else " [required]") + ": ").strip() + if text == "?": + print(info["help"]) + continue + if text == ":save": + raise SaveDraft + try: + selected = None if text == ":skip" else (value if not text else convert(text)) + if selected is None and text != ":skip": + raise ValueError("A value is required; use :skip to continue in a draft.") + except ValueError as exc: + print(exc, file=sys.stderr) + continue + set_value(path, selected) + return selected + + if preset == "embedded-local": + prompt("local_environment.source_dir") + prompt("local_environment.python") else: - t["harbor"] = {"python": ask("Local Harbor runner Python", sys.executable)} - t["tasks"] = {"catalog": ask("Existing task catalog JSON/JSONL")} - t["harness"] = {"profile": ask("Existing harness profile JSON")} - t["model"] = {"checkpoint": ask("GPU checkpoint directory"), "reference": ask("GPU reference directory"), - "args_file": ask("Qwen model args preset", "qwen3-8B")} - t["gateway"] = {"tokenizer_digest": ask("Actual tokenizer SHA-256"), - "template_digest": ask("Actual template SHA-256")} - if not service: - t["gateway"].update(host=ask("Gateway bind address", "127.0.0.1"), - advertised_url=ask("Gateway origin reachable by tasks", "http://127.0.0.1:49184")) - layout = choose("GPU layout", ["colocate", "split"]) - t["deployment"] = {"layout": layout, "num_gpus": ask("Total GPU budget", 1, int)} - if layout == "split": - t["deployment"].update(actor_gpus=ask("Actor GPUs", 1, int), rollout_gpus=ask("Serving GPUs", 1, int)) - t["training"] = {"num_rollout": ask("Rollout batches", 2, int)} - t["sampling"] = {"group_size": ask("Samples per group", 2, int), - "groups_per_batch": ask("Groups per batch", 1, int)} - answers = {"answers_version": 1, "preset": preset, "training": t, "deployment": d} - if preset == "service-sandbox-local-runner": - answers["sandbox_config"] = ask("Existing Sandbox configuration") + for path in ("deployment.manager.source_dir", "deployment.manager.controller_python", + "deployment.manager.control_url", "deployment.manager.gateway_url", + "deployment.gpu_target.ssh.host", "deployment.gpu_target.source_dir", + "deployment.gpu_target.train_python"): + prompt(path) + if preset == "service-ssh": + workers = get(answers, "deployment.rollout.workers") or [] + count = ask("SSH runner count (each needs Harbor, Docker and task files; :save to exit)", len(workers) or 1, int) + if count < 1: + raise ValueError("At least one SSH runner is required") + workers = workers[:count] + [{**worker_defaults(), "id": f"worker-{index}"} + for index in range(len(workers), count)] + set_value("deployment.rollout.workers", workers) + for index in range(count): + for key in ("id", "ssh.host", "source_dir", "runner_python", "task_roots.0"): + prompt(f"deployment.rollout.workers.{index}.{key}") + else: + prompt("training.harbor.python", sys.executable) + prompt("sandbox_config") + prompt("deployment.storage.run_root") + for path in ("training.tasks.catalog", "training.harness.profile", "training.model.checkpoint", + "training.model.reference", "training.model.args_file"): + prompt(path) + if preset == "embedded-local": + prompt("training.gateway.host") + prompt("training.gateway.advertised_url") + prompt("training.deployment.layout", label="GPU layout (colocate or split)") + prompt("training.deployment.num_gpus", convert=int, label="Total GPU budget (check model memory first)") + if get(answers, "training.deployment.layout") == "split": + prompt("training.deployment.actor_gpus", convert=int) + prompt("training.deployment.rollout_gpus", convert=int) + prompt("training.training.num_rollout", convert=int, label="Number of rollout batches") + prompt("training.sampling.group_size", convert=int, label="Samples per prompt (GRPO requires at least 2)") + prompt("training.sampling.groups_per_batch", convert=int, label="Complete prompt groups per batch") return answers @@ -132,6 +184,11 @@ def main(argv): init.add_argument("--out-dir", required=True) init.add_argument("--answers") init.add_argument("--probe-local", action="store_true") + init.add_argument("--resume", help="Resume a saved draft JSON") + init.add_argument("--draft-file", help="Progress file (default: .draft.json)") + init.add_argument("--save-draft", action="store_true", help="Save incomplete answers without publishing a runnable bundle") + init.add_argument("--example", choices=["local", "sandbox"], help="Save a filled reference draft; replace site paths before use") + init.add_argument("--prepare-model", action="store_true", help="Recalculate fingerprints using the target Python") args = parser.parse_args(argv) try: if args.command == "options": @@ -151,7 +208,7 @@ def main(argv): else: render_validation(result) return int(result["status"] == "invalid") - if not args.answers and not sys.stdin.isatty(): + if not (args.answers or args.resume or args.example or args.save_draft) and not sys.stdin.isatty(): parser.error("init requires --answers when stdin is not a terminal") output = Path(args.out_dir).expanduser().absolute() if output.exists() or output.is_symlink(): @@ -159,14 +216,61 @@ def main(argv): if args.probe_local: from .probe import probe_local print(json.dumps(probe_local(output), indent=2)) - answers = read_mapping(args.answers) if args.answers else interactive_answers() + if args.answers and args.resume: + raise ValueError("Choose --answers or --resume") + draft_path = Path(args.draft_file or args.resume or (str(output) + ".draft.json")).expanduser().absolute() + revision = load_draft(draft_path)["revision"] if args.resume and draft_path.exists() else None + if not args.resume and (draft_path.exists() or draft_path.is_symlink()): + raise ValueError(f"Draft already exists: {draft_path}. Use --resume or choose a new --draft-file.") + + def persist(current): + nonlocal revision + revision = save_draft(draft_path, current, revision=revision)["revision"] + + answers = load_draft(args.resume)["answers"] if args.resume else (read_mapping(args.answers) if args.answers else None) + if args.example: + answers = reference_examples(output)[0 if args.example == "local" else 1]["answers"] + if args.save_draft or args.example: + persist(answers or {"answers_version": 1, "preset": "embedded-local"}) + print(f"Draft saved: {draft_path}\nResume: harborrl init --out-dir {shlex.quote(str(output))} --resume {shlex.quote(str(draft_path))}") + return 0 + interactive = not args.answers and sys.stdin.isatty() + if interactive: + try: + answers = interactive_answers(answers, output_directory=output, on_change=persist) + except (SaveDraft, EOFError, KeyboardInterrupt): + if draft_path.is_file(): + print(f"Draft kept at {draft_path}; resume with --resume {shlex.quote(str(draft_path))}") + else: + print("Cancelled before a deployment was selected; no draft created") + return 0 + if answers is None: + raise ValueError("Supply --answers or --resume") + if interactive or args.prepare_model or not all(answers.get("training", {}).get("gateway", {}).get(key) + for key in ("tokenizer_digest", "template_digest")): + from .fingerprints import prepare_model + print("Preparing model fingerprints automatically with the GPU target tokenizer...") + try: + prepared = prepare_model(answers, output) + answers.setdefault("training", {}).setdefault("gateway", {}).update(prepared["fingerprints"]) + except ValueError: + if revision is None or interactive: + persist(answers) + print(f"Draft kept at {draft_path}. Fix the environment/checkpoint, then resume.", file=sys.stderr) + raise + if interactive: + persist(answers) bundle = build_config_bundle(answers, output_directory=output) render_validation(bundle["validation"]) - if not args.answers: + if interactive: print(bundle["files"]["config.yaml"]) print(bundle["files"]["deployment.yaml"]) - if ask("Publish these files? (yes/no)", "no").lower() != "yes": - print("Cancelled; no files written") + try: + publish = ask("Publish these files? (yes/no)", "no").lower() == "yes" + except (SaveDraft, EOFError, KeyboardInterrupt): + publish = False + if not publish: + print(f"Draft saved: {draft_path}; no runnable bundle published") return 0 publish_bundle(bundle, output) print(f"Created configuration bundle: {output}") diff --git a/harborrl/options/fingerprints.py b/harborrl/options/fingerprints.py new file mode 100644 index 000000000..0f9b660f3 --- /dev/null +++ b/harborrl/options/fingerprints.py @@ -0,0 +1,65 @@ +"""Explicit tokenizer preparation, separate from offline preview/validation.""" +import json +import os +import re +import shlex +import subprocess + +from harborrl.deployment.remote import ssh_command +from harborrl.deployment.schema import _ssh_spec + +from .presets import defaults, merge + +SCRIPT = '''import json, sys +from transformers import AutoTokenizer +from harborrl.rollout.runtime import tokenizer_fingerprint +tokenizer = AutoTokenizer.from_pretrained(sys.argv[1], local_files_only=True, trust_remote_code=False) +a, b = tokenizer_fingerprint(tokenizer) +print('HARBORRL_FINGERPRINT=' + json.dumps({'tokenizer_digest': a, 'template_digest': b})) +''' + + +def prepare_model(answers, output_directory, *, timeout=90): + from .presets import check_answers + check_answers(answers, output_directory) + t, d = defaults(answers["preset"], output_directory) + t = merge(t, answers.get("training", {})) + d = merge(d, answers.get("deployment", {})) + local = answers.get("local_environment", {}) + target = d["gpu_target"] + source = local.get("source_dir", target["source_dir"]) + python = local.get("python", target["train_python"]) + checkpoint = t["model"]["checkpoint"] + if not all(isinstance(v, str) and v.strip() for v in (source, python, checkpoint)): + raise ValueError("Select the checkpoint directory, GPU source directory and Python interpreter before Prepare model.") + command = [python, "-c", SCRIPT, checkpoint] + env = {**os.environ, "HF_HUB_OFFLINE": "1", "TRANSFORMERS_OFFLINE": "1"} + if target["transport"] == "ssh": + spec = _ssh_spec(target["ssh"], "GPU SSH destination") + remote = (f"cd {shlex.quote(source)} && env HF_HUB_OFFLINE=1 TRANSFORMERS_OFFLINE=1 " + f"PYTHONPATH={shlex.quote(source)} " + shlex.join(command)) + command = ssh_command(spec, remote) + cwd = None + else: + env["PYTHONPATH"] = os.pathsep.join(filter(None, [source, env.get("PYTHONPATH")])) + cwd = source + try: + result = subprocess.run(command, cwd=cwd, env=env, stdin=subprocess.DEVNULL, + capture_output=True, text=True, timeout=timeout, check=False) + except (OSError, subprocess.TimeoutExpired) as exc: + raise ValueError(f"Model preparation could not finish: {exc}. Check the selected Python and SSH access; your draft can still be saved.") from exc + if result.returncode: + detail = (result.stderr or result.stdout)[-2500:].strip() + raise ValueError("Could not load the local tokenizer. Use the GPU training Python with Transformers installed, " + "check the checkpoint files and SSH access. Custom tokenizer code is not automatically executed. " + f"Your draft can still be saved.\n{detail}") + try: + line = next(line for line in reversed(result.stdout.splitlines()) if line.startswith("HARBORRL_FINGERPRINT=")) + fingerprints = json.loads(line.split("=", 1)[1]) + if (set(fingerprints) != {"tokenizer_digest", "template_digest"} + or any(not re.fullmatch(r"[0-9a-f]{64}", value) or value == "0" * 64 for value in fingerprints.values())): + raise ValueError("invalid fingerprints") + except (StopIteration, ValueError, TypeError) as exc: + raise ValueError("Model preparation returned no valid tokenizer fingerprints") from exc + return {"fingerprints": fingerprints, "checkpoint": checkpoint, "python": python, + "target": target["ssh"]["host"] if target["transport"] == "ssh" else "local"} diff --git a/harborrl/options/onboarding.py b/harborrl/options/onboarding.py new file mode 100644 index 000000000..5c958bf70 --- /dev/null +++ b/harborrl/options/onboarding.py @@ -0,0 +1,147 @@ +"""Shared onboarding help, reference recipes and resumable draft documents.""" +import json +import os +import sys +import tempfile +from copy import deepcopy +from pathlib import Path + +from harborrl.config import ROOT + +from .presets import check_answers, defaults + +FIELD_HELP = { + "local_environment.source_dir": ("Shared source directory", "Absolute HarborRL checkout directory on this machine. Controller, GPU and rollout use this one directory.", 0), + "local_environment.python": ("Shared Python interpreter", "Absolute Python executable in your training environment, with HarborRL, Harbor and the training dependencies installed. Detected from the process running this wizard.", 0), + "deployment.manager.source_dir": ("Controller source directory", "Absolute HarborRL checkout directory on the controller running this wizard.", 0), + "deployment.manager.controller_python": ("Controller Python", "Absolute Python executable on the controller with HarborRL installed.", 0), + "deployment.manager.control_url": ("Control origin", "Controller HTTP origin reachable from the GPU, including port 49200; for example http://controller.example:49200.", 0), + "deployment.manager.gateway_url": ("Gateway origin", "Controller HTTP origin reachable from task containers or the configured relay, including port 49184.", 0), + "deployment.gpu_target.ssh.host": ("GPU SSH destination", "SSH alias from the controller user's SSH config, or user@hostname; test with ssh first.", 0), + "deployment.gpu_target.source_dir": ("GPU source directory", "Absolute HarborRL checkout path on the GPU machine, using the same code revision as the controller.", 0), + "deployment.gpu_target.train_python": ("GPU training Python", "Absolute Python executable on the GPU machine with Transformers and the training backend installed.", 0), + "training.harbor.python": ("Harbor runner Python", "Absolute Python executable on the runner with Harbor 0.23.0 installed. Sandbox uses a Python 3.12+ runner with the sandbox extra.", 0), + "deployment.storage.run_root": ("Storage run root", "Writable absolute output directory. Service mode requires the same shared filesystem path on controller, GPU and runners.", 0), + "deployment.rollout.workers": ("Rollout workers", "Add at least one SSH worker with a checkout, Python interpreter, Docker and task root.", 0), + "sandbox_config": ("Sandbox configuration", "Path to your Sandbox YAML on the runner; configure the service endpoint, credential file reference and task image bindings. See docs/sandbox-rollout.md.", 0), + "training.model.args_file": ("Model args preset", "Slime model preset matching the checkpoint architecture; qwen3-8B is the Qwen3 8B example. GPU memory requirements depend on the model.", 1), + "training.model.checkpoint": ("Checkpoint directory", "Existing Hugging Face model directory on the GPU target containing config, tokenizer and model weights. Used to calculate fingerprints automatically.", 1), + "training.model.reference": ("Reference checkpoint", "Existing reference model checkpoint on the GPU target in the format required by the training backend, for example its converted torch_dist directory.", 1), + "training.tasks.catalog": ("Task catalog", "Existing locked JSON/JSONL catalog on the controller. Run harborrl hello for a bundled task and catalog, or harborrl hub --help to prepare your own.", 1), + "training.harness.profile": ("Claude Code profile", "Existing JSON agent profile with cli_version, model, max_turns and timeouts. Run harborrl hello to generate a working profile example.", 1), + "training.gateway.tokenizer_digest": ("Automatic model fingerprints", "Select a checkpoint and Python environment, then choose Prepare model. HarborRL calculates both fingerprints with the real tokenizer on the GPU target.", 1), + "training.gateway.template_digest": ("Automatic chat template fingerprint", "Calculated together with the tokenizer fingerprint by Prepare model; no manual SHA-256 entry is needed.", 1), + "training.gateway.host": ("Gateway bind address", "Listener address on this machine. Use 0.0.0.0 if Docker task containers need to connect from outside the host loopback interface.", 1), + "training.gateway.advertised_url": ("Gateway advertised origin", "HTTP origin reachable from task containers. For local Docker use a host address reachable from containers or configured host networking; loopback works only when that network is shared.", 1), + "training.deployment.layout": ("GPU layout", "colocate shares GPUs between training and serving; split assigns separate GPU budgets to each.", 2), + "training.deployment.num_gpus": ("Total GPUs", "Number of GPUs reserved for this run. Check the model memory requirements before choosing a budget.", 2), + "training.deployment.actor_gpus": ("Actor GPUs", "GPUs used for weight updates. In colocate mode this equals the total GPU budget.", 2), + "training.deployment.rollout_gpus": ("Serving GPUs", "GPUs used for model inference during rollout. In colocate mode this equals the total GPU budget.", 2), + "training.deployment.actor_tensor_parallel_size": ("Actor tensor parallelism", "Number of GPUs that jointly hold one training model replica; must divide the actor GPU budget.", 2), + "training.deployment.rollout_gpus_per_engine": ("Serving tensor parallelism", "GPUs assigned to each serving engine; must divide the serving GPU budget.", 2), + "training.training.num_rollout": ("Rollout batches", "Number of rollout batches to collect and train on before this run finishes.", 2), + "training.training.learning_rate": ("Learning rate", "Positive optimizer learning rate; 1e-6 is a conservative starting value to review for your model.", 2), + "training.training.save_interval": ("Save interval", "Save training checkpoints every this many rollout batches.", 2), + "training.training.backend_contract": ("Algorithm contract", "Select the training adapter contract supported by your installed Slime revision.", 2), + "training.sampling.group_size": ("Samples per group", "Generate at least two attempts for the same prompt so GRPO can compare their verifier rewards.", 2), + "training.sampling.groups_per_batch": ("Groups per batch", "Number of complete prompt groups in one training batch. Samples per batch equals this times samples per group.", 2), + "training.sampling.max_tokens": ("Maximum output tokens", "Maximum generated tokens per model response. Increase for tasks that require longer reasoning or tool use.", 2), + "training.sampling.max_context": ("Context limit", "Maximum prompt plus response tokens supported by the selected model and serving runtime.", 2), + "training.sampling.max_attempts": ("Maximum attempts", "Maximum task execution attempts allowed for each rollout slot.", 2), +} + + +def describe_field(path): + label, help_text, step = FIELD_HELP.get(path, (path.split(".")[-1].replace("_", " "), + "Enter the value on the machine assigned to this role.", 0)) + if path.startswith("deployment.rollout.workers."): + key = path.split(".")[-1] + label = {"host": "Worker SSH destination", "source_dir": "Worker source directory", + "runner_python": "Worker runner Python", "0": "Worker task root", + "id": "Worker ID"}.get(key, label) + help_text = {"host": "SSH alias reachable from the controller.", + "source_dir": "Absolute HarborRL checkout directory on this worker.", + "runner_python": "Absolute Python executable with Harbor installed on this worker.", + "0": "Absolute directory containing task files on this worker.", + "id": "Unique short worker name, using letters, numbers and hyphens."}.get(key, help_text) + return {"path": path, "label": label, "help": help_text, "step": step} + + +def reference_examples(output_directory): + """Site-independent, filled recipes. Paths/hosts are illustrative, never readiness claims.""" + output = Path(output_directory).resolve() + result = [] + for preset, label in (("embedded-local", "Local best practice"), + ("service-sandbox-local-runner", "Sandbox best practice")): + t, d = defaults(preset, output) + t["tasks"]["catalog"] = str(output.parent / "hello-world" / "catalog.json") + t["harness"]["profile"] = str(output.parent / "hello-world" / "claude-code-profile.json") + t["model"].update(checkpoint="/srv/models/Qwen3-8B", reference="/srv/models/Qwen3-8B_torch_dist") + # Four colocated GPUs is a topology example, not a model memory guarantee. + t["deployment"].update(num_gpus=4, actor_gpus=4, rollout_gpus=4) + d["gpu_target"]["expected_gpus"] = 4 + answers = {"answers_version": 1, "preset": preset, "training": t, "deployment": d} + if preset == "embedded-local": + answers["local_environment"] = {"source_dir": str(ROOT), "python": sys.executable} + t["gateway"].update(host="0.0.0.0", advertised_url="http://host.docker.internal:49184") + d["manager"].update(bind="0.0.0.0", gateway_url="http://host.docker.internal:49184") + notes = ["The shared source directory and Python are detected on the machine running HarborRL; edit them once for all three roles.", + "Docker containers must resolve host.docker.internal to the host (on Linux configure host-gateway or replace it with a reachable host address)."] + else: + d["manager"].update(control_url="http://controller.example:49200", gateway_url="http://controller.example:49184") + d["gpu_target"].update(ssh={"host": "gpu-training"}, source_dir="/srv/HarborRL", train_python="/srv/venvs/harborrl/bin/python") + d["storage"]["run_root"] = t["output"]["root"] = "/srv/shared/harborrl-runs" + t["gateway"]["advertised_url"] = d["manager"]["gateway_url"] + t["harbor"]["python"] = sys.executable + answers["sandbox_config"] = "/srv/site/sandbox.yaml" + t["harbor"]["environment"]["config"] = answers["sandbox_config"] + notes = ["Controller and Sandbox runner are on this host; the training GPU is reached over SSH. Use a Python 3.12+ Sandbox runner.", + "Replace the example hostnames and paths; mount the shared run root at the same absolute path on every participating host.", + "Provide your Sandbox endpoint, credential file and task image bindings in sandbox.yaml."] + result.append({"id": preset, "title": label, "answers": answers, + "notes": notes + ["Replace model and input paths with existing files, then Prepare model to calculate fingerprints.", + "Four GPUs illustrates colocate allocation; run Doctor and check model memory before training."], + "status": "reference"}) + return result + + +def load_draft(path): + path = Path(path) + if path.is_symlink(): + raise ValueError("Draft must be a regular file") + document = json.loads(path.read_text(encoding="utf-8")) + if (document.get("draft_version") != 1 or type(document.get("revision")) is not int + or document["revision"] < 1): + raise ValueError("Unsupported draft document") + check_answers(document["answers"], path.parent) + return document + + +def save_draft(path, answers, *, revision=None): + path = Path(path).expanduser().absolute() + check_answers(answers, path.parent) + if path.is_symlink(): + raise ValueError("Draft must be a regular file") + path.parent.mkdir(parents=True, exist_ok=True) + lock = path.with_name("." + path.name + ".lock") + descriptor = os.open(lock, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600) + temporary = None + try: + os.close(descriptor) + current = load_draft(path)["revision"] if path.exists() else 0 + if revision != current and not (revision is None and current == 0): + raise ValueError("Draft changed since it was opened. Reopen it before saving, or use a new name.") + document = {"draft_version": 1, "revision": current + 1, "answers": deepcopy(answers)} + descriptor, name = tempfile.mkstemp(prefix="." + path.name, dir=path.parent) + temporary = Path(name) + with os.fdopen(descriptor, "w", encoding="utf-8") as stream: + json.dump(document, stream, indent=2) + stream.write("\n") + stream.flush() + os.fsync(stream.fileno()) + temporary.replace(path) + return document + finally: + if temporary is not None: + temporary.unlink(missing_ok=True) + lock.unlink() diff --git a/harborrl/options/presets.py b/harborrl/options/presets.py index 6cc7ef8b5..e6e2d6842 100644 --- a/harborrl/options/presets.py +++ b/harborrl/options/presets.py @@ -14,6 +14,14 @@ ) +class MissingAnswers(ValueError): + def __init__(self, paths): + from .onboarding import describe_field + self.fields = [describe_field(path) for path in paths] + super().__init__("Missing required answers:\n" + "\n".join( + f" - {row['label']} ({row['path']}): {row['help']}" for row in self.fields)) + + def merge(base, override, path=""): if not isinstance(override, dict): raise TypeError(f"{path or 'answers'} must be a mapping") @@ -37,7 +45,7 @@ def defaults(preset, output_directory): "schema_version": 2, "execution": {"backend": "harbor_job"}, "tasks": {"catalog": None}, "harness": {"name": "claude_code", "profile": None}, - "harbor": {"python": None, "version": "0.23.0", "workers": [], + "harbor": {"python": sys.executable if not service else None, "version": "0.23.0", "workers": [], "runner_transport": "ssh" if preset == "service-ssh" else "local", "environment": {"runtime": "sandbox", "config": None} if sandbox else {"runtime": "docker"}}, "gateway": {"host": "0.0.0.0" if service else "127.0.0.1", "port": 49184, @@ -64,18 +72,35 @@ def defaults(preset, output_directory): return training, deployment -def build_configs(answers, output_directory): +def check_answers(answers, output_directory): if not isinstance(answers, dict) or set(answers) - { - "answers_version", "preset", "training", "deployment", "sandbox_config"}: + "answers_version", "preset", "training", "deployment", "sandbox_config", "local_environment"}: raise ValueError("unknown answers field or non-mapping answers") if type(answers.get("answers_version")) is not int or answers["answers_version"] != 1: raise ValueError("answers_version must be 1") preset = answers.get("preset") training, deployment = defaults(preset, output_directory) + merge(training, answers.get("training", {}), "training.") + merge(deployment, answers.get("deployment", {}), "deployment.") + if "local_environment" in answers: + if preset != "embedded-local": + raise ValueError("local_environment requires Local mode") + merge({"source_dir": str(ROOT), "python": sys.executable}, answers["local_environment"], "local_environment.") + + +def build_configs(answers, output_directory): + check_answers(answers, output_directory) + preset = answers["preset"] + training, deployment = defaults(preset, output_directory) t = answers.get("training", {}) d = answers.get("deployment", {}) training = merge(training, t, "training.") deployment = merge(deployment, d, "deployment.") + if "local_environment" in answers: + environment = {"source_dir": str(ROOT), "python": sys.executable, **answers["local_environment"]} + deployment["manager"].update(source_dir=environment["source_dir"], controller_python=environment["python"]) + deployment["gpu_target"].update(source_dir=environment["source_dir"], train_python=environment["python"]) + training["harbor"]["python"] = environment["python"] # Repeated fields have one source unless both were explicitly supplied. for section, field, other_section, other_field in ( @@ -121,6 +146,8 @@ def build_configs(answers, output_directory): ("tasks", "catalog"), ("harness", "profile"), ("harbor", "python"), ("model", "checkpoint"), ("model", "reference"), ("gateway", "tokenizer_digest"), ("gateway", "template_digest"), ("gateway", "advertised_url"))] + required += [("deployment", "manager", "source_dir"), ("deployment", "manager", "controller_python"), + ("deployment", "storage", "run_root")] if preset != "embedded-local": required += [("deployment", section, field) for section, field in ( ("manager", "control_url"), ("manager", "gateway_url"), @@ -131,10 +158,23 @@ def build_configs(answers, output_directory): missing.append("deployment.gpu_target.ssh.host") if preset == "service-ssh" and not deployment["rollout"]["workers"]: missing.append("deployment.rollout.workers") + for index, worker in enumerate(deployment["rollout"]["workers"]): + for key in ("id", "source_dir", "runner_python"): + if not worker.get(key): + missing.append(f"deployment.rollout.workers.{index}.{key}") + if not worker.get("ssh", {}).get("host"): + missing.append(f"deployment.rollout.workers.{index}.ssh.host") + if not worker.get("task_roots") or not worker["task_roots"][0]: + missing.append(f"deployment.rollout.workers.{index}.task_roots.0") if preset == "service-sandbox-local-runner" and not training["harbor"]["environment"].get("config"): missing.append("sandbox_config") if missing: - raise ValueError("Missing required answers: " + ", ".join(missing)) + if "local_environment" in answers: + aliases = {"deployment.manager.source_dir": "local_environment.source_dir", + "deployment.manager.controller_python": "local_environment.python", + "training.harbor.python": "local_environment.python"} + missing = list(dict.fromkeys(aliases.get(path, path) for path in missing)) + raise MissingAnswers(missing) for field in ("tokenizer_digest", "template_digest"): if training["gateway"][field] == "0" * 64: raise ValueError(f"gateway.{field}: a placeholder digest cannot be used by init") diff --git a/harborrl/trajectories/native.py b/harborrl/trajectories/native.py index 8043c670d..eaf9ed32e 100644 --- a/harborrl/trajectories/native.py +++ b/harborrl/trajectories/native.py @@ -61,11 +61,12 @@ def publish(path, value): except FileExistsError: if path.read_bytes() != payload: raise ValueError(f"conflicting immutable record: {path.name}") - directory = os.open(path.parent, os.O_RDONLY) - try: - os.fsync(directory) - finally: - os.close(directory) + if os.name == "posix": + directory = os.open(path.parent, os.O_RDONLY) + try: + os.fsync(directory) + finally: + os.close(directory) finally: os.unlink(temp) return path diff --git a/harborrl/webui/jobs.py b/harborrl/webui/jobs.py index 28a36df2b..289fd7829 100644 --- a/harborrl/webui/jobs.py +++ b/harborrl/webui/jobs.py @@ -3,6 +3,7 @@ import os import signal import subprocess +import sys import threading from datetime import datetime, timezone from pathlib import Path @@ -104,6 +105,42 @@ def start(self, configuration, bundle, mode): threading.Thread(target=self._watch, args=(self.child,), name="harborrl-web-job", daemon=True).start() return dict(record) + def start_hello(self): + """Use the same durable logs/lifecycle without requiring a training configuration.""" + from harborrl.config import ROOT + + with self.lock: + self._refresh() + if self.closed or self.child is not None: + raise ValueError("A job is already active or the console is closing") + if any(record["state"] == "unknown" for record in self.records.values()): + raise ValueError("An interrupted job must be reconciled before launching again") + identifier = uuid4().hex + root = self.root / identifier + root.mkdir(mode=0o700) + output = root / "output" + command = [sys.executable, "-u", "-m", "harborrl.cli", "hello", "--out-dir", str(output)] + record = {"id": identifier, "configuration": "Hello World", "mode": "hello", "state": "running", + "created_at": now(), "finished_at": None, "exit_code": None, "command": command, + "log_path": str(root / "console.log"), "snapshot": str(root), "output_root": str(output)} + env = {**os.environ, "PYTHONUNBUFFERED": "1"} + env["PYTHONPATH"] = os.pathsep.join(filter(None, [str(ROOT), env.get("PYTHONPATH")])) + self._save(record) + try: + with (root / "console.log").open("wb") as log: + self.child = subprocess.Popen(command, cwd=ROOT, env=env, stdin=subprocess.DEVNULL, + stdout=log, stderr=subprocess.STDOUT, start_new_session=True) + except OSError as exc: + record.update(state="failed", message=str(exc), finished_at=now()) + else: + self.active = identifier + record["pid"] = self.child.pid + self.records[identifier] = record + self._save(record) + if self.child is not None: + threading.Thread(target=self._watch, args=(self.child,), name="harborrl-web-hello", daemon=True).start() + return dict(record) + def stop(self, identifier): with self.lock: self._refresh() diff --git a/harborrl/webui/server.py b/harborrl/webui/server.py index 442699175..aa1e7ca06 100644 --- a/harborrl/webui/server.py +++ b/harborrl/webui/server.py @@ -13,7 +13,8 @@ import yaml from harborrl.options.catalog import build_catalog -from harborrl.options.presets import PRESETS, defaults +from harborrl.options.presets import PRESETS, MissingAnswers, check_answers, defaults +from harborrl.options.onboarding import FIELD_HELP, describe_field, load_draft, reference_examples, save_draft from harborrl.options.validation import read_mapping, validate_mappings from harborrl.options.wizard import build_config_bundle, publish_bundle @@ -32,6 +33,8 @@ def __init__(self, workspace): self.workspace = Path(workspace).expanduser().resolve() self.configurations = self.workspace / "configurations" self.configurations.mkdir(parents=True, exist_ok=True) + self.drafts = self.workspace / "drafts" + self.drafts.mkdir(exist_ok=True) self.jobs = Jobs(self.workspace / "jobs") self.token = secrets.token_urlsafe(32) self.lock = threading.RLock() @@ -49,8 +52,31 @@ def bootstrap(self): "live_supported": os.name == "posix", "defaults": {item["id"]: dict(zip(("training", "deployment"), defaults(item["id"], self.configurations / "experiment"))) for item in PRESETS}, + "field_help": {path: describe_field(path) for path in FIELD_HELP}, + "examples": reference_examples(self.configurations / "experiment"), + "drafts": self.list_drafts(), "configurations": self.list_configurations(), "jobs": self.jobs.list()} + def draft_path(self, name): + self.directory(name) + path = self.drafts / (name + ".json") + if path.is_symlink() or path.resolve().parent != self.drafts: + raise ValueError("Draft must stay inside the workspace") + return path + + def list_drafts(self): + return [{"id": path.stem, "revision": load_draft(path)["revision"], "kind": "draft"} + for path in sorted(self.drafts.glob("*.json")) + if not path.is_symlink() and NAME.fullmatch(path.stem)] + + def draft(self, name): + return {"id": name, **load_draft(self.draft_path(name))} + + def save_draft(self, body): + with self.lock: + document = save_draft(self.draft_path(body["name"]), body["answers"], revision=body.get("revision")) + return {"id": body["name"], "kind": "draft", "path": str(self.draft_path(body["name"])), **document} + def list_configurations(self): result = [] for path in sorted(self.configurations.iterdir()): @@ -78,6 +104,9 @@ def configuration(self, identifier): manifest = json.loads((root / "setup-manifest.json").read_text(encoding="utf-8")) answers = {"answers_version": 1, "preset": manifest["preset"], "training": read_mapping(root / "config.yaml"), "deployment": read_mapping(root / "deployment.yaml")} + if answers["preset"] == "embedded-local": + manager = answers["deployment"]["manager"] + answers["local_environment"] = {"source_dir": manager["source_dir"], "python": manager["controller_python"]} return {"id": identifier, "answers": answers, "files": {name: (root / name).read_text(encoding="utf-8") for name in ("config.yaml", "deployment.yaml", "setup-manifest.json")}} @@ -135,6 +164,8 @@ def dispatch(self, method): return self.respond(200, console.bootstrap()) if path == "/api/jobs": return self.respond(200, console.jobs.list()) + if path.startswith("/api/drafts/"): + return self.respond(200, console.draft(path.removeprefix("/api/drafts/"))) if path.startswith("/api/jobs/"): return self.respond(200, console.jobs.log(path.removeprefix("/api/jobs/"))) if path.startswith("/api/configurations/"): @@ -154,6 +185,25 @@ def dispatch(self, method): return self.respond(200, console.preview(body)) if path == "/api/configurations": return self.respond(201, console.save(body)) + if path == "/api/drafts": + return self.respond(201, console.save_draft(body)) + if path == "/api/prepare-model": + from harborrl.options.fingerprints import prepare_model + return self.respond(200, prepare_model(body["answers"], console.directory(body["name"]))) + if path == "/api/hello": + return self.respond(201, console.jobs.start_hello()) + if path == "/api/hello-inputs": + from harborrl.hello import create_inputs + with console.lock: + root = console.workspace / "hello-world" + if not root.exists(): + create_inputs(root) + if root.is_symlink() or not root.resolve().is_relative_to(console.workspace): + raise ValueError("Hello World inputs must stay inside the workspace") + if not all((root / name).is_file() for name in ("catalog.json", "claude-code-profile.json")): + raise ValueError("Hello World inputs are incomplete. Run harborrl hello with a new output directory and use its catalog and profile.") + return self.respond(200, {"catalog": str(root / "catalog.json"), + "profile": str(root / "claude-code-profile.json")}) if path == "/api/jobs": return self.respond(201, console.start(body)) if path == "/api/stop": @@ -167,14 +217,12 @@ def dispatch(self, method): if (not isinstance(answers, dict) or type(answers.get("answers_version")) is not int or answers.get("answers_version") != 1): raise ValueError("Import an answers_version: 1 YAML/JSON mapping") - if set(answers) - {"answers_version", "preset", "training", "deployment", "sandbox_config"}: - raise ValueError("Unknown answer fields") - if any(not isinstance(answers.get(key, {}), dict) for key in ("training", "deployment")): - raise ValueError("training and deployment answers must be objects") - if answers.get("preset") not in {p["id"] for p in PRESETS}: - raise ValueError("Unknown deployment preset") + check_answers(answers, console.configurations / "import") return self.respond(200, {"answers": answers}) self.respond(404, {"error": "Not found"}) + except MissingAnswers as exc: + self.respond(400, {"error": "Complete these fields to create a runnable configuration. You can save this draft now.", + "code": "missing_answers", "fields": exc.fields}) except (ValueError, TypeError, KeyError, OSError, AttributeError, yaml.YAMLError) as exc: self.respond(400, {"error": str(exc)}) diff --git a/harborrl/webui/static/app.js b/harborrl/webui/static/app.js index 0a6a5dc0d..7f73d4c0b 100644 --- a/harborrl/webui/static/app.js +++ b/harborrl/webui/static/app.js @@ -23,6 +23,10 @@ const state = { logSource: "console", jobs: [], busy: false, + draft: null, + dirty: false, + issues: [], + example: null, }; const fragment = new URLSearchParams(location.hash.slice(1)); if (fragment.has("token")) { @@ -48,12 +52,17 @@ async function api(path, body) { ...(body === undefined ? {} : { body: JSON.stringify(body) }), }); const result = await response.json(); - if (!response.ok) throw new Error(result.error || "Request failed"); + if (!response.ok) { + const error = new Error(result.error || "Request failed"); + error.fields = result.fields || []; + throw error; + } return result; } async function action(callback) { if (state.busy) return; state.busy = true; + updateActions(); $("#notice").hidden = true; try { await callback(); @@ -89,6 +98,8 @@ function defaultRoot() { function read(path) { const supplied = valueAt(state.answers, path); if (supplied !== undefined) return supplied; + if (path === "local_environment.source_dir") return read("deployment.manager.source_dir"); + if (path === "local_environment.python") return read("deployment.manager.controller_python"); if (path === "sandbox_config") return valueAt(state.answers, "training.harbor.environment.config"); if (path === "deployment.storage.run_root") @@ -106,7 +117,8 @@ function read(path) { function changed() { state.saved = null; state.preview = null; - $("#draft-state").textContent = "Draft"; + state.dirty = true; + $("#draft-state").textContent = "Unsaved changes"; $("#draft-state").className = "badge"; updateActions(); renderSummary(); @@ -118,19 +130,35 @@ function deriveWorkers() { } } function newAnswers(preset = "embedded-local") { - return { answers_version: 1, preset, training: {}, deployment: {} }; + const answers = { answers_version: 1, preset, training: {}, deployment: {} }; + if (preset === "embedded-local" && state.bootstrap) { + const manager = state.bootstrap.defaults[preset].deployment.manager; + answers.local_environment = { source_dir: manager.source_dir, python: manager.controller_python }; + } + return answers; } function field(path, label, type = "text", options = {}) { const value = read(path), id = path.replaceAll(".", "-"); + const help = state.bootstrap.field_help[path]?.help || options.hint || + (path.includes("workers.") ? workerHelp(path) : ""); + const invalid = state.issues.some((issue) => issue.path === path); + const attributes = `aria-describedby="${id}-help" aria-invalid="${invalid}" ${options.required ? 'aria-required="true" required' : ""}`; let control; if (options.choices) - control = ``; + control = ``; else if (type === "checkbox") control = ``; else - control = ``; - return `
${type === "checkbox" ? control : ""}${type === "checkbox" ? "" : control}${options.hint ? `${escapeHtml(options.hint)}` : ""}
`; + control = ``; + return `
${type === "checkbox" ? control : ""}${type === "checkbox" ? "" : control}${escapeHtml(help)}
`; +} +function workerHelp(path) { + if (path.endsWith("ssh.host")) return "SSH alias reachable from the controller."; + if (path.endsWith("source_dir")) return "Absolute HarborRL checkout directory on this worker."; + if (path.endsWith("runner_python")) return "Absolute Python executable with Harbor installed on this worker."; + if (path.includes("task_roots")) return "Absolute directory containing the task files on this worker."; + return "Unique worker name using letters, numbers and hyphens."; } const section = (title, content) => `

${title}

${content}
`; @@ -146,7 +174,13 @@ function renderForm() { ["service-sandbox-local-runner", "Sandbox", "Controller + GPU", "box"], ]; html = `

Deployment topology

${service ? "Service" : "Embedded"}
${presets.map(([id, label, detail, ico]) => ``).join("")}
`; - html += section( + if (!service) html += section( + "Shared local environment", + '

Controller, GPU and rollout share this source directory and Python. Values were detected on the machine running HarborRL. Choose an environment with the required training and Harbor dependencies.

' + + field("local_environment.source_dir", "Shared source directory", "text", {full: true, required: true}) + + field("local_environment.python", "Shared Python interpreter", "text", {full: true, required: true}), + ); + if (service) html += section( "Controller", field("deployment.manager.source_dir", "Source directory", "text", { full: true, @@ -170,7 +204,7 @@ function renderForm() { }) : ""), ); - html += section( + if (service) html += section( "GPU target", (service ? field("deployment.gpu_target.ssh.host", "SSH destination", "text", { @@ -191,7 +225,7 @@ function renderForm() { ); if (preset === "service-ssh") html += `

Rollout workers

`; - else + else if (service) html += section( "Rollout runner", field("training.harbor.python", "Harbor runner Python", "text", { @@ -242,6 +276,7 @@ function renderForm() { ); html += section( "Tasks & agent", + '

Creates a task catalog and agent profile on this machine. Training still needs your model and runtime.

' + field("training.tasks.catalog", "Task catalog", "text", { full: true, required: true, @@ -256,17 +291,10 @@ function renderForm() { ); html += section( "Gateway fingerprints", - field("training.gateway.tokenizer_digest", "Tokenizer SHA-256", "text", { - full: true, - required: true, - }) + - field("training.gateway.template_digest", "Template SHA-256", "text", { - full: true, - required: true, - }) + + `

Calculated automatically from the actual checkpoint tokenizer using the GPU target Python${service ? " over SSH" : ""}. No SHA-256 entry is needed.

${read("training.gateway.tokenizer_digest") && read("training.gateway.template_digest") ? "Fingerprints prepared" : "Model preparation required"}
Calculated fingerprints
Tokenizer
${escapeHtml(read("training.gateway.tokenizer_digest") || "Not calculated")}
Chat template
${escapeHtml(read("training.gateway.template_digest") || "Not calculated")}
` + (!service ? field("training.gateway.host", "Bind address") + - field("training.gateway.advertised_url", "Advertised origin", "url") + field("training.gateway.advertised_url", "Advertised origin", "url", {required: true}) : ""), ); } else if (state.step === 2) { @@ -346,7 +374,25 @@ function renderForm() { ? null : Number(input.value) : input.value; + if (path.startsWith("local_environment.")) { + state.answers.local_environment = { + source_dir: read("local_environment.source_dir"), + python: read("local_environment.python"), + }; + } setAt(state.answers, path, value); + state.issues = state.issues.filter((issue) => issue.path !== path); + input.setAttribute("aria-invalid", "false"); + input.closest(".field").classList.remove("invalid-field"); + renderIssues(); + if (["training.model.checkpoint", "local_environment.python", "local_environment.source_dir", + "deployment.gpu_target.train_python", "deployment.gpu_target.source_dir", "deployment.gpu_target.ssh.host"].includes(path)) { + if (state.answers.training.gateway) { + delete state.answers.training.gateway.tokenizer_digest; + delete state.answers.training.gateway.template_digest; + } + if ($("#fingerprint-status")) $("#fingerprint-status").textContent = "Checkpoint or environment changed; prepare model again"; + } // Loaded bundles contain both sides of repeated schema fields. const mirrors = { "deployment.manager.control_url": null, @@ -406,11 +452,37 @@ function renderForm() { ) return; state.answers = newAnswers(button.dataset.preset); + state.draft = null; + state.issues = []; changed(); renderForm(); }), ), ); + $("#prepare-model")?.addEventListener("click", () => action(async () => { + $("#fingerprint-status").textContent = "Loading tokenizer and calculating fingerprints..."; + try { + const result = await api("/api/prepare-model", {name: $("#config-name").value, answers: state.answers}); + setAt(state.answers, "training.gateway.tokenizer_digest", result.fingerprints.tokenizer_digest); + setAt(state.answers, "training.gateway.template_digest", result.fingerprints.template_digest); + state.issues = state.issues.filter((issue) => !issue.path.includes("_digest")); + changed(); + renderForm(); + renderIssues(); + notify(`Model fingerprints calculated on ${result.target}.`, true); + } catch (error) { + $("#fingerprint-status").textContent = "Preparation failed. Check the environment and checkpoint; you can save this draft."; + throw error; + } + })); + $("#hello-inputs")?.addEventListener("click", () => action(async () => { + const inputs = await api("/api/hello-inputs", {}); + setAt(state.answers, "training.tasks.catalog", inputs.catalog); + setAt(state.answers, "training.harness.profile", inputs.profile); + state.issues = state.issues.filter((issue) => !["training.tasks.catalog", "training.harness.profile"].includes(issue.path)); + changed(); renderForm(); + notify("Bundled task catalog and agent profile are ready.", true); + })); $("#add-worker")?.addEventListener("click", () => { const workers = state.answers.deployment.rollout?.workers || []; workers.push({ @@ -427,6 +499,7 @@ function renderForm() { renderForm(); }); renderSummary(); + renderIssues(); updateActions(); icons(); } @@ -436,7 +509,7 @@ function renderWorkers() { ? workers .map( (worker, index) => - `

Worker ${index + 1}

${field(`deployment.rollout.workers.${index}.id`, "ID")}${field(`deployment.rollout.workers.${index}.ssh.host`, "SSH destination")}${field(`deployment.rollout.workers.${index}.source_dir`, "Source directory", "text", { full: true })}${field(`deployment.rollout.workers.${index}.runner_python`, "Runner Python", "text", { full: true })}${field(`deployment.rollout.workers.${index}.task_roots.0`, "Task root", "text", { full: true })}
`, + `

Worker ${index + 1}

${field(`deployment.rollout.workers.${index}.id`, "ID", "text", {required: true})}${field(`deployment.rollout.workers.${index}.ssh.host`, "SSH destination", "text", {required: true})}${field(`deployment.rollout.workers.${index}.source_dir`, "Source directory", "text", { full: true , required: true })}${field(`deployment.rollout.workers.${index}.runner_python`, "Runner Python", "text", { full: true , required: true })}${field(`deployment.rollout.workers.${index}.task_roots.0`, "Task root", "text", { full: true , required: true })}
`, ) .join("") : '

No workers configured.

'; @@ -493,7 +566,7 @@ function renderSummary() { `
${rows.map(([key, value]) => `
${key}
${escapeHtml(value)}
`).join("")}
`; $("#summary-status").textContent = state.saved ? "Saved configuration" - : "Unsaved configuration"; + : state.draft && !state.dirty ? "Draft saved; continue when ready" : "Unsaved changes"; icons(); } function updateActions() { @@ -506,6 +579,9 @@ function updateActions() { !state.preview || state.preview.validation.status === "invalid" || !!state.saved; + $("#save-draft").disabled = state.busy; + $("#save-draft").textContent = state.draft && !state.dirty ? "Draft saved" : "Save draft"; + $("#prepare-model")?.toggleAttribute("disabled", state.busy); $("#step-description").textContent = `Step ${state.step + 1} of 4`; $$("[data-step]").forEach((button) => { button.classList.toggle( @@ -520,6 +596,7 @@ function updateActions() { const active = state.jobs.some((job) => ["running", "stopping", "unknown"].includes(job.state), ); + $("#hello-world").disabled = state.busy || active; $("#dry-run").disabled = !state.saved || state.busy || active; $("#doctor").disabled = !state.saved || state.busy || active || !state.bootstrap?.live_supported; @@ -537,17 +614,40 @@ async function preview(checkFiles = false) { check_files: checkFiles, }); const result = state.preview.validation; + state.issues = []; + renderIssues(); $("#validation-result").innerHTML = `
${result.status === "invalid" ? "Changes required" : "Structure valid · runtime checks deferred"}
${result.checks.map((row) => `
${icon(row.status === "passed" ? "circle-check" : row.status === "failed" ? "circle-x" : "clock-3")}
${escapeHtml(row.path)}

${escapeHtml(row.message)}

`).join("")}`; renderPreview(); } catch (error) { - $("#validation-result").textContent = error.message; - throw error; + state.issues = error.fields || []; + renderIssues(); + $("#validation-result").textContent = state.issues.length + ? `${state.issues.length} required fields need attention. Follow the links above, or save this draft and return later.` + : error.message; + if (!state.issues.length) throw error; } finally { updateActions(); icons(); } } +function renderIssues() { + const panel = $("#missing-fields"); + panel.hidden = !state.issues.length; + panel.innerHTML = `${state.issues.length} required fields need attention

You can save an incomplete draft at any time.

    ${state.issues.map((issue, index) => `
  • `).join("")}
`; + $$("[data-issue]").forEach((button) => button.addEventListener("click", () => action(async () => { + const issue = state.issues[Number(button.dataset.issue)]; + state.step = issue.step; renderForm(); + let path = issue.path; + if (state.answers.preset === "embedded-local") { + if (path === "deployment.manager.source_dir") path = "local_environment.source_dir"; + if (path === "deployment.manager.controller_python" || path === "training.harbor.python") path = "local_environment.python"; + } + const target = path.includes("_digest") ? $("#prepare-model") : + document.getElementById(path.replaceAll(".", "-")) || $("#add-worker"); + if (target) {target.focus(); target.scrollIntoView({block: "center", behavior: "smooth"});} + }))); +} function renderPreview() { $("#config-preview").textContent = state.preview?.files[state.file] || ""; $$("[data-file]").forEach((button) => @@ -569,12 +669,13 @@ function showView(view) { if (view === "runs") renderJobs(); } function renderSaved() { - const configurations = state.bootstrap.configurations; + const configurations = [...(state.bootstrap.drafts || []).map((item) => ({...item, kind: "draft"})), + ...state.bootstrap.configurations]; $("#saved-list").innerHTML = configurations.length ? configurations .map( (config) => - ``, + ``, ) .join("") : 'No configurations yet'; @@ -582,7 +683,7 @@ function renderSaved() { button.addEventListener("click", () => action(async () => { if ( - !state.saved && + state.dirty && !(await confirmAction( "Open saved configuration?", "The current draft will be replaced.", @@ -590,16 +691,19 @@ function renderSaved() { )) ) return; + const draft = button.dataset.kind === "draft"; const config = await api( - `/api/configurations/${encodeURIComponent(button.dataset.config)}`, + `/api/${draft ? "drafts" : "configurations"}/${encodeURIComponent(button.dataset.config)}`, ); state.answers = config.answers; - state.saved = config.id; + state.draft = draft ? {id: config.id, revision: config.revision} : null; + state.dirty = false; state.issues = []; + state.saved = draft ? null : config.id; $("#config-name").value = config.id; - $("#draft-state").textContent = "Saved"; + $("#draft-state").textContent = draft ? "Draft saved" : "Runnable"; $("#draft-state").className = "badge saved"; showView("configuration"); - await goStep(3); + await goStep(draft ? 0 : 3); renderSaved(); }), ), @@ -700,6 +804,7 @@ async function refreshJobs() { async function importAnswers(text) { const { answers } = await api("/api/import", { text }); state.answers = answers; + state.draft = null; state.issues = []; changed(); state.step = 0; renderForm(); @@ -760,19 +865,59 @@ async function initialize() { }); $("#save").addEventListener("click", () => action(async () => { + // Runnable bundles are immutable. Publish edits as a new numbered version. + const baseName = $("#config-name").value; + let name = baseName, index = 2; + while (state.bootstrap.configurations.some((item) => item.id === name)) name = `${baseName.slice(0, 56)}-v${index++}`; const saved = await api("/api/configurations", { - name: $("#config-name").value, + name, answers: state.answers, }); state.saved = saved.id; + state.dirty = false; state.bootstrap.configurations.push(saved); - $("#draft-state").textContent = "Saved"; + $("#draft-state").textContent = "Runnable"; $("#draft-state").className = "badge saved"; renderSaved(); renderSummary(); notify(`Saved ${saved.id}`, true); }), ); + $("#save-draft").addEventListener("click", () => action(async () => { + const name = $("#config-name").value; + const saved = await api("/api/drafts", {name, answers: state.answers, + revision: state.draft?.id === name ? state.draft.revision : null}); + state.draft = {id: saved.id, revision: saved.revision}; + state.dirty = false; + state.bootstrap.drafts = [...state.bootstrap.drafts.filter((item) => item.id !== saved.id), saved]; + $("#draft-state").textContent = "Draft saved"; + $("#draft-state").className = "badge saved"; + renderSaved(); renderSummary(); + notify(`Draft saved: ${saved.id}. Reopen it from the sidebar to continue.`, true); + })); + $("#hello-world").addEventListener("click", () => action(async () => { + const job = await api("/api/hello", {}); + state.selectedJob = job.id; state.logSource = "console"; $("#log-source").value = "console"; + showView("runs"); await refreshJobs(); + })); + $$("[data-example]").forEach((button) => button.addEventListener("click", () => { + state.example = state.bootstrap.examples.find((item) => item.id === button.dataset.example); + $("#example-title").textContent = state.example.title; + $("#example-notes").innerHTML = state.example.notes.map((note) => `
  • ${escapeHtml(note)}
  • `).join(""); + $("#example-content").textContent = JSON.stringify(state.example.answers, null, 2); + $("#example-dialog").showModal(); + })); + $("#close-example").addEventListener("click", () => $("#example-dialog").close()); + $("#download-example").addEventListener("click", () => download(`${state.example.id}-answers.json`, JSON.stringify(state.example.answers, null, 2), "application/json")); + $("#apply-example").addEventListener("click", () => action(async () => { + const example = structuredClone(state.example); + $("#example-dialog").close(); + if (state.dirty && !(await confirmAction("Use this example?", "Replace the current unsaved answers with the reference recipe?", "Use example"))) return; + state.answers = example.answers; state.draft = null; state.issues = []; + $("#config-name").value = `${example.id === "embedded-local" ? "local" : "sandbox"}-${Date.now().toString(36)}`; + state.step = 0; changed(); renderForm(); + notify("Example loaded as a draft. Replace example paths and hosts, use the bundled task inputs, then prepare your model.", true); + })); ["dry-run", "doctor", "train"].forEach((mode) => $("#" + mode).addEventListener("click", () => action(() => startJob(mode))), ); @@ -787,6 +932,7 @@ async function initialize() { ) return; state.answers = newAnswers(); + state.draft = null; state.issues = []; $("#config-name").value = `experiment-${state.bootstrap.configurations.length + 1}`; state.step = 0; diff --git a/harborrl/webui/static/index.html b/harborrl/webui/static/index.html index bc76f9f58..5a86fab22 100644 --- a/harborrl/webui/static/index.html +++ b/harborrl/webui/static/index.html @@ -60,6 +60,7 @@

    Training configuration

    +
    +
    +

    Start here

    Try the workflow on CPU, or open a filled deployment recipe to see what each field means.

    +
    + + + +
    +

    Hello World executes a scripted task and verifier, then exports a GRPO batch with fixture tokens. No model, GPU or task JSON is needed.

    +
    Training configuration autocomplete="off" />Draft
    +