mirror of
https://github.com/alphaXiv/OpenResearch.git
synced 2026-10-02 01:34:34 +08:00
OR-158 Decouple compute runs from GitHub
Ships direct source snapshots across compute backends, optional GitHub experiment publication, and CLI v0.1.98.
This commit is contained in:
Generated
+3
-1
@@ -989,7 +989,7 @@ checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
|
||||
|
||||
[[package]]
|
||||
name = "openresearch-cli"
|
||||
version = "0.1.97"
|
||||
version = "0.1.98"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -1006,7 +1006,9 @@ dependencies = [
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
"toml",
|
||||
"urlencoding",
|
||||
"uuid",
|
||||
|
||||
+3
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "openresearch-cli"
|
||||
version = "0.1.97"
|
||||
version = "0.1.98"
|
||||
edition = "2021"
|
||||
description = "OpenResearch CLI (orx) — Rust port"
|
||||
repository = "https://github.com/alphaXiv/openresearch-cli"
|
||||
@@ -12,6 +12,7 @@ path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "io-util", "io-std", "time", "fs", "process", "sync", "signal"] }
|
||||
tokio-util = { version = "0.7", features = ["io"] }
|
||||
# rustls instead of the default native-tls: avoids an OpenSSL dependency so the
|
||||
# static musl Linux builds link cleanly (native-tls/openssl won't build for musl).
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "charset", "http2", "rustls-tls", "stream"] }
|
||||
@@ -39,6 +40,7 @@ rust-embed = "8"
|
||||
futures = "0.3"
|
||||
# async fns in the Harness trait (object-safe `dyn Harness` in the registry).
|
||||
async-trait = "0.1"
|
||||
sha2 = "0.10"
|
||||
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
# Free-space pre-check for the data-dir move (statvfs). Already in the tree
|
||||
|
||||
@@ -55,16 +55,15 @@ JSON/SSE API over a local SQLite store. From there you get:
|
||||
- **The experiment tree** — every experiment is a git branch: a runnable
|
||||
snapshot of your code. The root is your baseline; children are variants
|
||||
measured against it, so lineage stays explicit.
|
||||
- **Runs** — projects and experiments stay local by default and run on this
|
||||
machine from their recorded Git commit. Enable GitHub syncing for a project
|
||||
when you want Modal, Hugging Face Jobs, Kubernetes, Slurm, SSH, Ray, or
|
||||
OpenResearch compute; remote launches push the exact branch first.
|
||||
- **Runs** — every backend receives the same immutable archive of the recorded
|
||||
Git commit. Modal, Hugging Face Jobs, Kubernetes, Slurm, SSH, Ray,
|
||||
OpenResearch, and local runs do not require a hosted repository or push.
|
||||
- **Autoresearch** — describe a goal and let the agent run autonomously toward
|
||||
it: proposing, launching, and analyzing experiments.
|
||||
|
||||
Everything binds to loopback only. Creating a local project does not call
|
||||
GitHub; public paper repositories and paper search use the network only when
|
||||
you choose those flows.
|
||||
Everything binds to loopback only. Creating a local project and launching
|
||||
compute do not publish code; upstream repositories and paper search use the
|
||||
network only when you choose those import flows.
|
||||
|
||||
### On a remote machine
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: orx-compute-k8s
|
||||
description: "Run an experiment on your own Kubernetes cluster (`orx exp run --backend k8s`): the committed-manifest contract orx enforces at submit. Use when the user names k8s, kubernetes, or a cluster, before writing or editing `.orx/k8s.yaml`, for multi-node or Indexed Jobs, or when a k8s submit is rejected."
|
||||
description: "Run an experiment on your own Kubernetes cluster (`orx exp run --backend k8s`): the single-pod committed-manifest contract orx enforces at submit. Use when the user names k8s, kubernetes, or a cluster, before writing or editing `.orx/k8s.yaml`, or when a k8s submit is rejected."
|
||||
---
|
||||
|
||||
**Use `--backend k8s` ONLY when the user explicitly asks to run on their
|
||||
@@ -12,8 +12,9 @@ come from the user's configured Kubernetes profile.
|
||||
**There are no flavors: the run's shape is a Kubernetes manifest you commit
|
||||
on the experiment branch** (default `.orx/k8s.yaml`, or `--manifest <path>`).
|
||||
Inspect the cluster yourself (`kubectl get nodes`, allocatable resources, GPU
|
||||
products) and write whatever the run needs — a single-pod GPU Job, an Indexed
|
||||
Job spanning nodes with a headless Service, an auxiliary inference Deployment.
|
||||
products) and write whatever the run needs around one primary pod — a GPU Job,
|
||||
PVCs, ConfigMaps, Services, or an auxiliary inference Deployment. Parallel and
|
||||
Indexed Jobs are rejected because the immutable archive is staged into one pod.
|
||||
The manifest inherits through the experiment tree like all code; changing it is
|
||||
a commit, visible in the diff like any experimental variable.
|
||||
|
||||
@@ -48,23 +49,18 @@ The contract orx enforces at submit (loud, before anything runs):
|
||||
(Services, Deployments, ConfigMaps) ride along; cancel deletes exactly what
|
||||
the manifest created.
|
||||
- **Some container of that Job must run `$ORX_SCRIPT`** — the env var orx
|
||||
injects with the clone-and-run script (branch tip + the experiment's fixed
|
||||
injects with the snapshot-and-run script (recorded revision + the experiment's fixed
|
||||
run command). Set `command: ["bash", "-c", "$ORX_SCRIPT"]`. The manifest
|
||||
shapes *where* the command runs, never *what* runs.
|
||||
- Every resource needs `metadata.name` (no `generateName`) and no foreign
|
||||
`metadata.namespace`. Use `{{ORX_RUN}}` in names — orx substitutes a
|
||||
run-unique token so re-runs don't collide.
|
||||
- orx injects run labels, the `orx-env` Secret (synced env + `HF_TOKEN` /
|
||||
`GITHUB_TOKEN`) on the primary Job's containers, and defaults for
|
||||
- orx injects run labels, the `orx-env` Secret (synced environment variables)
|
||||
on the primary Job's containers, and defaults for
|
||||
`activeDeadlineSeconds` (from `--timeout`, default 4h; a manifest-set value
|
||||
wins), `ttlSecondsAfterFinished`, and `backoffLimit: 0`. Auxiliary
|
||||
resources that need the env reference the `orx-env` Secret themselves.
|
||||
- The run log follows the primary Job's **leader pod** (completion index 0
|
||||
for Indexed Jobs, else its sole pod) — make it print the evidence; other
|
||||
pods stay reachable via `kubectl logs`. Cross-node traffic rides the pod
|
||||
network — fine for loosely-coupled work (async RL, parameter-server);
|
||||
tightly-coupled per-step all-reduce wants a fast fabric the cluster may not
|
||||
have.
|
||||
- The run log follows the primary Job's sole pod; make it print the evidence.
|
||||
- Everything downstream is identical (`orx exp wait` / `orx runs` /
|
||||
`orx logs`, cancel via `orx exp cancel`). A detached `orx supervise`
|
||||
watches the Job via kubectl; don't kill it.
|
||||
|
||||
@@ -3,11 +3,9 @@ name: orx-compute
|
||||
description: "Launch experiment runs with `orx exp run`: backends (hf, modal, k8s, ssh, slurm, ray, openresearch, local), flavors, timeouts, images, sizing, and `orx exp wait`. Use before launching or re-launching any run, when choosing or switching a backend or GPU flavor, when a job OOMs, stalls, or times out, or when deciding GPU vs CPU."
|
||||
---
|
||||
|
||||
Projects are local-only by default. In that state, commit the experiment and
|
||||
use the `local` backend; never push. Remote backends stay unavailable until the
|
||||
user explicitly enables GitHub syncing for the project. Once enabled,
|
||||
`orx exp run` pushes the selected branch before provisioning and aborts the
|
||||
launch if that push fails.
|
||||
Commit the experiment before launching. `orx` archives that exact revision and
|
||||
transfers the immutable source snapshot directly to the selected backend. No push,
|
||||
repository clone, GitHub token, or public repository is required.
|
||||
|
||||
In local mode (`orx up`) every run launches with `orx exp run <expId>` onto a
|
||||
**backend**: `hf`, `modal`, `k8s`, `ssh`, `slurm`, `ray`, `openresearch`, or `local`.
|
||||
@@ -30,9 +28,9 @@ orx exp cancel <expId> # cancel the in-flight run
|
||||
code/config on the child's branch instead (see the `orx-experiment-tree`
|
||||
skill). A node with no command refuses to launch and points at
|
||||
`orx project edit`.
|
||||
- **Push before launching.** Every backend clones the experiment branch's tip
|
||||
**as it is on GitHub** and runs the fixed command — uncommitted or unpushed
|
||||
edits won't be in the run (see the `orx-git` skill).
|
||||
- **Commit before launching.** Every backend runs the same content-addressed
|
||||
archive of the recorded local revision. Uncommitted edits are excluded; a
|
||||
push is unrelated to launching (see the `orx-git` skill).
|
||||
- **`orx exp run` queues the run and returns immediately** — it does not wait.
|
||||
Follow progress with `orx runs <projectId>` and `orx logs <runId>`, or block
|
||||
with `orx exp wait` (below).
|
||||
@@ -110,10 +108,10 @@ orx exp run <expId> --backend ssh --host my-gpu-box
|
||||
- **`--host <alias>` is required on every launch** — a machine, not a hardware
|
||||
shape, so there is no `--flavor`, `--image`, or `--timeout` (the process runs
|
||||
until it exits or is cancelled). Use one of the user's configured SSH host
|
||||
aliases; OpenResearch validates reachability and git separately.
|
||||
aliases; OpenResearch validates reachability before upload.
|
||||
- Auth is your own ssh keys/agent — orx never reads a key, it just shells out
|
||||
to `ssh <alias>`. The host needs `git` and `bash`; private repos clone via
|
||||
the `GITHUB_TOKEN` passed in the run's env. The run lives under
|
||||
to `ssh <alias>`. The host needs `tar` and `bash`; orx uploads the snapshot
|
||||
over SSH. The run lives under
|
||||
`~/.orx/runs/<runId>/` on the host; cancel kills the remote process group.
|
||||
|
||||
## Your Slurm cluster — `--backend slurm`
|
||||
@@ -134,9 +132,9 @@ orx exp run <expId> --backend slurm # CPU-only, settings defa
|
||||
|
||||
## A Ray Jobs cluster — `--backend ray`
|
||||
|
||||
Submits via the Ray Jobs / Dashboard API (same contract as Hugging Face Jobs:
|
||||
clone the experiment branch tip from GitHub and run the fixed command). Needs a
|
||||
reachable Ray head (Dashboard, usually port 8265).
|
||||
Submits via the Ray Jobs / Dashboard API. The snapshot is uploaded as Ray's
|
||||
`working_dir` package. Needs a reachable Ray head (Dashboard, usually port
|
||||
8265).
|
||||
|
||||
```sh
|
||||
orx exp run <expId> --backend ray
|
||||
@@ -192,7 +190,7 @@ orx exp run <expId> --backend local
|
||||
machine has. It shares CPU/RAM/GPU with OpenResearch and your editing tools:
|
||||
prefer it for small or CPU-scale runs and a remote backend for anything
|
||||
heavy.
|
||||
- Still the full run contract: it clones the branch tip into its own run dir
|
||||
- Still the full run contract: it extracts the recorded snapshot into its own run dir
|
||||
(never your checkout), supervised and tracked by OpenResearch — never run
|
||||
training directly in your shell instead. The run lives under
|
||||
`<orx data dir>/local-runs/<runId>/`; cancel TERMs the process group.
|
||||
|
||||
@@ -33,11 +33,11 @@ Rules and notes:
|
||||
touch a command is the baseline having none yet.
|
||||
- **Set a run command before launching.** `orx exp run` fails with a pointer to
|
||||
`orx exp cmd --set` if the node has none.
|
||||
- **Push your edits before launching.** A run trains the branch's tip **as it is
|
||||
on GitHub** — so commit and push first (see the `orx-git` skill). As a
|
||||
safety net, `orx exp run` refuses a child whose branch has **no changes over its
|
||||
parent** (the tell-tale of "queued before pushing") — push and retry, or pass
|
||||
`--force` to run the unchanged code deliberately.
|
||||
- **Commit your edits before launching.** Local projects run an immutable source
|
||||
snapshot of the committed branch and never need a GitHub push. Managed server
|
||||
projects still run their recorded remote revision. As a safety net, `orx exp
|
||||
run` refuses a child whose branch has no changes over its parent; commit a
|
||||
meaningful change and retry, or pass `--force` deliberately.
|
||||
- **Pick compute with exactly one of `--gpu`, `--cpu`, or `--sandbox`.** With
|
||||
`--gpu`, `--count` defaults to `1` and `--disk` to `100` (GB). A new GPU
|
||||
instance defaults to **RunPod** (the cheapest matching RunPod offer for the
|
||||
@@ -59,10 +59,10 @@ Rules and notes:
|
||||
|
||||
## The default compute target (local projects)
|
||||
|
||||
A local-only project always launches on this machine with the `local` backend.
|
||||
After GitHub syncing is enabled, local projects can also use remote backends;
|
||||
the user may configure a **default compute target** that is machine-wide and
|
||||
shared by those projects. When one is set, `orx exp run <expId>` with no
|
||||
A local project can launch on this machine or transfer its immutable source
|
||||
snapshot directly to a remote backend. The user may configure a **default
|
||||
compute target** that is machine-wide and shared by local projects. When one is
|
||||
set, `orx exp run <expId>` with no
|
||||
`--backend` launches there with the saved default flavor — omitting the flag is
|
||||
how you use it (flavor-required backends still need `--flavor` if no default
|
||||
flavor is saved). When none is set, ask the user to choose an explicit backend.
|
||||
@@ -71,8 +71,9 @@ stays their default.
|
||||
|
||||
## Running on Hugging Face Jobs — `--backend hf`
|
||||
|
||||
**Managed compute (`--gpu`/`--cpu`/`--sandbox`) is the default. Use
|
||||
`--backend hf` ONLY when the user explicitly asks for Hugging Face Jobs**
|
||||
**Managed compute (`--gpu`/`--cpu`/`--sandbox`) is the default for server
|
||||
projects. `--backend hf` requires a local project (`orx up`); use it ONLY when
|
||||
the user explicitly asks for Hugging Face Jobs**
|
||||
(e.g. "run this on HF", "use my huggingface account"), it is the configured
|
||||
default target, or the project context says to
|
||||
prefer it. A connected HF token by itself is NOT a signal to switch — it just
|
||||
@@ -97,12 +98,9 @@ Rules and notes:
|
||||
as managed GPUs.
|
||||
- **Set `--timeout` to cover the whole run** (default `4h`). HF kills the job
|
||||
at the timeout; a killed job reads as a failed run.
|
||||
- The job clones the experiment branch's **GitHub tip** and runs the fixed run
|
||||
command, same contract as managed runs — commit and push first. Private
|
||||
repos work automatically: the platform mints a repo-scoped clone token from
|
||||
the project's connected GitHub app and passes it to the job as a secret.
|
||||
Never ask the user to provision a `GITHUB_TOKEN`; setting one (env or
|
||||
project env var) is only an override for repos outside the connected app.
|
||||
- For a local project, `orx` uploads the committed source snapshot into a
|
||||
private job volume before starting the fixed run command. The job never
|
||||
clones a repository and does not need repository credentials.
|
||||
- `--image` overrides the container (default: a CUDA pytorch image on GPU
|
||||
flavors, `python:3.12` on cpu flavors). Pick an image with your deps baked
|
||||
in when pip-install time dominates the run.
|
||||
@@ -113,10 +111,10 @@ Rules and notes:
|
||||
|
||||
## Running on Modal — `--backend modal`
|
||||
|
||||
**Same rule as HF: managed compute is the default. Use `--backend modal` ONLY
|
||||
when the user explicitly asks for Modal** ("run this on Modal", "use my Modal
|
||||
account") or it is the configured default target. Modal runs on the user's
|
||||
own Modal account, billed there per second; no OpenResearch balance is spent.
|
||||
**Same rule as HF: `--backend modal` requires a local project. Use it ONLY when
|
||||
the user explicitly asks for Modal** ("run this on Modal", "use my Modal
|
||||
account") or it is the configured default target. Modal runs on the user's own
|
||||
Modal account, billed there per second; no OpenResearch balance is spent.
|
||||
It runs the job in a Modal **Sandbox** (an ephemeral container that scales to
|
||||
zero when the run ends).
|
||||
|
||||
@@ -139,9 +137,8 @@ Rules and notes:
|
||||
Prefer the smallest flavor that fits.
|
||||
- **Set `--timeout` to cover the whole run** (default `4h`). Modal kills the
|
||||
sandbox at the timeout; a killed sandbox reads as a failed run.
|
||||
- Same clone contract as HF/managed: the sandbox clones the experiment branch's
|
||||
**GitHub tip** and runs the fixed command — commit and push first. Private
|
||||
repos work automatically via the platform's repo-scoped clone token.
|
||||
- `orx` copies the committed source snapshot into the sandbox before starting
|
||||
the fixed command; Modal never needs repository access.
|
||||
- `--image` overrides the container (default: a CUDA pytorch image on GPU
|
||||
flavors, `python:3.12` on cpu). Pick one with your deps baked in when
|
||||
pip-install time dominates.
|
||||
@@ -151,9 +148,10 @@ Rules and notes:
|
||||
|
||||
## Running on your Kubernetes cluster — `--backend k8s`
|
||||
|
||||
Runs the experiment on your own Kubernetes cluster from a manifest committed on
|
||||
the experiment branch. The full manifest contract lives in the `orx-compute-k8s`
|
||||
skill — fetch it (`orx skill compute-k8s`) before your first k8s launch.
|
||||
Requires a local project (`orx up`). Runs the experiment on your own Kubernetes
|
||||
cluster from a manifest committed on the experiment branch. The full manifest
|
||||
contract lives in the `orx-compute-k8s` skill — fetch it (`orx skill compute-k8s`)
|
||||
before your first k8s launch.
|
||||
|
||||
## Running on your own box — `--backend ssh`
|
||||
|
||||
@@ -171,11 +169,11 @@ orx exp run <expId> --backend ssh --host my-gpu-box # ~/.ssh/config alias
|
||||
Rules and notes:
|
||||
- **`--host` is the ssh host alias** (from `~/.ssh/config`) — a machine, not a
|
||||
hardware shape, so there is no `--flavor` here. Use one of the user's
|
||||
configured aliases; OpenResearch validates reachability and git separately.
|
||||
configured aliases; OpenResearch validates reachability and required tools.
|
||||
- Auth is your ssh keys/agent — orx never reads a key, it just shells out to
|
||||
`ssh <alias>`. The host needs `git` and `bash`; it clones the experiment
|
||||
branch's GitHub tip (private repos via the `GITHUB_TOKEN` passed in the run's
|
||||
env) and runs the fixed command. Commit and push first, same as the others.
|
||||
`ssh <alias>`. The host needs `bash` and `tar`; orx streams the committed
|
||||
source snapshot over SSH, extracts it into the run directory, and starts the
|
||||
fixed command.
|
||||
- No `--image` (the host's environment is used as-is) and no `--timeout` (the
|
||||
process runs until it exits or you cancel).
|
||||
- The run lives under `~/.orx/runs/<runId>/` on the host (`run.sh`, `log`,
|
||||
@@ -204,9 +202,9 @@ Rules and notes:
|
||||
environment (modules, conda, whatever the login profile provides).
|
||||
- `--timeout` (default `4h`) applies — size it to cover the whole run; a job
|
||||
killed at the timeout reads as a failed run.
|
||||
- Same clone contract as every backend: the job clones the experiment branch's
|
||||
GitHub tip and runs the fixed command — commit and push first. Everything
|
||||
downstream (`orx exp wait` / `orx runs` / `orx logs` / `orx exp cancel`) is
|
||||
- `orx` streams the committed source snapshot to the login node before `sbatch`
|
||||
starts the fixed command. Everything downstream (`orx exp wait` / `orx runs`
|
||||
/ `orx logs` / `orx exp cancel`) is
|
||||
identical; a detached `orx supervise` mirrors status and logs — don't kill it.
|
||||
|
||||
## Running on a Ray Jobs cluster — `--backend ray`
|
||||
@@ -233,7 +231,8 @@ Rules and notes:
|
||||
that avoids Pending on small heads.
|
||||
- No `--image`, `--host`, or `--timeout` — the job runs in the cluster's
|
||||
runtime env until it finishes; size and bound work in the run command itself.
|
||||
- Same clone contract and downstream commands as every backend; a detached
|
||||
- Ray receives the committed snapshot as its `working_dir` package; downstream
|
||||
commands stay the same, and a detached
|
||||
`orx supervise` mirrors status and logs — don't kill it.
|
||||
|
||||
## Running on an OpenResearch box — `--backend openresearch`
|
||||
@@ -258,7 +257,8 @@ Rules and notes:
|
||||
`--provider <P>`. No `--image` — the platform's image is fixed.
|
||||
- `--timeout` (default `4h`) applies — the box is deleted when the run ends
|
||||
either way, so nothing persists on it; everything you need must be in the log.
|
||||
- Same clone contract and downstream commands as every backend; a detached
|
||||
- `orx` streams the committed source snapshot to the provisioned box;
|
||||
downstream commands stay the same, and a detached
|
||||
`orx supervise` mirrors status and logs — don't kill it.
|
||||
|
||||
## Running on this machine — `--backend local`
|
||||
@@ -279,9 +279,8 @@ Rules and notes:
|
||||
this machine has; prefer it for small or CPU-scale runs and use a remote
|
||||
backend for anything heavy — it shares CPU/RAM/GPU with everything else on
|
||||
the machine.
|
||||
- Same clone contract as every backend: the run clones the experiment
|
||||
branch's GitHub tip into its own run dir (never your checkout) and runs the
|
||||
fixed command — commit and push first. Never run training directly in your
|
||||
- The run extracts the committed source snapshot into its own run dir (never
|
||||
your checkout) and runs the fixed command. Never run training directly in your
|
||||
shell instead: that would be unsupervised and untracked by OpenResearch.
|
||||
- The run lives under `<orx data dir>/local-runs/<runId>/` (`run.sh`, `log`,
|
||||
`pid`, `exit_code`). Cancellation through OpenResearch or `orx exp cancel` TERMs the
|
||||
|
||||
@@ -85,7 +85,7 @@ To drive a project toward a goal (e.g. "best convergence for d=8"), this is the
|
||||
intended flow — do **not** edit a frozen node or rewrite the run command:
|
||||
|
||||
1. **Read the baseline's code.** You already sit in a private git worktree of the
|
||||
project's repo — `git fetch origin && git checkout <branch>` and read it with
|
||||
project's repo — `git checkout <branch>` and read it with
|
||||
your normal tools (see the `orx-git` skill). See the node's run command with
|
||||
`orx exp status <expId>` and find where the knobs live (config files,
|
||||
hyperparameters, model defs).
|
||||
@@ -117,10 +117,9 @@ intended flow — do **not** edit a frozen node or rewrite the run command:
|
||||
4. **Implement each child's change on its git branch** — `orx create-experiment`
|
||||
prints the child's branch (`orx/<slug>`); in your worktree:
|
||||
```sh
|
||||
git fetch origin && git checkout orx/<child-slug>
|
||||
git merge --ff-only origin/orx/<child-slug>
|
||||
git checkout orx/<child-slug>
|
||||
# …edit only the files that idea touches…
|
||||
git commit -am "cosine LR + warmup" && git push
|
||||
git commit -am "cosine LR + warmup"
|
||||
```
|
||||
**Leave the run command alone.** While you're in the code, **make the run
|
||||
print the evidence you'll need to judge it** — final metrics, a compact
|
||||
|
||||
@@ -114,17 +114,13 @@ the run command:
|
||||
The child inherits its parent's run command automatically — you don't set it,
|
||||
and you never give siblings different commands or env vars (cardinal rule 2).
|
||||
4. **Implement each child's change on its git branch** — `orx create-experiment`
|
||||
prints the child's branch (`orx/<slug>`); sync the project's clone (in the
|
||||
openresearch cache dir — see the `orx-git` skill), check the branch out,
|
||||
edit only the files that idea touches, commit, and push. **Leave the run
|
||||
prints the child's branch (`orx/<slug>`); check the branch out in the
|
||||
project worktree, edit only the files that idea touches, and commit. **Leave the run
|
||||
command alone:**
|
||||
```sh
|
||||
DIR=~/.cache/openresearch/repos/<owner>/<repo> # owner/repo from `orx projects`
|
||||
[ -d "$DIR" ] || git clone https://github.com/<owner>/<repo> "$DIR"
|
||||
git -C "$DIR" fetch origin && git -C "$DIR" checkout orx/<child-slug>
|
||||
git -C "$DIR" merge --ff-only origin/orx/<child-slug>
|
||||
# …edit config.yaml under "$DIR": schedule: constant → cosine …
|
||||
git -C "$DIR" commit -am "cosine LR + warmup" && git -C "$DIR" push
|
||||
git checkout orx/<child-slug>
|
||||
# …edit config.yaml: schedule: constant → cosine …
|
||||
git commit -am "cosine LR + warmup"
|
||||
```
|
||||
While you're in the code, **make the run emit the evidence you'll need to judge
|
||||
it.** Have it write rollout transcripts, per-sample eval breakdowns, generated
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
---
|
||||
name: orx-git
|
||||
description: "Read, edit, commit, and diff experiment code with local Git. Use whenever you touch an experiment branch, compare nodes, prepare a run, or diagnose stale code."
|
||||
---
|
||||
|
||||
Git records every experiment locally. GitHub publication may be enabled for
|
||||
collaborator visibility, but it is never part of compute transport. Follow the
|
||||
project playbook's publication status; do not push merely to launch compute and
|
||||
do not fetch from or publish back to the paper's upstream repository.
|
||||
|
||||
Each experiment node has a local `orx/<slug>` branch. `orx
|
||||
create-experiment` creates it from its parent. Work in the session worktree,
|
||||
check out the printed branch, make only that experiment's change, and commit it:
|
||||
|
||||
```sh
|
||||
git checkout orx/<slug>
|
||||
git status --short
|
||||
git add <changed files>
|
||||
git commit -m "describe the experiment change"
|
||||
```
|
||||
|
||||
The runner builds an immutable source archive from the recorded commit, so
|
||||
committed work is sufficient on every backend. Uncommitted files are never included in a
|
||||
run. Before launching, confirm `git status --short` is empty and inspect the
|
||||
recorded commit with `git show --stat --oneline HEAD`.
|
||||
|
||||
To compare a child with its parent, use local refs only:
|
||||
|
||||
```sh
|
||||
git diff <parent-branch>...orx/<child-slug>
|
||||
git log --oneline <parent-branch>..orx/<child-slug>
|
||||
```
|
||||
|
||||
Once a run answers an experiment, treat that branch as immutable and create a
|
||||
child for the next change.
|
||||
@@ -1,20 +1,18 @@
|
||||
---
|
||||
name: orx-git
|
||||
description: "Read, edit, and diff a node's code with plain git: sync, commit, and push before running. Use whenever you touch experiment code — before editing any branch, when a checkout or push fails, when comparing two nodes' code, or when a run seems to have picked up stale code."
|
||||
description: "Read, edit, commit, and diff experiment code with Git. Use whenever you touch a branch, compare nodes, prepare a run, diagnose stale code, or publish changes."
|
||||
---
|
||||
|
||||
Every experiment node **is a git branch** (`orx/<slug>`) on the project's GitHub
|
||||
repo — `orx create-experiment` prints it. There is no dev box and no `orx` code
|
||||
command: the **local clone in the cache dir is the standard way to interface
|
||||
with code** — reading a node's files, diffing what a run changed, and editing —
|
||||
all with plain git and your own tools.
|
||||
Every experiment node is a git branch (`orx/<slug>`), but source transport
|
||||
depends on the project type:
|
||||
|
||||
(In a local `orx up` session you already sit in a private git worktree of the
|
||||
project repo, so you can edit the checked-out branch in place — `git fetch origin
|
||||
&& git checkout <branch>`, edit, commit, push. The cache-dir clone below is the
|
||||
flow for everything outside a live session, and for cloud/full-set contexts.)
|
||||
- For a local project, work in its session worktree, check out the branch, edit,
|
||||
and commit. Every compute backend receives an immutable snapshot of that local
|
||||
commit. A GitHub push is optional publication and is never required to run.
|
||||
- For a managed server project, GitHub remains the source of record. Use the
|
||||
cache-dir clone flow below and push the branch before running.
|
||||
|
||||
**Clone into the openresearch cache dir, not your cwd.** The canonical location,
|
||||
**For managed server projects, clone into the openresearch cache dir, not your cwd.** The canonical location,
|
||||
keyed by repo so the same clone is reused across all of a project's experiments:
|
||||
|
||||
```
|
||||
@@ -25,7 +23,7 @@ keyed by repo so the same clone is reused across all of a project's experiments:
|
||||
directory or the user's project folders — clones accreting in `~/projects` is the
|
||||
failure mode this avoids.
|
||||
|
||||
This is how you **realize a child's hypothesis**: after `create-experiment
|
||||
For a managed server project, this is how you **realize a child's hypothesis**: after `create-experiment
|
||||
--parent`, check out the child's branch and make the specific code/config edits
|
||||
its description calls for — then commit, push, and run. Edit only the files that
|
||||
idea touches, and **don't touch the run command** (it's inherited; see the
|
||||
@@ -63,9 +61,9 @@ Rules and notes:
|
||||
already has — the repo lives under your account or your org, so access is the
|
||||
same as any of your repos. If a clone or push fails on auth, authenticate git
|
||||
for github.com (e.g. `gh auth login` or an SSH key) and retry.
|
||||
- **Push before you run.** `orx exp run` launches from the branch's pushed tip on
|
||||
GitHub — uncommitted or unpushed edits won't be in the run. Commit and push
|
||||
first.
|
||||
- **Push before managed server runs.** Local projects run committed snapshots
|
||||
directly and do not require a push. Managed server projects launch from the
|
||||
pushed GitHub tip, so commit and push those branches first.
|
||||
- **Never merge or rebase a branch once its node is frozen** (cardinal rule):
|
||||
its history is the code those results came from. Bring changes in on a
|
||||
**child** instead. On a *provisional* node a plain `git merge
|
||||
|
||||
+2
-44
@@ -10,8 +10,8 @@
|
||||
//!
|
||||
//! This module is now thin: it parses args and resolves the id to a
|
||||
//! `ControlPlane`, then calls one verb. The per-plane bodies live in
|
||||
//! `crate::plane::{server_plane, local_plane}`. Only the three job-launch helpers
|
||||
//! (`hf_clone_script` / `default_hf_image` / `spawn_detached_supervise`) stay
|
||||
//! `crate::plane::{server_plane, local_plane}`. Only the two job-launch helpers
|
||||
//! (`default_hf_image` / `spawn_detached_supervise`) stay
|
||||
//! here — every `src/local/*` backend imports them as `crate::commands::exp::*`.
|
||||
|
||||
use std::time::{Duration, Instant};
|
||||
@@ -98,34 +98,6 @@ async fn wait(
|
||||
|
||||
// --- job-launch helpers shared with the src/local/* backends -----------------
|
||||
|
||||
/// Fetch one recorded commit and run the fixed command. GITHUB_TOKEN
|
||||
/// (passed as a job secret when present locally) authenticates private repos
|
||||
/// through an ephemeral credential helper; the URL and git config stay tokenless.
|
||||
pub(crate) fn hf_clone_script(git_ref: &str, owner: &str, repo: &str, cmd: &str) -> String {
|
||||
let url = shell_quote(&format!("https://github.com/{owner}/{repo}.git"));
|
||||
format!(
|
||||
"set -eo pipefail; command -v git >/dev/null 2>&1 || (apt-get update -qq && apt-get install -y -qq git); \
|
||||
git init -q repo; cd repo; \
|
||||
git remote add origin {url}; \
|
||||
git -c credential.helper= -c credential.helper='!f() {{ echo username=x-access-token; echo \"password=$GITHUB_TOKEN\"; }}; f' \
|
||||
fetch --depth 1 origin {git_ref}; git checkout --detach FETCH_HEAD; {cmd}",
|
||||
git_ref = shell_quote(git_ref),
|
||||
)
|
||||
}
|
||||
|
||||
fn shell_quote(value: &str) -> String {
|
||||
format!("'{}'", value.replace('\'', "'\"'\"'"))
|
||||
}
|
||||
|
||||
pub(crate) fn local_clone_script(repo_path: &str, commit_sha: &str, cmd: &str) -> String {
|
||||
format!(
|
||||
"set -eo pipefail; git clone --no-checkout --local {} repo; cd repo; git checkout --detach {}; {}",
|
||||
shell_quote(repo_path),
|
||||
shell_quote(commit_sha),
|
||||
cmd
|
||||
)
|
||||
}
|
||||
|
||||
/// Default docker image per flavor family: plain python for CPU flavors, a
|
||||
/// CUDA-ready pytorch image for GPU flavors. Override with --image.
|
||||
pub(crate) fn default_hf_image(flavor: &str) -> String {
|
||||
@@ -309,17 +281,3 @@ mod tests {
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod clone_script_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn remote_clone_fetches_recorded_commit_detached() {
|
||||
let script = hf_clone_script("abc123", "owner", "repo", "python run.py");
|
||||
assert!(script.contains("fetch --depth 1 origin 'abc123'"));
|
||||
assert!(script.contains("checkout --detach FETCH_HEAD"));
|
||||
assert!(!script.contains("--branch"));
|
||||
assert!(!script.contains("x-access-token:${GITHUB_TOKEN}@"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,9 +47,6 @@ pub async fn run(args: crate::ProjectsArgs) -> Result<()> {
|
||||
"paperId": p.paper_id,
|
||||
"path": p.repo_path,
|
||||
"baselineBranch": p.baseline_branch,
|
||||
"repo": p.has_github_repository().then(|| format!("{}/{}", p.github_owner, p.github_repo)),
|
||||
"githubEnabled": p.github_enabled(),
|
||||
"githubUrl": p.github_url(),
|
||||
"archived": false,
|
||||
"orgId": serde_json::Value::Null,
|
||||
"orgName": "Local (orx up)",
|
||||
@@ -92,19 +89,13 @@ pub async fn run(args: crate::ProjectsArgs) -> Result<()> {
|
||||
.unwrap_or(0);
|
||||
for p in &local {
|
||||
let pad = id_width.saturating_sub(p.id.chars().count());
|
||||
let publication = if p.github_enabled() {
|
||||
format!(" · GitHub {}/{}", p.github_owner, p.github_repo)
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
println!(
|
||||
" {}{} {} (local) {} · baseline {}{}",
|
||||
" {}{} {} (local) {} · baseline {}",
|
||||
p.id,
|
||||
" ".repeat(pad),
|
||||
p.name,
|
||||
p.repo_path,
|
||||
p.baseline_branch,
|
||||
publication,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+4
-58
@@ -1,5 +1,5 @@
|
||||
use crate::config;
|
||||
use crate::error::{anyhow, require_credentials, Result};
|
||||
use crate::error::{require_credentials, Result};
|
||||
use crate::local::agent_skills::{self, SkillSet};
|
||||
|
||||
// Bundled top-level overview, shipped with the CLI so `orx skill` works without
|
||||
@@ -17,51 +17,14 @@ fn current_skill_set() -> SkillSet {
|
||||
}
|
||||
}
|
||||
|
||||
fn local_project_publication() -> Option<bool> {
|
||||
if !crate::local::chat::in_local_session() {
|
||||
return None;
|
||||
}
|
||||
let enabled = (|| {
|
||||
let session_id = crate::local::chat::launching_chat_session()?;
|
||||
let store = crate::store::Store::open().ok()?;
|
||||
let session = store.get_chat_session(&session_id).ok().flatten()?;
|
||||
store
|
||||
.get_local_project(&session.project_id)
|
||||
.ok()
|
||||
.flatten()
|
||||
.map(|project| project.github_enabled())
|
||||
})()
|
||||
.unwrap_or(false);
|
||||
Some(enabled)
|
||||
}
|
||||
|
||||
pub async fn run(args: crate::SkillArgs) -> Result<()> {
|
||||
let publication = local_project_publication();
|
||||
if let Some(path) = args.path {
|
||||
// First: a bundled module (with or without the `orx-` prefix). These
|
||||
// ship in the binary, so they resolve offline and never drift.
|
||||
if let Some(skill) = agent_skills::find(&path, current_skill_set()) {
|
||||
if let Some(github_enabled) = publication {
|
||||
if !agent_skills::available_in_session(skill, github_enabled) {
|
||||
return Err(anyhow!(
|
||||
"{} requires GitHub. Enable GitHub syncing for this project first.",
|
||||
skill.name
|
||||
));
|
||||
}
|
||||
println!(
|
||||
"{}",
|
||||
agent_skills::session_content(skill, github_enabled).trim_end()
|
||||
);
|
||||
} else {
|
||||
println!("{}", skill.content.trim_end());
|
||||
}
|
||||
println!("{}", skill.content.trim_end());
|
||||
return Ok(());
|
||||
}
|
||||
if publication == Some(false) {
|
||||
return Err(anyhow!(
|
||||
"Only bundled local-safe skills are available while this project is local-only. Enable GitHub syncing for this project before loading remote references."
|
||||
));
|
||||
}
|
||||
// Otherwise fetch the canonical doc from the API (same docs the assistant
|
||||
// reads), so the schema never drifts from a hand-maintained copy.
|
||||
let creds = require_credentials().await;
|
||||
@@ -72,28 +35,11 @@ pub async fn run(args: crate::SkillArgs) -> Result<()> {
|
||||
|
||||
// No path: print the bundled overview, then the bundled module index, then
|
||||
// list API-fetchable deep references (best effort — skip if unreachable).
|
||||
if publication == Some(false) {
|
||||
println!(
|
||||
"OpenResearch local-only skills. Commit experiment branches locally and run them on this machine. GitHub and external compute remain unavailable until the user enables GitHub syncing for this project."
|
||||
);
|
||||
} else {
|
||||
println!("{}", SKILL_MD);
|
||||
}
|
||||
println!("{}", SKILL_MD);
|
||||
|
||||
println!("\nBundled modules (orx skill <name>):");
|
||||
for s in agent_skills::skills(current_skill_set()) {
|
||||
let github_enabled = publication.unwrap_or(true);
|
||||
if agent_skills::available_in_session(s, github_enabled) {
|
||||
println!(
|
||||
" {:<20} {}",
|
||||
s.name,
|
||||
agent_skills::session_description(s, github_enabled)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if publication == Some(false) {
|
||||
return Ok(());
|
||||
println!(" {:<20} {}", s.name, s.description);
|
||||
}
|
||||
|
||||
let creds = match config::load_credentials().await? {
|
||||
|
||||
+37
-33
@@ -70,7 +70,25 @@ pub async fn run(args: crate::SuperviseArgs) -> Result<()> {
|
||||
} else {
|
||||
Some(require_credentials().await)
|
||||
};
|
||||
let descriptor = BackendDescriptor::parse(&stored.backend_json)?;
|
||||
let mut descriptor = BackendDescriptor::parse(&stored.backend_json)?;
|
||||
if descriptor.job_id.is_none() {
|
||||
if let Some(recovered) = crate::compute::recover_submission_handle(&run_id)? {
|
||||
store.set_backend_json(&run_id, &recovered.to_json())?;
|
||||
descriptor = recovered;
|
||||
}
|
||||
}
|
||||
if descriptor.job_id.is_none() {
|
||||
store.update_status(&run_id, "failed", Some(now_ms()), None)?;
|
||||
store.set_result_markdown(
|
||||
&run_id,
|
||||
&format!(
|
||||
"Submission was interrupted before the {} provider handle was recorded. \
|
||||
Inspect the provider for resources labelled or_run={run_id} before retrying.",
|
||||
descriptor.kind.trim_end_matches("_job")
|
||||
),
|
||||
)?;
|
||||
return Ok(());
|
||||
}
|
||||
if descriptor.kind == "k8s_job" {
|
||||
return run_k8s(store, stored, descriptor, creds, run_id).await;
|
||||
}
|
||||
@@ -980,35 +998,19 @@ async fn run_openresearch(
|
||||
.await
|
||||
.unwrap_or(false);
|
||||
if !already_launched {
|
||||
// The payload is re-derivable from the store + config, so a restart
|
||||
// that died before launching can rebuild it exactly.
|
||||
let Some(exp) = store.get_local_experiment(&stored.experiment_id)? else {
|
||||
store.update_status(&run_id, "failed", Some(now_ms()), None)?;
|
||||
store.set_result_markdown(
|
||||
&run_id,
|
||||
"Local experiment vanished from the store before launch.",
|
||||
)?;
|
||||
teardown_box(&store, &lifecycle, &sandbox_id, &run_id).await;
|
||||
return Ok(());
|
||||
let source = match crate::compute::SourceSnapshot::from_run(&stored, &descriptor) {
|
||||
Ok(source) => source,
|
||||
Err(error) => {
|
||||
store.update_status(&run_id, "failed", Some(now_ms()), None)?;
|
||||
store.set_result_markdown(
|
||||
&run_id,
|
||||
&format!("The recorded source snapshot could not be loaded: {error}"),
|
||||
)?;
|
||||
teardown_box(&store, &lifecycle, &sandbox_id, &run_id).await;
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
let Some(project) = store.get_local_project(&exp.project_id)? else {
|
||||
store.update_status(&run_id, "failed", Some(now_ms()), None)?;
|
||||
store.set_result_markdown(
|
||||
&run_id,
|
||||
"Local project vanished from the store before launch.",
|
||||
)?;
|
||||
teardown_box(&store, &lifecycle, &sandbox_id, &run_id).await;
|
||||
return Ok(());
|
||||
};
|
||||
let script = crate::commands::exp::hf_clone_script(
|
||||
stored
|
||||
.commit_sha
|
||||
.as_deref()
|
||||
.ok_or_else(|| anyhow!("Remote run is missing its recorded commit SHA."))?,
|
||||
&project.github_owner,
|
||||
&project.github_repo,
|
||||
&stored.command,
|
||||
);
|
||||
let script = crate::compute::staged_script(&stored.command);
|
||||
let script =
|
||||
openresearch::wrap_with_timeout(&script, descriptor.timeout_secs.unwrap_or(4 * 3600));
|
||||
let mut env: std::collections::HashMap<String, String> =
|
||||
@@ -1016,10 +1018,6 @@ async fn run_openresearch(
|
||||
if let Ok(hf_token) = hf::resolve_token() {
|
||||
env.entry("HF_TOKEN".to_string()).or_insert(hf_token);
|
||||
}
|
||||
if let Some(gh) = crate::local::git::resolve_github_token() {
|
||||
env.insert("GITHUB_TOKEN".to_string(), gh);
|
||||
}
|
||||
|
||||
// sshd and the org key sync can lag a freshly-online box, so the
|
||||
// launch retries for ~2 minutes before giving up.
|
||||
let mut launch_err = None;
|
||||
@@ -1033,6 +1031,12 @@ async fn run_openresearch(
|
||||
teardown_box(&store, &lifecycle, &sandbox_id, &run_id).await;
|
||||
return Ok(());
|
||||
}
|
||||
let staged = ssh::stage_source(&target, &run_id, &source.path, &source.digest).await;
|
||||
if let Err(err) = staged {
|
||||
eprintln!("supervise {run_id}: source staging failed (will retry): {err}");
|
||||
launch_err = Some(err);
|
||||
continue;
|
||||
}
|
||||
match ssh::run_job(&ssh::SshJobSpec {
|
||||
target: target.clone(),
|
||||
run_id: run_id.clone(),
|
||||
|
||||
+255
-189
@@ -274,13 +274,23 @@ fn router(state: AppState) -> Router {
|
||||
post(disable_project_github),
|
||||
)
|
||||
.route("/api/projects/{id}/github/push", post(push_project_github))
|
||||
.route("/api/github/account", get(github_account))
|
||||
.route(
|
||||
"/api/github/project-repo-preview",
|
||||
get(github_project_repo_preview),
|
||||
)
|
||||
.route("/api/github/repo-access", get(github_repo_access))
|
||||
.route("/api/projects/{id}/experiments", get(list_experiments))
|
||||
.route("/api/projects/{id}/runs", get(list_project_runs))
|
||||
.route("/api/papers/search", get(search_papers_api))
|
||||
.route("/api/papers/resolve", get(resolve_paper_api))
|
||||
.route("/api/compute/backends", get(compute_backends))
|
||||
.route("/api/runs", post(create_run))
|
||||
.route("/api/runs/{id}", get(get_run))
|
||||
.route("/api/instances", get(list_instances))
|
||||
.route("/api/runs/{id}/cancel", post(cancel_run))
|
||||
.route("/api/runs/{id}/log", get(run_log))
|
||||
.route("/api/runs/{id}/logs", get(run_logs))
|
||||
.route("/api/runs/{id}/diff", get(run_diff))
|
||||
.route("/api/experiments/{id}/diff", get(experiment_diff))
|
||||
.route("/api/experiments/{id}/commits", get(experiment_commits))
|
||||
@@ -315,8 +325,6 @@ fn router(state: AppState) -> Router {
|
||||
)
|
||||
.route("/api/settings/data-dir/validate", post(validate_data_dir))
|
||||
.route("/api/settings/data-dir/move", post(move_data_dir))
|
||||
.route("/api/github/repo-access", get(github_repo_access))
|
||||
.route("/api/github/account", get(github_account))
|
||||
.route(
|
||||
"/api/settings/git",
|
||||
get(git_settings).post(set_git_settings),
|
||||
@@ -827,6 +835,9 @@ async fn project_path_status(Query(q): Query<ProjectPathStatusQ>) -> ApiResult {
|
||||
};
|
||||
let initialized =
|
||||
git_version.is_some() && directory && local::git::is_repository(&resolved);
|
||||
let github_publication = initialized
|
||||
.then(|| local::git::github_publication(&resolved))
|
||||
.flatten();
|
||||
Ok(Json(json!({
|
||||
"gitVersion": git_version,
|
||||
"resolvedPath": resolved.to_string_lossy(),
|
||||
@@ -834,6 +845,8 @@ async fn project_path_status(Query(q): Query<ProjectPathStatusQ>) -> ApiResult {
|
||||
"directory": directory,
|
||||
"empty": empty,
|
||||
"initialized": initialized,
|
||||
"githubOwner": github_publication.as_ref().map(|(owner, _)| owner),
|
||||
"githubRepo": github_publication.as_ref().map(|(_, repo)| repo),
|
||||
})))
|
||||
})
|
||||
.await
|
||||
@@ -897,6 +910,7 @@ struct CreateProjectReq {
|
||||
create_folder: bool,
|
||||
#[serde(default)]
|
||||
initialize_git: bool,
|
||||
github_sync_enabled: Option<bool>,
|
||||
}
|
||||
|
||||
async fn create_project(
|
||||
@@ -916,9 +930,22 @@ async fn create_project(
|
||||
let path = req.path;
|
||||
let create_folder = req.create_folder;
|
||||
let initialize_git = req.initialize_git;
|
||||
let clone_url = req.clone_url;
|
||||
let clone_url = req.clone_url.filter(|url| !url.trim().is_empty());
|
||||
let paper_id = req.paper_id.filter(|paper_id| !paper_id.trim().is_empty());
|
||||
if paper_id.is_some() && clone_url.is_none() {
|
||||
return Err(bad_request(
|
||||
"A paper project requires a linked public code repository.",
|
||||
));
|
||||
}
|
||||
let github_sync_enabled = req
|
||||
.github_sync_enabled
|
||||
.unwrap_or_else(crate::config::github_for_new_projects);
|
||||
let repo_size_kb = match clone_url.as_deref() {
|
||||
Some(url) => local::github::public_repo_size_kb(url).await,
|
||||
None => None,
|
||||
};
|
||||
let shallow_clone = local::github::should_shallow_clone(repo_size_kb);
|
||||
let run_command = req.run_command;
|
||||
let paper_id = req.paper_id.filter(|p| !p.trim().is_empty());
|
||||
let result = tokio::task::spawn_blocking(move || {
|
||||
let store = Store::open()?;
|
||||
local::projects::create_project(
|
||||
@@ -929,6 +956,7 @@ async fn create_project(
|
||||
create_folder,
|
||||
initialize_git,
|
||||
clone_url,
|
||||
shallow_clone,
|
||||
run_command,
|
||||
paper_id,
|
||||
},
|
||||
@@ -942,7 +970,19 @@ async fn create_project(
|
||||
.admit(&project.id)
|
||||
.ok_or_else(|| bad_request("project deletion is in progress"))?;
|
||||
drop(create_admission);
|
||||
let (project, github_publication_error) = publish_project_by_default(project).await;
|
||||
let (project, github_publication_error) = if github_sync_enabled {
|
||||
match push_project_for_sync(project.clone()).await {
|
||||
Ok(project) => (project, None),
|
||||
Err(error) => {
|
||||
let project = Store::open()?
|
||||
.get_local_project(&project.id)?
|
||||
.unwrap_or(project);
|
||||
(project, Some(error.to_string()))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
(project, None)
|
||||
};
|
||||
crate::telemetry::capture_project_created(true);
|
||||
Ok(Json(json!({
|
||||
"project": project_json(&project),
|
||||
@@ -1066,7 +1106,9 @@ async fn initialize_project_git(
|
||||
|
||||
fn push_project(project: &local::model::LocalProject) -> Result<()> {
|
||||
if !project.github_enabled() {
|
||||
return Err(anyhow!("Connect GitHub for this project before pushing."));
|
||||
return Err(anyhow!(
|
||||
"Enable GitHub syncing for this project before pushing."
|
||||
));
|
||||
}
|
||||
let path = std::path::Path::new(&project.repo_path);
|
||||
local::git::add_github_remote(path, &project.github_owner, &project.github_repo)?;
|
||||
@@ -1100,7 +1142,20 @@ async fn create_independent_project_repository(
|
||||
.map(|session| session.id)
|
||||
.collect::<Vec<_>>();
|
||||
local::git::migrate_legacy_project_worktrees(&project, &session_ids)?;
|
||||
let source_repository = project
|
||||
.has_github_repository()
|
||||
.then(|| (project.github_owner.clone(), project.github_repo.clone()));
|
||||
let reroot_shallow = local::git::prepare_shallow_repository_for_publication(
|
||||
std::path::Path::new(&project.repo_path),
|
||||
)?;
|
||||
let (owner, repo, _) = local::github::create_project_repo(&project.slug).await?;
|
||||
if reroot_shallow {
|
||||
local::git::reroot_shallow_repository(
|
||||
std::path::Path::new(&project.repo_path),
|
||||
&project.baseline_branch,
|
||||
source_repository.as_ref(),
|
||||
)?;
|
||||
}
|
||||
project.github_owner = owner;
|
||||
project.github_repo = repo;
|
||||
project.github_sync_enabled = false;
|
||||
@@ -1113,19 +1168,18 @@ async fn push_project_for_sync(
|
||||
) -> Result<local::model::LocalProject> {
|
||||
if local::git::resolve_github_token().is_none() {
|
||||
return Err(anyhow!(
|
||||
"Connect GitHub first with gh auth login or a GitHub token."
|
||||
"Connect GitHub first with `gh auth login` or a GitHub token."
|
||||
));
|
||||
}
|
||||
|
||||
let mut using_existing_repository = project.has_github_repository();
|
||||
if using_existing_repository {
|
||||
if let Some(meta) =
|
||||
local::github::repo_meta(&project.github_owner, &project.github_repo).await
|
||||
{
|
||||
if !meta.can_push || meta.archived {
|
||||
project = create_independent_project_repository(project).await?;
|
||||
using_existing_repository = false;
|
||||
}
|
||||
let can_push = local::github::repo_meta(&project.github_owner, &project.github_repo)
|
||||
.await
|
||||
.is_some_and(|meta| meta.can_push && !meta.archived);
|
||||
if !can_push {
|
||||
project = create_independent_project_repository(project).await?;
|
||||
using_existing_repository = false;
|
||||
}
|
||||
} else {
|
||||
project = create_independent_project_repository(project).await?;
|
||||
@@ -1133,11 +1187,10 @@ async fn push_project_for_sync(
|
||||
}
|
||||
|
||||
let push_once = |project: &local::model::LocalProject| {
|
||||
let mut project_for_push = project.clone();
|
||||
project_for_push.github_sync_enabled = true;
|
||||
tokio::task::spawn_blocking(move || push_project(&project_for_push))
|
||||
let mut project = project.clone();
|
||||
project.github_sync_enabled = true;
|
||||
tokio::task::spawn_blocking(move || push_project(&project))
|
||||
};
|
||||
|
||||
let first_push = push_once(&project)
|
||||
.await
|
||||
.map_err(|error| anyhow!("Git push task failed: {error}"))?;
|
||||
@@ -1156,25 +1209,6 @@ async fn push_project_for_sync(
|
||||
Ok(project)
|
||||
}
|
||||
|
||||
async fn publish_project_by_default(
|
||||
project: local::model::LocalProject,
|
||||
) -> (local::model::LocalProject, Option<String>) {
|
||||
if !crate::config::github_for_new_projects() || project.github_enabled() {
|
||||
return (project, None);
|
||||
}
|
||||
match push_project_for_sync(project.clone()).await {
|
||||
Ok(project) => (project, None),
|
||||
Err(error) => {
|
||||
let project = Store::open()
|
||||
.and_then(|store| store.get_local_project(&project.id))
|
||||
.ok()
|
||||
.flatten()
|
||||
.unwrap_or(project);
|
||||
(project, Some(error.to_string()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn enable_project_github(State(state): State<AppState>, Path(id): Path<String>) -> ApiResult {
|
||||
reject_if_moving(&state)?;
|
||||
let _admission = state
|
||||
@@ -1184,16 +1218,11 @@ async fn enable_project_github(State(state): State<AppState>, Path(id): Path<Str
|
||||
reject_if_moving(&state)?;
|
||||
let _lock = project_publication_lock(&state, &id).await;
|
||||
let store = Store::open()?;
|
||||
let mut project = store
|
||||
let project = store
|
||||
.get_local_project(&id)?
|
||||
.ok_or_else(|| not_found("project"))?;
|
||||
project = push_project_for_sync(project).await.map_err(bad_request)?;
|
||||
let git_status = tokio::task::spawn_blocking({
|
||||
let project = project.clone();
|
||||
move || project_git_json(&project)
|
||||
})
|
||||
.await
|
||||
.map_err(|error| ApiError::from(anyhow!("git task failed: {error}")))?;
|
||||
let project = push_project_for_sync(project).await.map_err(bad_request)?;
|
||||
let git_status = project_git_json(&project);
|
||||
Ok(Json(
|
||||
json!({ "project": project_json(&project), "git": git_status }),
|
||||
))
|
||||
@@ -1246,6 +1275,41 @@ async fn push_project_github(State(state): State<AppState>, Path(id): Path<Strin
|
||||
))
|
||||
}
|
||||
|
||||
async fn github_account() -> ApiResult {
|
||||
Ok(Json(
|
||||
json!({ "login": local::github::viewer_login().await }),
|
||||
))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ProjectRepoPreviewQuery {
|
||||
name: String,
|
||||
}
|
||||
|
||||
async fn github_project_repo_preview(Query(q): Query<ProjectRepoPreviewQuery>) -> ApiResult {
|
||||
let candidate = local::projects::project_slug_preview(&Store::open()?, q.name.trim())?;
|
||||
let repo = local::github::available_project_repo_name(&candidate).await;
|
||||
Ok(Json(json!({ "repo": repo })))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct RepoAccessQuery {
|
||||
owner: String,
|
||||
repo: String,
|
||||
}
|
||||
|
||||
async fn github_repo_access(Query(q): Query<RepoAccessQuery>) -> ApiResult {
|
||||
let owner = q.owner.trim();
|
||||
let repo = q.repo.trim();
|
||||
if owner.is_empty() || repo.is_empty() {
|
||||
return Err(bad_request("owner and repo are required"));
|
||||
}
|
||||
let meta = local::github::repo_meta(owner, repo).await;
|
||||
Ok(Json(json!({
|
||||
"canPush": meta.is_some_and(|meta| meta.can_push && !meta.archived),
|
||||
})))
|
||||
}
|
||||
|
||||
/// Mark a project visited: bumps updated_at, which drives the recency sort
|
||||
/// and the SSE project.updated diff.
|
||||
async fn open_project(Path(id): Path<String>) -> ApiResult {
|
||||
@@ -1314,7 +1378,7 @@ async fn update_project(
|
||||
/// Delete a project and everything hanging off it. Refuses while runs are in
|
||||
/// flight (deleting their rows would strand the supervisor mid-job) — but
|
||||
/// requests their cancellation, so a retry shortly after goes through. The
|
||||
/// GitHub repo and the cache clone are left untouched.
|
||||
/// The registered repository folder is left untouched.
|
||||
async fn delete_project(State(state): State<AppState>, Path(id): Path<String>) -> ApiResult {
|
||||
reject_if_moving(&state)?;
|
||||
let _deleting_project = state
|
||||
@@ -1397,6 +1461,87 @@ async fn list_project_runs(Path(id): Path<String>) -> ApiResult {
|
||||
Ok(Json(json!({ "runs": runs })))
|
||||
}
|
||||
|
||||
async fn compute_backends() -> Json<Value> {
|
||||
Json(json!({ "backends": crate::compute::capabilities() }))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct CreateRunReq {
|
||||
experiment_id: String,
|
||||
backend: Option<String>,
|
||||
flavor: Option<String>,
|
||||
host: Option<String>,
|
||||
manifest: Option<String>,
|
||||
image: Option<String>,
|
||||
timeout: Option<String>,
|
||||
org: Option<String>,
|
||||
provider: Option<String>,
|
||||
disk: Option<i64>,
|
||||
#[serde(default)]
|
||||
force: bool,
|
||||
}
|
||||
|
||||
async fn create_run(State(state): State<AppState>, Json(req): Json<CreateRunReq>) -> ApiResult {
|
||||
reject_if_moving(&state)?;
|
||||
let store = Store::open()?;
|
||||
let experiment = store
|
||||
.get_local_experiment(&req.experiment_id)?
|
||||
.ok_or_else(|| not_found("experiment"))?;
|
||||
let _admission = state
|
||||
.project_lifecycle
|
||||
.admit(&experiment.project_id)
|
||||
.ok_or_else(|| bad_request("project deletion is in progress"))?;
|
||||
let mut backend = req.backend;
|
||||
let mut flavor = req.flavor;
|
||||
local::apply_compute_default(&mut backend, &mut flavor);
|
||||
let args = crate::ExpRunArgs {
|
||||
exp_id: req.experiment_id,
|
||||
gpu: None,
|
||||
count: None,
|
||||
disk: req.disk,
|
||||
provider: req.provider,
|
||||
cpu: None,
|
||||
vcpus: None,
|
||||
sandbox: None,
|
||||
backend: Some(backend.unwrap_or_else(|| "local".to_string())),
|
||||
flavor,
|
||||
org: req.org,
|
||||
host: req.host,
|
||||
manifest: req.manifest,
|
||||
image: req.image,
|
||||
timeout: req.timeout,
|
||||
force: req.force,
|
||||
};
|
||||
let run = crate::compute::submit(&args).await.map_err(bad_request)?;
|
||||
Ok(Json(json!({ "run": ApiRun::from(&run) })))
|
||||
}
|
||||
|
||||
fn backend_for_run(
|
||||
run: &StoredRun,
|
||||
) -> std::result::Result<Box<dyn crate::compute::ComputeBackend>, ApiError> {
|
||||
let descriptor =
|
||||
crate::jobs::BackendDescriptor::parse(&run.backend_json).map_err(bad_request)?;
|
||||
let id = descriptor
|
||||
.kind
|
||||
.strip_suffix("_job")
|
||||
.unwrap_or(&descriptor.kind);
|
||||
let id = if id == "k8s" { "k8s" } else { id };
|
||||
crate::compute::backend(id).map_err(bad_request)
|
||||
}
|
||||
|
||||
async fn get_run(Path(id): Path<String>) -> ApiResult {
|
||||
let run = Store::open()?
|
||||
.get_run(&id)?
|
||||
.ok_or_else(|| not_found("run"))?;
|
||||
let backend = backend_for_run(&run)?;
|
||||
let run = backend.status(&run).await.map_err(bad_request)?;
|
||||
if is_terminal(&run.status) {
|
||||
backend.cleanup(&run).await.map_err(bad_request)?;
|
||||
}
|
||||
Ok(Json(json!({ "run": ApiRun::from(&run) })))
|
||||
}
|
||||
|
||||
/// Newest-first cap for the cross-project instances list. Generous: the store
|
||||
/// is a local single-user SQLite db, so this only bounds pathological history.
|
||||
const INSTANCES_LIMIT: usize = 500;
|
||||
@@ -1433,10 +1578,27 @@ async fn cancel_run(Path(id): Path<String>) -> ApiResult {
|
||||
if is_terminal(&run.status) {
|
||||
return Err(bad_request(format!("run already {}", run.status)));
|
||||
}
|
||||
crate::commands::exp::request_local_run_cancel(&store, &run.id)?;
|
||||
let backend = backend_for_run(&run)?;
|
||||
backend.cancel(&run).await.map_err(bad_request)?;
|
||||
Ok(Json(json!({ "ok": true })))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct LogsQuery {
|
||||
cursor: Option<u64>,
|
||||
}
|
||||
|
||||
async fn run_logs(Path(id): Path<String>, Query(q): Query<LogsQuery>) -> ApiResult {
|
||||
let run = Store::open()?
|
||||
.get_run(&id)?
|
||||
.ok_or_else(|| not_found("run"))?;
|
||||
let batch = backend_for_run(&run)?
|
||||
.logs(&run, crate::compute::LogCursor(q.cursor.unwrap_or(0)))
|
||||
.await
|
||||
.map_err(bad_request)?;
|
||||
Ok(Json(json!(batch)))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct LogQuery {
|
||||
offset: Option<u64>,
|
||||
@@ -2271,36 +2433,6 @@ fn data_dir_json() -> Value {
|
||||
})
|
||||
}
|
||||
|
||||
/// The signed-in GitHub login, so "creates github.com/you/x" can name the real
|
||||
/// account. `null` when there's no usable token — the UI keeps saying "you".
|
||||
async fn github_account() -> ApiResult {
|
||||
Ok(Json(
|
||||
json!({ "login": local::github::viewer_login().await }),
|
||||
))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct RepoAccessQuery {
|
||||
owner: String,
|
||||
repo: String,
|
||||
}
|
||||
|
||||
/// Whether the stored credentials can push to `owner/repo`, so the New project
|
||||
/// form can drop the fork choice when it isn't one. Mirrors the create path:
|
||||
/// unknown (no token / API hiccup) counts as access, so the UI never nags about
|
||||
/// a fork the server wouldn't make.
|
||||
async fn github_repo_access(Query(q): Query<RepoAccessQuery>) -> ApiResult {
|
||||
let owner = q.owner.trim().to_string();
|
||||
let repo = q.repo.trim().to_string();
|
||||
if owner.is_empty() || repo.is_empty() {
|
||||
return Err(bad_request("owner and repo are required"));
|
||||
}
|
||||
let meta = local::github::repo_meta(&owner, &repo).await;
|
||||
Ok(Json(json!({
|
||||
"canPush": meta.map(|m| m.can_push && !m.archived).unwrap_or(true),
|
||||
})))
|
||||
}
|
||||
|
||||
async fn data_dir_settings() -> ApiResult {
|
||||
tokio::task::spawn_blocking(|| Ok(Json(data_dir_json())))
|
||||
.await
|
||||
@@ -2551,25 +2683,16 @@ fn git_out(args: &[&str]) -> Option<String> {
|
||||
}
|
||||
|
||||
fn git_settings_json() -> Value {
|
||||
// Spawn failure means gh isn't installed — distinct from installed-but-
|
||||
// signed-out, so the UI can lead with the right fix.
|
||||
let gh = std::process::Command::new("gh")
|
||||
.args(["auth", "token"])
|
||||
.output();
|
||||
let gh_installed = gh.is_ok();
|
||||
let github_source = if std::env::var("GITHUB_TOKEN").is_ok_and(|t| !t.trim().is_empty()) {
|
||||
Some("env")
|
||||
} else if crate::config::synced_env_var("GITHUB_TOKEN").is_some() {
|
||||
Some("stored")
|
||||
} else {
|
||||
matches!(gh, Ok(out) if out.status.success() && !out.stdout.is_empty()).then_some("gh")
|
||||
};
|
||||
let gh_installed = std::process::Command::new("gh")
|
||||
.arg("--version")
|
||||
.output()
|
||||
.is_ok_and(|output| output.status.success());
|
||||
json!({
|
||||
"gitVersion": git_out(&["--version"]),
|
||||
"userName": git_out(&["config", "--global", "user.name"]),
|
||||
"userEmail": git_out(&["config", "--global", "user.email"]),
|
||||
"ghInstalled": gh_installed,
|
||||
"githubTokenSource": github_source,
|
||||
"githubTokenSource": github_token_source(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -2584,9 +2707,7 @@ fn project_defaults_json() -> Value {
|
||||
}
|
||||
|
||||
async fn project_defaults() -> ApiResult {
|
||||
tokio::task::spawn_blocking(|| Ok(Json(project_defaults_json())))
|
||||
.await
|
||||
.map_err(|error| ApiError::from(anyhow!("project defaults task failed: {error}")))?
|
||||
Ok(Json(project_defaults_json()))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -2598,21 +2719,16 @@ struct SetProjectDefaultsReq {
|
||||
}
|
||||
|
||||
async fn set_project_defaults(Json(req): Json<SetProjectDefaultsReq>) -> ApiResult {
|
||||
tokio::task::spawn_blocking(move || -> Result<Json<Value>> {
|
||||
if req.github_for_new_projects && github_token_source().is_none() {
|
||||
return Err(anyhow!(
|
||||
"Connect GitHub before enabling it by default for new projects."
|
||||
));
|
||||
}
|
||||
crate::config::set_github_for_new_projects(req.github_for_new_projects)?;
|
||||
if let Some(seen) = req.github_default_prompt_seen {
|
||||
crate::config::set_github_default_prompt_seen(seen)?;
|
||||
}
|
||||
Ok(Json(project_defaults_json()))
|
||||
})
|
||||
.await
|
||||
.map_err(|error| ApiError::from(anyhow!("project defaults task failed: {error}")))?
|
||||
.map_err(bad_request)
|
||||
if req.github_for_new_projects && github_token_source().is_none() {
|
||||
return Err(bad_request(
|
||||
"Connect GitHub before enabling it by default for new projects.",
|
||||
));
|
||||
}
|
||||
crate::config::set_github_for_new_projects(req.github_for_new_projects)?;
|
||||
if let Some(seen) = req.github_default_prompt_seen {
|
||||
crate::config::set_github_default_prompt_seen(seen)?;
|
||||
}
|
||||
Ok(Json(project_defaults_json()))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -2620,60 +2736,42 @@ struct SetGitTokenReq {
|
||||
token: String,
|
||||
}
|
||||
|
||||
/// Validate a pasted GitHub token against the API, then persist it to the
|
||||
/// synced env file — the same store job launches already read, so local git
|
||||
/// ops and remote compute both pick it up.
|
||||
async fn set_git_token(Json(req): Json<SetGitTokenReq>) -> ApiResult {
|
||||
let token = req.token.trim().to_string();
|
||||
if token.is_empty() {
|
||||
return Err(bad_request("token is required"));
|
||||
}
|
||||
let resp = reqwest::Client::new()
|
||||
let response = reqwest::Client::new()
|
||||
.get("https://api.github.com/user")
|
||||
.header("User-Agent", "orx")
|
||||
.header("Authorization", format!("Bearer {token}"))
|
||||
.bearer_auth(&token)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| bad_request(format!("Could not reach api.github.com: {e}")))?;
|
||||
if resp.status() == reqwest::StatusCode::UNAUTHORIZED {
|
||||
return Err(bad_request(
|
||||
"GitHub rejected the token — check it was copied fully.",
|
||||
));
|
||||
}
|
||||
if !resp.status().is_success() {
|
||||
.map_err(|error| bad_request(format!("Could not reach api.github.com: {error}")))?;
|
||||
if !response.status().is_success() {
|
||||
return Err(bad_request(format!(
|
||||
"GitHub returned {} validating the token.",
|
||||
resp.status()
|
||||
"GitHub rejected the token ({}).",
|
||||
response.status()
|
||||
)));
|
||||
}
|
||||
// Classic PATs list scopes; fine-grained tokens send an empty header, so
|
||||
// only enforce when scopes are reported.
|
||||
let scopes = resp
|
||||
let scopes = response
|
||||
.headers()
|
||||
.get("x-oauth-scopes")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
if !scopes.trim().is_empty() && !scopes.split(',').any(|s| s.trim() == "repo") {
|
||||
if !scopes.trim().is_empty() && !scopes.split(',').any(|scope| scope.trim() == "repo") {
|
||||
return Err(bad_request(
|
||||
"Token is valid but lacks the `repo` scope — private clones and branch pushes would fail.",
|
||||
"Token is valid but lacks the `repo` scope needed for private repositories.",
|
||||
));
|
||||
}
|
||||
tokio::task::spawn_blocking(move || {
|
||||
crate::config::write_synced_env_var("GITHUB_TOKEN", &token)?;
|
||||
Ok(Json(git_settings_json()))
|
||||
})
|
||||
.await
|
||||
.map_err(|e| ApiError::from(anyhow!("git task failed: {e}")))?
|
||||
crate::config::write_synced_env_var("GITHUB_TOKEN", &token)?;
|
||||
Ok(Json(git_settings_json()))
|
||||
}
|
||||
|
||||
async fn delete_git_token() -> ApiResult {
|
||||
tokio::task::spawn_blocking(|| {
|
||||
crate::config::remove_synced_env_var("GITHUB_TOKEN")?;
|
||||
Ok(Json(git_settings_json()))
|
||||
})
|
||||
.await
|
||||
.map_err(|e| ApiError::from(anyhow!("git task failed: {e}")))?
|
||||
crate::config::remove_synced_env_var("GITHUB_TOKEN")?;
|
||||
Ok(Json(git_settings_json()))
|
||||
}
|
||||
|
||||
async fn git_settings() -> ApiResult {
|
||||
@@ -3002,7 +3100,7 @@ struct SshPreflightReq {
|
||||
host: String,
|
||||
}
|
||||
|
||||
/// Live check for one host: can we reach it (BatchMode ssh), and is `git` there?
|
||||
/// Live check for one host: can we reach it and run bash/tar snapshots?
|
||||
async fn ssh_preflight(Json(req): Json<SshPreflightReq>) -> ApiResult {
|
||||
let host = req.host.trim().to_string();
|
||||
if host.is_empty() {
|
||||
@@ -3012,7 +3110,7 @@ async fn ssh_preflight(Json(req): Json<SshPreflightReq>) -> ApiResult {
|
||||
let test = SshHostTest {
|
||||
host,
|
||||
reachable: p.reachable,
|
||||
git_found: p.git_found,
|
||||
tools_found: p.tools_found,
|
||||
error: p.error,
|
||||
tested_at: now_ms(),
|
||||
};
|
||||
@@ -3099,7 +3197,7 @@ struct SlurmPreflightReq {
|
||||
host: String,
|
||||
}
|
||||
|
||||
/// Live check for one login node: reachable, Slurm CLI + git present, and
|
||||
/// Live check for one login node: reachable, Slurm CLI + snapshot tools, and
|
||||
/// which partitions exist (feeds the partition picker).
|
||||
async fn slurm_preflight(Json(req): Json<SlurmPreflightReq>) -> ApiResult {
|
||||
let host = req.host.trim().to_string();
|
||||
@@ -3110,7 +3208,7 @@ async fn slurm_preflight(Json(req): Json<SlurmPreflightReq>) -> ApiResult {
|
||||
Ok(Json(json!({
|
||||
"reachable": p.reachable,
|
||||
"slurmFound": p.slurm_found,
|
||||
"gitFound": p.git_found,
|
||||
"toolsFound": p.tools_found,
|
||||
"partitions": p.partitions,
|
||||
"error": p.error,
|
||||
})))
|
||||
@@ -3262,7 +3360,7 @@ fn openresearch_summary(logged_in: bool, ssh: &SshReadiness) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
fn compute_settings_json(ssh: SshReadiness, github_enabled: bool) -> Value {
|
||||
fn compute_settings_json(ssh: SshReadiness) -> Value {
|
||||
let default = crate::config::compute_default();
|
||||
let (default_backend, default_flavor) = match &default {
|
||||
Some((b, f)) => (Some(b.as_str()), f.as_deref()),
|
||||
@@ -3369,30 +3467,15 @@ fn compute_settings_json(ssh: SshReadiness, github_enabled: bool) -> Value {
|
||||
]);
|
||||
if let Some(targets) = targets.as_array_mut() {
|
||||
for target in targets {
|
||||
let local_target = target.get("id").and_then(Value::as_str) == Some("local");
|
||||
let enabled = local_target || github_enabled;
|
||||
if let Some(target) = target.as_object_mut() {
|
||||
target.insert("enabled".to_string(), Value::Bool(enabled));
|
||||
target.insert(
|
||||
"disabledReason".to_string(),
|
||||
if enabled {
|
||||
Value::Null
|
||||
} else {
|
||||
Value::String("Connect GitHub to enable".to_string())
|
||||
},
|
||||
);
|
||||
target.insert("enabled".to_string(), Value::Bool(true));
|
||||
target.insert("disabledReason".to_string(), Value::Null);
|
||||
}
|
||||
}
|
||||
}
|
||||
let effective_backend = if github_enabled {
|
||||
default_backend.unwrap_or("local")
|
||||
} else {
|
||||
"local"
|
||||
};
|
||||
let effective_flavor = github_enabled.then_some(default_flavor).flatten();
|
||||
json!({
|
||||
"defaultBackend": effective_backend,
|
||||
"defaultFlavor": effective_flavor,
|
||||
"defaultBackend": default_backend.unwrap_or("local"),
|
||||
"defaultFlavor": default_flavor,
|
||||
"configuredDefaultBackend": default_backend,
|
||||
"configuredDefaultFlavor": default_flavor,
|
||||
"targets": targets,
|
||||
@@ -3405,26 +3488,14 @@ struct ComputeSettingsQuery {
|
||||
project_id: Option<String>,
|
||||
}
|
||||
|
||||
fn project_github_enabled(project_id: Option<&str>) -> Result<bool> {
|
||||
let Some(project_id) = project_id else {
|
||||
return Ok(false);
|
||||
};
|
||||
Ok(Store::open()?
|
||||
.get_local_project(project_id)?
|
||||
.ok_or_else(|| anyhow!("project not found"))?
|
||||
.github_enabled())
|
||||
}
|
||||
|
||||
async fn compute_settings(Query(query): Query<ComputeSettingsQuery>) -> ApiResult {
|
||||
let ssh = openresearch_ssh_readiness().await;
|
||||
let project_id = query.project_id;
|
||||
let _project_id = query.project_id;
|
||||
// fs/env probes only, but keep them off the async runtime anyway.
|
||||
let payload = tokio::task::spawn_blocking(move || -> Result<Value> {
|
||||
let github_enabled = project_github_enabled(project_id.as_deref())?;
|
||||
Ok(compute_settings_json(ssh, github_enabled))
|
||||
})
|
||||
.await
|
||||
.map_err(|e| ApiError::from(anyhow!("compute settings task failed: {e}")))??;
|
||||
let payload =
|
||||
tokio::task::spawn_blocking(move || -> Result<Value> { Ok(compute_settings_json(ssh)) })
|
||||
.await
|
||||
.map_err(|e| ApiError::from(anyhow!("compute settings task failed: {e}")))??;
|
||||
Ok(Json(payload))
|
||||
}
|
||||
|
||||
@@ -3441,7 +3512,7 @@ struct SetComputeDefaultReq {
|
||||
/// (config state fluctuates outside orx; the UI warns instead) — only unknown
|
||||
/// backends and meaningless flavors are rejected.
|
||||
async fn set_compute_default(Json(req): Json<SetComputeDefaultReq>) -> ApiResult {
|
||||
let github_enabled = project_github_enabled(req.project_id.as_deref())?;
|
||||
let _project_id = req.project_id;
|
||||
let backend = req
|
||||
.backend
|
||||
.map(|b| b.trim().to_string())
|
||||
@@ -3452,11 +3523,6 @@ async fn set_compute_default(Json(req): Json<SetComputeDefaultReq>) -> ApiResult
|
||||
.filter(|f| !f.is_empty());
|
||||
if let Some(b) = &backend {
|
||||
local::validate_compute_default(b, flavor.as_deref()).map_err(bad_request)?;
|
||||
if b != "local" && !github_enabled {
|
||||
return Err(bad_request(
|
||||
"Connect GitHub for this project before selecting remote compute.",
|
||||
));
|
||||
}
|
||||
}
|
||||
// Picking openresearch as the default is the moment to answer "will this
|
||||
// actually work?", so the row that comes back is honest about the SSH key.
|
||||
@@ -3466,7 +3532,7 @@ async fn set_compute_default(Json(req): Json<SetComputeDefaultReq>) -> ApiResult
|
||||
// the plain ApiError conversion, not as a 400 blaming the request.
|
||||
let payload = tokio::task::spawn_blocking(move || -> Result<Value> {
|
||||
crate::config::set_compute_default(backend, flavor)?;
|
||||
Ok(compute_settings_json(ssh, github_enabled))
|
||||
Ok(compute_settings_json(ssh))
|
||||
})
|
||||
.await
|
||||
.map_err(|e| ApiError::from(anyhow!("compute default task failed: {e}")))??;
|
||||
|
||||
+807
@@ -0,0 +1,807 @@
|
||||
//! Backend-agnostic compute lifecycle and immutable source snapshots.
|
||||
//!
|
||||
//! Local Git remains the experiment-history database. A launch never asks a
|
||||
//! remote backend to clone that history: it archives the exact recorded commit
|
||||
//! once, addresses the archive by SHA-256, and hands that immutable payload to
|
||||
//! the selected provider adapter.
|
||||
|
||||
use std::io::Read as _;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, Stdio};
|
||||
|
||||
#[cfg(unix)]
|
||||
use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use serde::Serialize;
|
||||
use sha2::{Digest as _, Sha256};
|
||||
|
||||
use crate::error::{anyhow, Result};
|
||||
use crate::jobs::BackendDescriptor;
|
||||
use crate::local::model::{LocalExperiment, LocalProject};
|
||||
use crate::store::{log_path, Store, StoredRun};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SourceSnapshot {
|
||||
pub revision: String,
|
||||
pub digest: String,
|
||||
pub size: u64,
|
||||
pub path: PathBuf,
|
||||
pub ray_package: Option<(String, PathBuf)>,
|
||||
}
|
||||
|
||||
impl SourceSnapshot {
|
||||
pub fn create(
|
||||
project: &LocalProject,
|
||||
experiment: &LocalExperiment,
|
||||
include_ray_package: bool,
|
||||
) -> Result<Self> {
|
||||
let repo = Path::new(&project.repo_path);
|
||||
let revision = crate::local::git::local_head_sha(repo, &experiment.branch_name)?;
|
||||
let dir = crate::store::data_dir().join("source-snapshots");
|
||||
prepare_snapshot_dir(&dir)?;
|
||||
|
||||
let nonce = uuid::Uuid::new_v4();
|
||||
let tar_tmp = dir.join(format!(".{nonce}.tar"));
|
||||
archive(repo, &revision, "tar", &tar_tmp)?;
|
||||
let (digest, size) = digest_file(&tar_tmp)?;
|
||||
let path = dir.join(format!("{digest}.tar"));
|
||||
install_content_addressed(&tar_tmp, &path, &digest, size)?;
|
||||
|
||||
let ray_package = if include_ray_package {
|
||||
let zip_tmp = dir.join(format!(".{nonce}.zip"));
|
||||
archive(repo, &revision, "zip", &zip_tmp)?;
|
||||
let (zip_digest, zip_size) = digest_file(&zip_tmp)?;
|
||||
let zip_path = dir.join(format!("{zip_digest}.zip"));
|
||||
install_content_addressed(&zip_tmp, &zip_path, &zip_digest, zip_size)?;
|
||||
Some((zip_digest, zip_path))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
Ok(Self {
|
||||
revision,
|
||||
digest,
|
||||
size,
|
||||
path,
|
||||
ray_package,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn apply_to_descriptor(&self, descriptor: &mut BackendDescriptor) {
|
||||
descriptor.source_digest = Some(self.digest.clone());
|
||||
descriptor.source_path = Some(self.path.to_string_lossy().into_owned());
|
||||
descriptor.source_size = Some(self.size);
|
||||
}
|
||||
|
||||
pub fn from_run(run: &StoredRun, descriptor: &BackendDescriptor) -> Result<Self> {
|
||||
let revision = run
|
||||
.commit_sha
|
||||
.clone()
|
||||
.ok_or_else(|| anyhow!("Run {} has no recorded source revision.", run.id))?;
|
||||
let digest = descriptor
|
||||
.source_digest
|
||||
.clone()
|
||||
.ok_or_else(|| anyhow!("Run {} has no recorded source digest.", run.id))?;
|
||||
let recorded_path = descriptor
|
||||
.source_path
|
||||
.as_deref()
|
||||
.map(PathBuf::from)
|
||||
.ok_or_else(|| anyhow!("Run {} has no recorded source archive.", run.id))?;
|
||||
let path = if recorded_path.is_file() {
|
||||
recorded_path
|
||||
} else {
|
||||
crate::store::data_dir()
|
||||
.join("source-snapshots")
|
||||
.join(format!("{digest}.tar"))
|
||||
};
|
||||
if !path.is_file() {
|
||||
return Err(anyhow!(
|
||||
"Run {} source archive is missing at {}.",
|
||||
run.id,
|
||||
path.display()
|
||||
));
|
||||
}
|
||||
let size = descriptor
|
||||
.source_size
|
||||
.or_else(|| std::fs::metadata(&path).ok().map(|m| m.len()))
|
||||
.unwrap_or(0);
|
||||
let (actual_digest, actual_size) = digest_file(&path)?;
|
||||
if actual_digest != digest || (size != 0 && actual_size != size) {
|
||||
return Err(anyhow!(
|
||||
"Run {} source archive failed its digest check.",
|
||||
run.id
|
||||
));
|
||||
}
|
||||
Ok(Self {
|
||||
revision,
|
||||
digest,
|
||||
size,
|
||||
path,
|
||||
ray_package: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn archive(repo: &Path, revision: &str, format: &str, destination: &Path) -> Result<()> {
|
||||
let mut options = std::fs::OpenOptions::new();
|
||||
options.write(true).create_new(true);
|
||||
#[cfg(unix)]
|
||||
options.mode(0o600);
|
||||
let file = options.open(destination)?;
|
||||
let output = Command::new("git")
|
||||
.current_dir(repo)
|
||||
.args(["archive", &format!("--format={format}"), revision])
|
||||
.stdout(Stdio::from(file))
|
||||
.stderr(Stdio::piped())
|
||||
.output()
|
||||
.map_err(|e| anyhow!("Could not run git archive: {e}"))?;
|
||||
if output.status.success() {
|
||||
return Ok(());
|
||||
}
|
||||
let _ = std::fs::remove_file(destination);
|
||||
Err(anyhow!(
|
||||
"git archive failed for {}: {}",
|
||||
revision,
|
||||
String::from_utf8_lossy(&output.stderr).trim()
|
||||
))
|
||||
}
|
||||
|
||||
fn prepare_snapshot_dir(path: &Path) -> Result<()> {
|
||||
std::fs::create_dir_all(path)?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
|
||||
let metadata = std::fs::symlink_metadata(path)?;
|
||||
if !metadata.file_type().is_dir() || metadata.uid() != unsafe { libc::geteuid() } {
|
||||
return Err(anyhow!(
|
||||
"Source snapshot directory {} is not owned by the current user.",
|
||||
path.display()
|
||||
));
|
||||
}
|
||||
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn digest_file(path: &Path) -> Result<(String, u64)> {
|
||||
let mut file = std::fs::File::open(path)?;
|
||||
let mut hasher = Sha256::new();
|
||||
let mut size = 0u64;
|
||||
let mut buf = [0u8; 128 * 1024];
|
||||
loop {
|
||||
let read = file.read(&mut buf)?;
|
||||
if read == 0 {
|
||||
break;
|
||||
}
|
||||
hasher.update(&buf[..read]);
|
||||
size += read as u64;
|
||||
}
|
||||
Ok((format!("{:x}", hasher.finalize()), size))
|
||||
}
|
||||
|
||||
fn install_content_addressed(
|
||||
source: &Path,
|
||||
destination: &Path,
|
||||
expected_digest: &str,
|
||||
expected_size: u64,
|
||||
) -> Result<()> {
|
||||
if destination.exists() {
|
||||
let (digest, size) = digest_file(destination)?;
|
||||
if digest == expected_digest && size == expected_size {
|
||||
std::fs::remove_file(source)?;
|
||||
restrict_snapshot_file(destination)?;
|
||||
return Ok(());
|
||||
}
|
||||
std::fs::remove_file(destination)?;
|
||||
}
|
||||
match std::fs::rename(source, destination) {
|
||||
Ok(()) => restrict_snapshot_file(destination),
|
||||
Err(_err) if destination.exists() => {
|
||||
let (digest, size) = digest_file(destination)?;
|
||||
std::fs::remove_file(source)?;
|
||||
if digest == expected_digest && size == expected_size {
|
||||
restrict_snapshot_file(destination)
|
||||
} else {
|
||||
Err(anyhow!(
|
||||
"Cached source snapshot {} failed its digest check.",
|
||||
destination.display()
|
||||
))
|
||||
}
|
||||
}
|
||||
Err(err) => Err(err.into()),
|
||||
}
|
||||
}
|
||||
|
||||
fn restrict_snapshot_file(path: &Path) -> Result<()> {
|
||||
#[cfg(unix)]
|
||||
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn snapshot_script(archive_path: &str, command: &str) -> String {
|
||||
format!(
|
||||
"set -eo pipefail; mkdir -p repo; tar -xf {} -C repo; cd repo; {}",
|
||||
shell_quote(archive_path),
|
||||
command
|
||||
)
|
||||
}
|
||||
|
||||
pub fn staged_script(command: &str) -> String {
|
||||
format!("set -eo pipefail; cd repo; {command}")
|
||||
}
|
||||
|
||||
pub fn gated_script(archive_path: &str, command: &str) -> String {
|
||||
format!(
|
||||
"set -eo pipefail; mkdir -p \"$(dirname -- {archive})\"; while [ ! -f {ready} ]; do sleep 0.1; done; mkdir -p repo; tar -xf {archive} -C repo; cd repo; {command}",
|
||||
archive = shell_quote(archive_path),
|
||||
ready = shell_quote(&format!("{archive_path}.ready")),
|
||||
)
|
||||
}
|
||||
|
||||
fn shell_quote(value: &str) -> String {
|
||||
format!("'{}'", value.replace('\'', "'\"'\"'"))
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Capabilities {
|
||||
pub id: &'static str,
|
||||
pub label: &'static str,
|
||||
pub remote: bool,
|
||||
pub flavors: bool,
|
||||
pub requires_flavor: bool,
|
||||
pub source_transport: &'static str,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Preflight {
|
||||
pub ready: bool,
|
||||
pub detail: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct StagedSource(pub SourceSnapshot);
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
pub struct LogCursor(pub u64);
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LogBatch {
|
||||
pub data_base64: String,
|
||||
pub next_cursor: u64,
|
||||
pub eof: bool,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait ComputeBackend: Send + Sync {
|
||||
fn capabilities(&self) -> Capabilities;
|
||||
async fn preflight(&self, args: &crate::ExpRunArgs) -> Result<Preflight>;
|
||||
async fn stage_source(
|
||||
&self,
|
||||
project: &LocalProject,
|
||||
experiment: &LocalExperiment,
|
||||
) -> Result<StagedSource>;
|
||||
async fn submit(
|
||||
&self,
|
||||
args: &crate::ExpRunArgs,
|
||||
source: StagedSource,
|
||||
run_id: String,
|
||||
) -> Result<StoredRun>;
|
||||
|
||||
async fn status(&self, handle: &StoredRun) -> Result<StoredRun> {
|
||||
Store::open()?
|
||||
.get_run(&handle.id)?
|
||||
.ok_or_else(|| anyhow!("Run {} not found.", handle.id))
|
||||
}
|
||||
|
||||
async fn logs(&self, handle: &StoredRun, cursor: LogCursor) -> Result<LogBatch> {
|
||||
use base64::Engine as _;
|
||||
use std::io::{Read as _, Seek as _};
|
||||
let path = log_path(&handle.id);
|
||||
let mut file = match std::fs::File::open(&path) {
|
||||
Ok(file) => file,
|
||||
Err(_) => {
|
||||
return Ok(LogBatch {
|
||||
data_base64: String::new(),
|
||||
next_cursor: cursor.0,
|
||||
eof: crate::local::is_terminal(&handle.status),
|
||||
})
|
||||
}
|
||||
};
|
||||
let len = file.metadata()?.len();
|
||||
let start = cursor.0.min(len);
|
||||
file.seek(std::io::SeekFrom::Start(start))?;
|
||||
let mut data = Vec::new();
|
||||
file.take(256 * 1024).read_to_end(&mut data)?;
|
||||
let next = start + data.len() as u64;
|
||||
Ok(LogBatch {
|
||||
data_base64: base64::engine::general_purpose::STANDARD.encode(data),
|
||||
next_cursor: next,
|
||||
eof: crate::local::is_terminal(&handle.status) && next >= len,
|
||||
})
|
||||
}
|
||||
|
||||
async fn cancel(&self, handle: &StoredRun) -> Result<()> {
|
||||
crate::commands::exp::request_local_run_cancel(&Store::open()?, &handle.id)
|
||||
}
|
||||
|
||||
async fn cleanup(&self, handle: &StoredRun) -> Result<()> {
|
||||
if self.capabilities().id == "hf" && crate::local::is_terminal(&handle.status) {
|
||||
let descriptor = BackendDescriptor::parse(&handle.backend_json)?;
|
||||
if let (Some(path), Some(digest)) = (descriptor.source_path, descriptor.source_digest) {
|
||||
let staging = Path::new(&path)
|
||||
.parent()
|
||||
.unwrap_or_else(|| Path::new("."))
|
||||
.join(format!("{digest}.hf"));
|
||||
if staging.is_dir() {
|
||||
std::fs::remove_dir_all(staging)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
async fn stage_snapshot(
|
||||
project: &LocalProject,
|
||||
experiment: &LocalExperiment,
|
||||
include_ray_package: bool,
|
||||
) -> Result<StagedSource> {
|
||||
let project = project.clone();
|
||||
let experiment = experiment.clone();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
SourceSnapshot::create(&project, &experiment, include_ray_package)
|
||||
})
|
||||
.await
|
||||
.map_err(|error| anyhow!("source snapshot task failed: {error}"))?
|
||||
.map(StagedSource)
|
||||
}
|
||||
|
||||
fn ready() -> Result<Preflight> {
|
||||
Ok(Preflight {
|
||||
ready: true,
|
||||
detail: None,
|
||||
})
|
||||
}
|
||||
|
||||
macro_rules! backend_adapter {
|
||||
(
|
||||
$name:ident, $id:literal, $label:literal, $remote:literal, $flavors:literal,
|
||||
$requires_flavor:literal, $transport:literal, $ray_package:literal,
|
||||
preflight |$preflight_args:ident| $preflight:expr,
|
||||
submit |$submit_args:ident, $source:ident, $run_id:ident| $submit:expr
|
||||
) => {
|
||||
pub struct $name;
|
||||
|
||||
#[async_trait]
|
||||
impl ComputeBackend for $name {
|
||||
fn capabilities(&self) -> Capabilities {
|
||||
Capabilities {
|
||||
id: $id,
|
||||
label: $label,
|
||||
remote: $remote,
|
||||
flavors: $flavors,
|
||||
requires_flavor: $requires_flavor,
|
||||
source_transport: $transport,
|
||||
}
|
||||
}
|
||||
|
||||
async fn preflight(&self, args: &crate::ExpRunArgs) -> Result<Preflight> {
|
||||
let $preflight_args = args;
|
||||
$preflight
|
||||
}
|
||||
|
||||
async fn stage_source(
|
||||
&self,
|
||||
project: &LocalProject,
|
||||
experiment: &LocalExperiment,
|
||||
) -> Result<StagedSource> {
|
||||
stage_snapshot(project, experiment, $ray_package).await
|
||||
}
|
||||
|
||||
async fn submit(
|
||||
&self,
|
||||
args: &crate::ExpRunArgs,
|
||||
staged: StagedSource,
|
||||
id: String,
|
||||
) -> Result<StoredRun> {
|
||||
let $submit_args = args;
|
||||
let $source = staged.0;
|
||||
let $run_id = id;
|
||||
$submit
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
backend_adapter!(
|
||||
LocalCompute,
|
||||
"local",
|
||||
"This machine",
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
"local archive",
|
||||
false,
|
||||
preflight | _args | ready(),
|
||||
submit | args,
|
||||
source,
|
||||
run_id | crate::local::localrun::submit_local_run_with_source(args, source, run_id).await
|
||||
);
|
||||
|
||||
backend_adapter!(
|
||||
HuggingFaceCompute,
|
||||
"hf",
|
||||
"Hugging Face Jobs",
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
"private job volume",
|
||||
false,
|
||||
preflight | args | {
|
||||
if args.flavor.is_none() {
|
||||
return Ok(not_ready("Hugging Face Jobs requires --flavor."));
|
||||
}
|
||||
let token = crate::jobs::huggingface::resolve_token()?;
|
||||
crate::jobs::huggingface::whoami(&token).await?;
|
||||
ready()
|
||||
},
|
||||
submit | args,
|
||||
source,
|
||||
run_id | crate::local::hf::submit_local_hf_with_source(args, source, run_id).await
|
||||
);
|
||||
|
||||
backend_adapter!(
|
||||
ModalCompute,
|
||||
"modal",
|
||||
"Modal",
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
"sandbox filesystem",
|
||||
false,
|
||||
preflight | args | {
|
||||
if args.flavor.is_none() {
|
||||
return Ok(not_ready("Modal requires --flavor."));
|
||||
}
|
||||
crate::jobs::modal::preflight().await?;
|
||||
ready()
|
||||
},
|
||||
submit | args,
|
||||
source,
|
||||
run_id | crate::local::modal::submit_local_modal_with_source(args, source, run_id).await
|
||||
);
|
||||
|
||||
backend_adapter!(
|
||||
KubernetesCompute,
|
||||
"k8s",
|
||||
"Kubernetes",
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
"kubectl cp",
|
||||
false,
|
||||
preflight | _args | {
|
||||
let settings = crate::jobs::kubernetes::load_settings()?.unwrap_or_default();
|
||||
let check =
|
||||
crate::jobs::kubernetes::preflight(settings.context.as_deref(), &settings.namespace)
|
||||
.await;
|
||||
if !check.kubectl_found || !check.reachable || !check.can_create_jobs {
|
||||
return Ok(not_ready(check.error.as_deref().unwrap_or(
|
||||
"kubectl cannot reach the cluster or create Jobs in the namespace.",
|
||||
)));
|
||||
}
|
||||
ready()
|
||||
},
|
||||
submit | args,
|
||||
source,
|
||||
run_id | crate::local::k8s::submit_local_k8s_with_source(args, source, run_id).await
|
||||
);
|
||||
|
||||
backend_adapter!(
|
||||
SshCompute,
|
||||
"ssh",
|
||||
"SSH",
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
"SSH tar stream",
|
||||
false,
|
||||
preflight | args | {
|
||||
let host = args
|
||||
.host
|
||||
.as_deref()
|
||||
.ok_or_else(|| anyhow!("SSH requires --host <alias>."))?;
|
||||
let check = crate::jobs::ssh::preflight(&crate::jobs::ssh::SshTarget::alias(host)).await;
|
||||
if !check.reachable || !check.tools_found {
|
||||
return Ok(not_ready(
|
||||
check
|
||||
.error
|
||||
.as_deref()
|
||||
.unwrap_or("The SSH host needs bash and tar."),
|
||||
));
|
||||
}
|
||||
ready()
|
||||
},
|
||||
submit | args,
|
||||
source,
|
||||
run_id | crate::local::ssh::submit_local_ssh_with_source(args, source, run_id).await
|
||||
);
|
||||
|
||||
backend_adapter!(
|
||||
SlurmCompute,
|
||||
"slurm",
|
||||
"Slurm",
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
"SSH tar stream",
|
||||
false,
|
||||
preflight | args | {
|
||||
let settings = crate::jobs::slurm::load_settings()?.unwrap_or_default();
|
||||
let host = args
|
||||
.host
|
||||
.as_deref()
|
||||
.or(settings.host.as_deref())
|
||||
.ok_or_else(|| anyhow!("Slurm requires --host or a configured host."))?;
|
||||
let check = crate::jobs::slurm::preflight(host).await;
|
||||
if !check.reachable || !check.slurm_found || !check.tools_found {
|
||||
return Ok(not_ready(check.error.as_deref().unwrap_or(
|
||||
"The Slurm host needs bash, tar, sbatch, squeue, and scancel.",
|
||||
)));
|
||||
}
|
||||
ready()
|
||||
},
|
||||
submit | args,
|
||||
source,
|
||||
run_id | crate::local::slurm::submit_local_slurm_with_source(args, source, run_id).await
|
||||
);
|
||||
|
||||
backend_adapter!(
|
||||
RayCompute,
|
||||
"ray",
|
||||
"Ray Jobs",
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
"working_dir package",
|
||||
true,
|
||||
preflight | _args | {
|
||||
let address = crate::jobs::ray::resolve_address(None);
|
||||
crate::jobs::ray::preflight(&address).await?;
|
||||
ready()
|
||||
},
|
||||
submit | args,
|
||||
source,
|
||||
run_id | crate::local::ray::submit_local_ray_with_source(args, source, run_id).await
|
||||
);
|
||||
|
||||
backend_adapter!(
|
||||
OpenResearchCompute,
|
||||
"openresearch",
|
||||
"OpenResearch",
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
"SSH tar stream",
|
||||
false,
|
||||
preflight | args | {
|
||||
if args.flavor.is_none() {
|
||||
return Ok(not_ready("OpenResearch requires --flavor."));
|
||||
}
|
||||
if crate::config::load_credentials().await?.is_none() {
|
||||
return Ok(not_ready("OpenResearch requires `orx login`."));
|
||||
}
|
||||
ready()
|
||||
},
|
||||
submit | args,
|
||||
source,
|
||||
run_id
|
||||
| crate::local::openresearch::submit_local_openresearch_with_source(args, source, run_id)
|
||||
.await
|
||||
);
|
||||
|
||||
pub fn backend(id: &str) -> Result<Box<dyn ComputeBackend>> {
|
||||
match id {
|
||||
"local" => Ok(Box::new(LocalCompute)),
|
||||
"hf" => Ok(Box::new(HuggingFaceCompute)),
|
||||
"modal" => Ok(Box::new(ModalCompute)),
|
||||
"k8s" => Ok(Box::new(KubernetesCompute)),
|
||||
"ssh" => Ok(Box::new(SshCompute)),
|
||||
"slurm" => Ok(Box::new(SlurmCompute)),
|
||||
"ray" => Ok(Box::new(RayCompute)),
|
||||
"openresearch" => Ok(Box::new(OpenResearchCompute)),
|
||||
_ => Err(anyhow!("Unknown compute backend '{id}'.")),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn capabilities() -> Vec<Capabilities> {
|
||||
crate::local::BACKENDS
|
||||
.iter()
|
||||
.filter_map(|id| backend(id).ok())
|
||||
.map(|backend| backend.capabilities())
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub async fn submit(args: &crate::ExpRunArgs) -> Result<StoredRun> {
|
||||
let backend_id = args.backend.as_deref().unwrap_or("local");
|
||||
let backend = backend(backend_id)?;
|
||||
let store = Store::open()?;
|
||||
let experiment = store
|
||||
.get_local_experiment(&args.exp_id)?
|
||||
.ok_or_else(|| anyhow!("Local experiment {} not found.", args.exp_id))?;
|
||||
let project = store
|
||||
.get_local_project(&experiment.project_id)?
|
||||
.ok_or_else(|| anyhow!("Local project {} not found.", experiment.project_id))?;
|
||||
let preflight = backend.preflight(args).await?;
|
||||
if !preflight.ready {
|
||||
return Err(anyhow!(
|
||||
"{}",
|
||||
preflight
|
||||
.detail
|
||||
.unwrap_or_else(|| "Compute backend is not ready.".to_string())
|
||||
));
|
||||
}
|
||||
let source = backend.stage_source(&project, &experiment).await?;
|
||||
let run_id = uuid::Uuid::new_v4().to_string();
|
||||
let command = Some(experiment.run_command.clone())
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.or_else(|| {
|
||||
project
|
||||
.run_command
|
||||
.clone()
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let mut descriptor = BackendDescriptor {
|
||||
kind: format!("{}_job", backend_id),
|
||||
namespace: None,
|
||||
job_id: None,
|
||||
flavor: args.flavor.clone(),
|
||||
image: args.image.clone(),
|
||||
url: None,
|
||||
context: None,
|
||||
manifest: args.manifest.clone(),
|
||||
resources: None,
|
||||
ssh_host: None,
|
||||
ssh_port: None,
|
||||
ssh_user: None,
|
||||
timeout_secs: None,
|
||||
source_digest: None,
|
||||
source_path: None,
|
||||
source_size: None,
|
||||
};
|
||||
source.0.apply_to_descriptor(&mut descriptor);
|
||||
let now = crate::store::now_ms();
|
||||
let pending = StoredRun {
|
||||
id: run_id.clone(),
|
||||
experiment_id: experiment.id.clone(),
|
||||
project_id: project.id.clone(),
|
||||
status: "starting".to_string(),
|
||||
backend_json: descriptor.to_json(),
|
||||
command,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
ended_at: None,
|
||||
exit_code: None,
|
||||
commit_sha: Some(source.0.revision.clone()),
|
||||
result_markdown: None,
|
||||
cancel_requested: false,
|
||||
chat_session_id: crate::local::chat::launching_chat_session(),
|
||||
};
|
||||
reserve_run(&store, &pending, args.force)?;
|
||||
let pending_backend_json = descriptor.to_json();
|
||||
match backend.submit(args, source, run_id.clone()).await {
|
||||
Ok(run) => {
|
||||
if project.github_enabled() {
|
||||
if let Err(error) = crate::local::git::spawn_branch_publication(
|
||||
Path::new(&project.repo_path),
|
||||
&experiment.branch_name,
|
||||
&project.github_owner,
|
||||
&project.github_repo,
|
||||
) {
|
||||
eprintln!(
|
||||
"GitHub sync could not start; compute is already running from the source snapshot: {error}"
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(run)
|
||||
}
|
||||
Err(error) => {
|
||||
let current = store.get_run(&run_id)?;
|
||||
let handle_was_persisted = current
|
||||
.as_ref()
|
||||
.is_some_and(|run| run.backend_json != pending_backend_json);
|
||||
if !handle_was_persisted {
|
||||
store.update_status(&run_id, "failed", Some(crate::store::now_ms()), None)?;
|
||||
store
|
||||
.set_result_markdown(&run_id, &format!("Compute submission failed: {error}"))?;
|
||||
}
|
||||
Err(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn reserve_run(store: &Store, pending: &StoredRun, force: bool) -> Result<()> {
|
||||
let dir = crate::store::data_dir().join("submission-locks");
|
||||
std::fs::create_dir_all(&dir)?;
|
||||
let file = std::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.read(true)
|
||||
.write(true)
|
||||
.truncate(false)
|
||||
.open(dir.join(&pending.experiment_id))?;
|
||||
let mut lock = fd_lock::RwLock::new(file);
|
||||
let _guard = lock.write()?;
|
||||
if !force {
|
||||
if let Some(run) = store
|
||||
.list_runs_by_experiment(&pending.experiment_id)?
|
||||
.into_iter()
|
||||
.find(|run| !crate::local::is_terminal(&run.status))
|
||||
{
|
||||
return Err(anyhow!(
|
||||
"Run {} is already in flight for this experiment ({}). Cancel it with \
|
||||
`orx exp cancel {}` or pass --force to launch anyway.",
|
||||
run.id,
|
||||
run.status,
|
||||
pending.experiment_id
|
||||
));
|
||||
}
|
||||
}
|
||||
store.upsert_run(pending)
|
||||
}
|
||||
|
||||
pub fn record_submission_handle(run_id: &str, descriptor: &BackendDescriptor) -> Result<()> {
|
||||
let database_error = Store::open()
|
||||
.and_then(|store| store.set_backend_json(run_id, &descriptor.to_json()))
|
||||
.err();
|
||||
let dir = crate::store::data_dir().join("submission-handles");
|
||||
if let Err(error) = std::fs::create_dir_all(&dir) {
|
||||
return match database_error {
|
||||
None => {
|
||||
eprintln!("warning: could not create submission recovery directory: {error}");
|
||||
Ok(())
|
||||
}
|
||||
Some(database_error) => Err(anyhow!(
|
||||
"Could not persist provider handle in SQLite ({database_error}) or the recovery directory ({error})."
|
||||
)),
|
||||
};
|
||||
}
|
||||
let destination = dir.join(format!("{run_id}.json"));
|
||||
let temporary = dir.join(format!(".{run_id}.{}.tmp", uuid::Uuid::new_v4()));
|
||||
let file_error = std::fs::write(&temporary, descriptor.to_json())
|
||||
.and_then(|()| std::fs::rename(&temporary, destination))
|
||||
.err();
|
||||
if let Some(error) = file_error {
|
||||
let _ = std::fs::remove_file(temporary);
|
||||
if let Some(database_error) = database_error {
|
||||
return Err(anyhow!(
|
||||
"Could not persist provider handle in SQLite ({database_error}) or its recovery file ({error})."
|
||||
));
|
||||
}
|
||||
eprintln!("warning: could not write redundant submission recovery record: {error}");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn recover_submission_handle(run_id: &str) -> Result<Option<BackendDescriptor>> {
|
||||
let path = crate::store::data_dir()
|
||||
.join("submission-handles")
|
||||
.join(format!("{run_id}.json"));
|
||||
match std::fs::read_to_string(path) {
|
||||
Ok(json) => BackendDescriptor::parse(&json).map(Some),
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
|
||||
Err(error) => Err(error.into()),
|
||||
}
|
||||
}
|
||||
|
||||
fn not_ready(detail: impl Into<String>) -> Preflight {
|
||||
Preflight {
|
||||
ready: false,
|
||||
detail: Some(detail.into()),
|
||||
}
|
||||
}
|
||||
+172
-20
@@ -10,6 +10,8 @@
|
||||
//! plain `Bearer` header on every call including the log stream.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Stdio;
|
||||
use std::sync::OnceLock;
|
||||
use std::time::Duration;
|
||||
|
||||
@@ -19,6 +21,115 @@ use serde_json::json;
|
||||
|
||||
use crate::error::{anyhow, Result};
|
||||
|
||||
const SOURCE_LAUNCHER: &str = r#"
|
||||
import json, sys
|
||||
from huggingface_hub import HfApi
|
||||
|
||||
spec = json.load(sys.stdin)
|
||||
api = HfApi(endpoint=spec["endpoint"], token=spec["token"])
|
||||
volume = api.sync_job_volume(
|
||||
spec["sourceDir"],
|
||||
"/orx-source",
|
||||
namespace=spec["namespace"],
|
||||
remote_name="orx-" + spec["digest"],
|
||||
read_only=True,
|
||||
)
|
||||
job = api.run_job(
|
||||
image=spec["image"],
|
||||
command=spec["command"],
|
||||
env=spec["environment"],
|
||||
secrets=spec["secrets"],
|
||||
flavor=spec["flavor"],
|
||||
timeout=spec["timeoutSeconds"],
|
||||
labels=spec["labels"],
|
||||
volumes=[volume],
|
||||
namespace=spec["namespace"],
|
||||
)
|
||||
print(json.dumps({"id": job.id}))
|
||||
"#;
|
||||
|
||||
fn managed_python() -> PathBuf {
|
||||
let env = crate::config::config_dir().join("envs").join("huggingface");
|
||||
if cfg!(windows) {
|
||||
env.join("Scripts").join("python.exe")
|
||||
} else {
|
||||
env.join("bin").join("python")
|
||||
}
|
||||
}
|
||||
|
||||
async fn ensure_client_env() -> Result<PathBuf> {
|
||||
static INSTALL_LOCK: OnceLock<tokio::sync::Mutex<()>> = OnceLock::new();
|
||||
let _install = INSTALL_LOCK
|
||||
.get_or_init(|| tokio::sync::Mutex::new(()))
|
||||
.lock()
|
||||
.await;
|
||||
let python = managed_python();
|
||||
if python.exists() {
|
||||
let ready = tokio::process::Command::new(&python)
|
||||
.args([
|
||||
"-c",
|
||||
"from huggingface_hub import HfApi; assert hasattr(HfApi, 'sync_job_volume')",
|
||||
])
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.status()
|
||||
.await
|
||||
.map(|status| status.success())
|
||||
.unwrap_or(false);
|
||||
if ready {
|
||||
return Ok(python);
|
||||
}
|
||||
}
|
||||
let base = ["python3", "python"]
|
||||
.into_iter()
|
||||
.find(|candidate| {
|
||||
std::process::Command::new(candidate)
|
||||
.args(["-c", "import venv"])
|
||||
.status()
|
||||
.map(|status| status.success())
|
||||
.unwrap_or(false)
|
||||
})
|
||||
.ok_or_else(|| anyhow!("Python 3 is required to stage source for Hugging Face Jobs."))?;
|
||||
let env_dir = python
|
||||
.parent()
|
||||
.and_then(Path::parent)
|
||||
.ok_or_else(|| anyhow!("Invalid managed Hugging Face environment path."))?;
|
||||
if !python.exists() {
|
||||
if let Some(parent) = env_dir.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
let status = tokio::process::Command::new(base)
|
||||
.args(["-m", "venv"])
|
||||
.arg(env_dir)
|
||||
.status()
|
||||
.await?;
|
||||
if !status.success() {
|
||||
return Err(anyhow!(
|
||||
"Could not create the Hugging Face client environment."
|
||||
));
|
||||
}
|
||||
}
|
||||
eprintln!("orx: installing the Hugging Face source-transfer client (one time)…");
|
||||
let status = tokio::process::Command::new(&python)
|
||||
.args([
|
||||
"-m",
|
||||
"pip",
|
||||
"install",
|
||||
"--quiet",
|
||||
"--disable-pip-version-check",
|
||||
"huggingface_hub>=1.8.0",
|
||||
])
|
||||
.status()
|
||||
.await?;
|
||||
if !status.success() {
|
||||
return Err(anyhow!(
|
||||
"Could not install the Hugging Face source-transfer client."
|
||||
));
|
||||
}
|
||||
Ok(python)
|
||||
}
|
||||
|
||||
pub fn endpoint() -> String {
|
||||
std::env::var("HF_ENDPOINT").unwrap_or_else(|_| "https://huggingface.co".to_string())
|
||||
}
|
||||
@@ -188,31 +299,72 @@ pub struct JobSubmission {
|
||||
pub labels: HashMap<String, String>,
|
||||
}
|
||||
|
||||
pub async fn run_job(token: &str, namespace: &str, spec: &JobSubmission) -> Result<JobInfo> {
|
||||
// Mirror the python client: arguments/environment always present.
|
||||
let mut body = json!({
|
||||
/// Sync the immutable archive into the private `jobs-artifacts` bucket and
|
||||
/// mount it into the Job. The digest-derived remote name makes retries upload
|
||||
/// nothing when the exact source was already staged.
|
||||
pub async fn run_job_with_source(
|
||||
token: &str,
|
||||
namespace: &str,
|
||||
spec: &JobSubmission,
|
||||
archive: &Path,
|
||||
digest: &str,
|
||||
) -> Result<JobInfo> {
|
||||
let python = ensure_client_env().await?;
|
||||
let source_dir = archive
|
||||
.parent()
|
||||
.unwrap_or_else(|| Path::new("."))
|
||||
.join(format!("{digest}.hf"));
|
||||
std::fs::create_dir_all(&source_dir)?;
|
||||
let staged = source_dir.join("source.tar");
|
||||
if !staged.exists() && std::fs::hard_link(archive, &staged).is_err() {
|
||||
std::fs::copy(archive, &staged)?;
|
||||
}
|
||||
let body = json!({
|
||||
"endpoint": endpoint(),
|
||||
"token": token,
|
||||
"namespace": namespace,
|
||||
"sourceDir": source_dir,
|
||||
"digest": digest,
|
||||
"image": spec.docker_image,
|
||||
"command": spec.command,
|
||||
"arguments": [],
|
||||
"environment": spec.environment,
|
||||
"environment": super::default_unbuffered(&spec.environment),
|
||||
"secrets": spec.secrets,
|
||||
"flavor": spec.flavor,
|
||||
"dockerImage": spec.docker_image,
|
||||
"timeoutSeconds": spec.timeout_seconds,
|
||||
"labels": spec.labels,
|
||||
});
|
||||
if !spec.secrets.is_empty() {
|
||||
body["secrets"] = json!(spec.secrets);
|
||||
let mut child = tokio::process::Command::new(python)
|
||||
.args(["-c", SOURCE_LAUNCHER])
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()?;
|
||||
use tokio::io::AsyncWriteExt as _;
|
||||
child
|
||||
.stdin
|
||||
.take()
|
||||
.expect("piped stdin")
|
||||
.write_all(body.to_string().as_bytes())
|
||||
.await?;
|
||||
let output = child.wait_with_output().await?;
|
||||
if !output.status.success() {
|
||||
return Err(anyhow!(
|
||||
"Hugging Face source staging failed: {}",
|
||||
String::from_utf8_lossy(&output.stderr).trim()
|
||||
));
|
||||
}
|
||||
if !spec.labels.is_empty() {
|
||||
body["labels"] = json!(spec.labels);
|
||||
}
|
||||
let res = http()
|
||||
.post(format!("{}/api/jobs/{}", endpoint(), namespace))
|
||||
.bearer_auth(token)
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| anyhow!("Could not reach Hugging Face: {}", e))?;
|
||||
let job: JobInfo = check(res, "job submit").await?.json().await?;
|
||||
Ok(job)
|
||||
let value: serde_json::Value = serde_json::from_slice(&output.stdout)?;
|
||||
let id = value["id"]
|
||||
.as_str()
|
||||
.filter(|id| !id.is_empty())
|
||||
.ok_or_else(|| anyhow!("Hugging Face source launch returned no job id."))?;
|
||||
Ok(JobInfo {
|
||||
id: id.to_string(),
|
||||
status: JobStatus {
|
||||
stage: "SCHEDULING".to_string(),
|
||||
message: None,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn inspect_job(token: &str, namespace: &str, job_id: &str) -> Result<JobInfo> {
|
||||
|
||||
+100
-12
@@ -13,7 +13,7 @@
|
||||
//! - the manifest must contain exactly one Job (or mark one of several with
|
||||
//! the `orx-primary: "true"` label) — its completion/failure is the run's;
|
||||
//! - the Job's container command must reference `$ORX_SCRIPT`, the injected
|
||||
//! env var holding the clone-and-run script (the run command stays the
|
||||
//! env var holding the snapshot-and-run script (the run command stays the
|
||||
//! experiment's fixed contract);
|
||||
//! - orx injects run labels, the `orx-env` Secret ref, and defaults for
|
||||
//! `activeDeadlineSeconds` / `ttlSecondsAfterFinished` / `backoffLimit`
|
||||
@@ -28,6 +28,7 @@
|
||||
//! `is_terminal_stage` in `jobs/mod.rs` apply unchanged.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use std::process::Stdio;
|
||||
use std::time::Duration;
|
||||
|
||||
@@ -231,7 +232,7 @@ pub struct ManifestSpec {
|
||||
/// Raw manifest text as committed on the experiment branch (YAML or JSON,
|
||||
/// multi-document fine).
|
||||
pub manifest: String,
|
||||
/// Clone-and-run script; injected as the `ORX_SCRIPT` env var on the
|
||||
/// Snapshot-and-run script; injected as the `ORX_SCRIPT` env var on the
|
||||
/// primary Job's containers.
|
||||
pub script: String,
|
||||
/// Run-unique DNS-safe token substituted for `{{ORX_RUN}}`.
|
||||
@@ -241,6 +242,7 @@ pub struct ManifestSpec {
|
||||
/// Injected as `activeDeadlineSeconds` when the manifest doesn't set one.
|
||||
pub timeout_seconds: u64,
|
||||
pub labels: HashMap<String, String>,
|
||||
pub source_archive: PathBuf,
|
||||
}
|
||||
|
||||
pub struct Submitted {
|
||||
@@ -318,12 +320,78 @@ pub async fn run_manifest(
|
||||
}
|
||||
created.push(handle);
|
||||
}
|
||||
let staged = stage_source(context, namespace, &job_name, &spec.source_archive).await;
|
||||
if let Err(error) = staged {
|
||||
for resource in created.iter().rev() {
|
||||
let _ = delete_resources(context, namespace, std::slice::from_ref(resource)).await;
|
||||
}
|
||||
return Err(error);
|
||||
}
|
||||
Ok(Submitted {
|
||||
job_name,
|
||||
resources: created,
|
||||
})
|
||||
}
|
||||
|
||||
async fn stage_source(
|
||||
context: Option<&str>,
|
||||
namespace: &str,
|
||||
job_name: &str,
|
||||
archive: &std::path::Path,
|
||||
) -> Result<()> {
|
||||
let selector = format!("job-name={job_name}");
|
||||
let deadline = std::time::Instant::now() + Duration::from_secs(120);
|
||||
let mut last_error = String::new();
|
||||
while std::time::Instant::now() < deadline {
|
||||
let pod = kubectl(
|
||||
context,
|
||||
&[
|
||||
"get",
|
||||
"pods",
|
||||
"-n",
|
||||
namespace,
|
||||
"-l",
|
||||
&selector,
|
||||
"-o",
|
||||
"jsonpath={.items[0].metadata.name}",
|
||||
],
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
let pod = pod.trim();
|
||||
if !pod.is_empty() {
|
||||
let local = archive.to_string_lossy().into_owned();
|
||||
let remote = format!("{namespace}/{pod}:/tmp/orx-source/source.tar");
|
||||
match kubectl(context, &["cp", &local, &remote], None).await {
|
||||
Ok(_) => match kubectl(
|
||||
context,
|
||||
&[
|
||||
"exec",
|
||||
"-n",
|
||||
namespace,
|
||||
pod,
|
||||
"--",
|
||||
"touch",
|
||||
"/tmp/orx-source/source.tar.ready",
|
||||
],
|
||||
None,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(_) => return Ok(()),
|
||||
Err(error) => last_error = error.to_string(),
|
||||
},
|
||||
Err(error) => last_error = error.to_string(),
|
||||
}
|
||||
}
|
||||
tokio::time::sleep(Duration::from_secs(1)).await;
|
||||
}
|
||||
Err(anyhow!(
|
||||
"Kubernetes source staging timed out for Job {namespace}/{job_name}: {last_error}"
|
||||
))
|
||||
}
|
||||
|
||||
fn resource_handle(doc: &Value) -> String {
|
||||
format!(
|
||||
"{}/{}",
|
||||
@@ -436,6 +504,15 @@ fn prepare_docs(
|
||||
|
||||
let job = &mut docs[primary];
|
||||
let job_name = job["metadata"]["name"].as_str().unwrap_or("").to_string();
|
||||
let completions = job["spec"]["completions"].as_u64().unwrap_or(1);
|
||||
let parallelism = job["spec"]["parallelism"].as_u64().unwrap_or(1);
|
||||
if completions > 1 || parallelism > 1 || job["spec"]["completionMode"] == "Indexed" {
|
||||
return Err(anyhow!(
|
||||
"Job '{}' must run a single pod; parallel and Indexed Jobs cannot receive a \
|
||||
pod-local source snapshot safely",
|
||||
job_name
|
||||
));
|
||||
}
|
||||
label_map(&mut job["spec"]["template"]["metadata"]["labels"]);
|
||||
if job["spec"]["activeDeadlineSeconds"].is_null() {
|
||||
job["spec"]["activeDeadlineSeconds"] = json!(timeout_seconds);
|
||||
@@ -443,15 +520,14 @@ fn prepare_docs(
|
||||
if job["spec"]["ttlSecondsAfterFinished"].is_null() {
|
||||
job["spec"]["ttlSecondsAfterFinished"] = json!(86400);
|
||||
}
|
||||
if job["spec"]["backoffLimit"].is_null() {
|
||||
// Silent retries would splice two attempts into one run log.
|
||||
job["spec"]["backoffLimit"] = json!(0);
|
||||
}
|
||||
// A replacement pod would not share the pod-local snapshot staged by kubectl cp.
|
||||
job["spec"]["backoffLimit"] = json!(0);
|
||||
|
||||
let containers = job["spec"]["template"]["spec"]["containers"]
|
||||
.as_array_mut()
|
||||
.ok_or_else(|| anyhow!("the Job has no containers"))?;
|
||||
let mut references_script = false;
|
||||
let mut script_container = None;
|
||||
let mut script_container_count = 0usize;
|
||||
for c in containers.iter_mut() {
|
||||
for field in ["command", "args"] {
|
||||
if let Some(items) = c[field].as_array() {
|
||||
@@ -459,7 +535,10 @@ fn prepare_docs(
|
||||
.iter()
|
||||
.any(|a| a.as_str().is_some_and(|s| s.contains("ORX_SCRIPT")))
|
||||
{
|
||||
references_script = true;
|
||||
script_container_count += 1;
|
||||
if script_container.is_none() {
|
||||
script_container = c["name"].as_str().map(str::to_string);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -492,14 +571,23 @@ fn prepare_docs(
|
||||
}
|
||||
c["envFrom"] = json!(env_from);
|
||||
}
|
||||
if !references_script {
|
||||
let Some(script_container) = script_container else {
|
||||
return Err(anyhow!(
|
||||
"no container in Job '{}' runs the experiment: reference the injected script, \
|
||||
e.g. command: [\"bash\", \"-c\", \"$ORX_SCRIPT\"] — it clones the branch tip \
|
||||
e.g. command: [\"bash\", \"-c\", \"$ORX_SCRIPT\"] — it extracts the source snapshot \
|
||||
and runs the experiment's fixed run command",
|
||||
job_name
|
||||
));
|
||||
};
|
||||
if script_container_count != 1 {
|
||||
return Err(anyhow!(
|
||||
"Job '{}' must have exactly one container that references ORX_SCRIPT; found {}",
|
||||
job_name,
|
||||
script_container_count
|
||||
));
|
||||
}
|
||||
job["spec"]["template"]["metadata"]["annotations"]["kubectl.kubernetes.io/default-container"] =
|
||||
json!(script_container);
|
||||
|
||||
Ok((docs, job_name))
|
||||
}
|
||||
@@ -781,13 +869,13 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn author_settings_win_over_defaults() {
|
||||
fn author_deadline_wins_but_retries_are_disabled() {
|
||||
let mut j = job("train");
|
||||
j["spec"]["activeDeadlineSeconds"] = json!(60);
|
||||
j["spec"]["backoffLimit"] = json!(2);
|
||||
let (docs, _) = prepare(j).unwrap();
|
||||
assert_eq!(docs[0]["spec"]["activeDeadlineSeconds"], 60);
|
||||
assert_eq!(docs[0]["spec"]["backoffLimit"], 2);
|
||||
assert_eq!(docs[0]["spec"]["backoffLimit"], 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+22
-4
@@ -141,10 +141,28 @@ pub fn inspect_job(dir: &Path) -> JobState {
|
||||
},
|
||||
// Dead pid: run.sh may have written exit_code and exited between the
|
||||
// check above and the ps probe — re-read before calling it killed.
|
||||
Ok(_) => exit_code_state(dir).unwrap_or(JobState {
|
||||
stage: "ERROR".into(),
|
||||
message: Some("process died without an exit code (killed?)".into()),
|
||||
}),
|
||||
Ok(_) => {
|
||||
for _ in 0..3 {
|
||||
if let Some(state) = exit_code_state(dir) {
|
||||
return state;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(10));
|
||||
}
|
||||
if std::fs::metadata(dir.join("pid"))
|
||||
.and_then(|metadata| metadata.modified())
|
||||
.and_then(|modified| modified.elapsed().map_err(std::io::Error::other))
|
||||
.is_ok_and(|age| age < std::time::Duration::from_secs(1))
|
||||
{
|
||||
return JobState {
|
||||
stage: "RUNNING".into(),
|
||||
message: None,
|
||||
};
|
||||
}
|
||||
JobState {
|
||||
stage: "ERROR".into(),
|
||||
message: Some("process died without an exit code (killed?)".into()),
|
||||
}
|
||||
}
|
||||
// pid not written yet — just starting.
|
||||
Err(_) => JobState {
|
||||
stage: "RUNNING".into(),
|
||||
|
||||
@@ -80,6 +80,14 @@ pub struct BackendDescriptor {
|
||||
/// the supervisor, long after the `--timeout` flag is gone.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub timeout_secs: Option<u64>,
|
||||
/// Immutable local source archive used for this run. These fields make a
|
||||
/// delayed or restarted supervisor independent of the working tree.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub source_digest: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub source_path: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub source_size: Option<u64>,
|
||||
}
|
||||
|
||||
impl BackendDescriptor {
|
||||
@@ -249,6 +257,9 @@ mod tests {
|
||||
ssh_port: None,
|
||||
ssh_user: None,
|
||||
timeout_secs: Some(14_400),
|
||||
source_digest: None,
|
||||
source_path: None,
|
||||
source_size: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+11
-2
@@ -66,6 +66,13 @@ def submit():
|
||||
if tags:
|
||||
kwargs["tags"] = tags
|
||||
sb = modal.Sandbox.create("bash", "-c", spec["script"], **kwargs)
|
||||
if spec.get("sourceArchive"):
|
||||
try:
|
||||
sb.filesystem.copy_from_local(spec["sourceArchive"], "/tmp/orx-source.tar")
|
||||
sb.exec("touch", "/tmp/orx-source.tar.ready").wait()
|
||||
except Exception:
|
||||
sb.terminate()
|
||||
raise
|
||||
print(json.dumps({"sandboxId": sb.object_id}))
|
||||
|
||||
def status(sid):
|
||||
@@ -389,7 +396,7 @@ pub fn default_image(gpu: bool) -> String {
|
||||
}
|
||||
|
||||
pub struct ModalJobSpec {
|
||||
/// `bash -c` payload (the shared clone-and-run script).
|
||||
/// `bash -c` payload (the shared snapshot-and-run script).
|
||||
pub script: String,
|
||||
pub image: String,
|
||||
pub gpu: Option<String>,
|
||||
@@ -402,6 +409,7 @@ pub struct ModalJobSpec {
|
||||
pub app: String,
|
||||
/// Sandbox tags (or_run / or_experiment / or_project) for observability.
|
||||
pub tags: HashMap<String, String>,
|
||||
pub source_archive: Option<PathBuf>,
|
||||
}
|
||||
|
||||
/// Submit the sandbox; returns its object id (the reattach handle).
|
||||
@@ -422,6 +430,7 @@ pub async fn run_job(spec: &ModalJobSpec) -> Result<String> {
|
||||
"env": env,
|
||||
"timeoutSeconds": spec.timeout_seconds,
|
||||
"tags": spec.tags,
|
||||
"sourceArchive": spec.source_archive,
|
||||
});
|
||||
let out = launcher_capture(&["submit"], Some(&body.to_string())).await?;
|
||||
let v: Value = serde_json::from_slice(&out).map_err(|e| {
|
||||
@@ -523,7 +532,7 @@ pub struct ModalStatus {
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
/// Fail fast before doing any launch work (git push, api registration) if
|
||||
/// Fail fast before doing any source staging or provider submission if
|
||||
/// Modal isn't usable — provisioning the orx-managed env on first use. Returns
|
||||
/// a friendly, actionable error otherwise.
|
||||
pub async fn preflight() -> Result<()> {
|
||||
|
||||
+38
-1
@@ -246,6 +246,7 @@ pub struct JobSubmission {
|
||||
pub resources: RayResources,
|
||||
pub env: HashMap<String, String>,
|
||||
pub metadata: HashMap<String, String>,
|
||||
pub working_dir: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -278,10 +279,14 @@ fn map_ray_status(raw: &str) -> String {
|
||||
/// so a success needs nothing from the response body.
|
||||
pub async fn run_job(address: &str, spec: &JobSubmission) -> Result<()> {
|
||||
let env = super::default_unbuffered(&spec.env);
|
||||
let mut runtime_env = json!({ "env_vars": env });
|
||||
if let Some(working_dir) = &spec.working_dir {
|
||||
runtime_env["working_dir"] = json!(working_dir);
|
||||
}
|
||||
let mut body = json!({
|
||||
"entrypoint": spec.entrypoint,
|
||||
"submission_id": spec.submission_id,
|
||||
"runtime_env": { "env_vars": env },
|
||||
"runtime_env": runtime_env,
|
||||
"metadata": spec.metadata,
|
||||
"entrypoint_num_cpus": spec.resources.cpus,
|
||||
"entrypoint_num_gpus": spec.resources.gpus,
|
||||
@@ -299,6 +304,38 @@ pub async fn run_job(address: &str, spec: &JobSubmission) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Upload a Ray `working_dir` package through the Jobs server. Ray's own SDK
|
||||
/// uses this exact content-addressed package endpoint before submitting the
|
||||
/// job; using the snapshot digest makes the existence check restart-safe.
|
||||
pub async fn stage_working_dir(
|
||||
address: &str,
|
||||
digest: &str,
|
||||
zip: &std::path::Path,
|
||||
) -> Result<String> {
|
||||
let name = format!("_ray_pkg_{digest}.zip");
|
||||
let url = format!("{address}/api/packages/gcs/{name}");
|
||||
let existing = http()
|
||||
.get(&url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| anyhow!("Could not query Ray source package at {address}: {e}"))?;
|
||||
if existing.status() != reqwest::StatusCode::OK {
|
||||
if existing.status() != reqwest::StatusCode::NOT_FOUND {
|
||||
check(existing, "source package lookup").await?;
|
||||
}
|
||||
let file = tokio::fs::File::open(zip).await?;
|
||||
let body = reqwest::Body::wrap_stream(tokio_util::io::ReaderStream::new(file));
|
||||
let uploaded = http()
|
||||
.put(&url)
|
||||
.body(body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| anyhow!("Could not upload Ray source package at {address}: {e}"))?;
|
||||
check(uploaded, "source package upload").await?;
|
||||
}
|
||||
Ok(format!("gcs://{name}"))
|
||||
}
|
||||
|
||||
pub async fn inspect_job(address: &str, submission_id: &str) -> Result<JobInfo> {
|
||||
let res = http()
|
||||
.get(format!("{address}/api/jobs/{submission_id}"))
|
||||
|
||||
+8
-7
@@ -359,12 +359,12 @@ pub async fn cancel_job(host: &str, job_id: &str) -> Result<()> {
|
||||
|
||||
// --- preflight ----------------------------------------------------------------
|
||||
|
||||
/// Per-host readiness for the Settings UI: reachable, Slurm CLI + git
|
||||
/// Per-host readiness for the Settings UI: reachable, Slurm CLI + snapshot tools
|
||||
/// present, and which partitions exist.
|
||||
pub struct SlurmPreflight {
|
||||
pub reachable: bool,
|
||||
pub slurm_found: bool,
|
||||
pub git_found: bool,
|
||||
pub tools_found: bool,
|
||||
/// From `sinfo` (default partition's trailing `*` stripped).
|
||||
pub partitions: Vec<String>,
|
||||
pub error: Option<String>,
|
||||
@@ -373,17 +373,18 @@ pub struct SlurmPreflight {
|
||||
pub async fn preflight(host: &str) -> SlurmPreflight {
|
||||
let cmd = "if command -v sbatch >/dev/null 2>&1 && command -v squeue >/dev/null 2>&1 \
|
||||
&& command -v scancel >/dev/null 2>&1; then echo SLURM_OK; fi; \
|
||||
if command -v git >/dev/null 2>&1; then echo GIT_OK; fi; \
|
||||
if command -v bash >/dev/null 2>&1 && command -v tar >/dev/null 2>&1; \
|
||||
then echo TOOLS_OK; fi; \
|
||||
sinfo -h -o %P 2>/dev/null || true";
|
||||
match ssh_run(&SshTarget::alias(host), cmd, None).await {
|
||||
Ok(out) => {
|
||||
let mut slurm_found = false;
|
||||
let mut git_found = false;
|
||||
let mut tools_found = false;
|
||||
let mut partitions = Vec::new();
|
||||
for line in out.lines().map(str::trim).filter(|l| !l.is_empty()) {
|
||||
match line {
|
||||
"SLURM_OK" => slurm_found = true,
|
||||
"GIT_OK" => git_found = true,
|
||||
"TOOLS_OK" => tools_found = true,
|
||||
p => {
|
||||
let p = p.trim_end_matches('*').to_string();
|
||||
if !p.is_empty() && !partitions.contains(&p) {
|
||||
@@ -395,7 +396,7 @@ pub async fn preflight(host: &str) -> SlurmPreflight {
|
||||
SlurmPreflight {
|
||||
reachable: true,
|
||||
slurm_found,
|
||||
git_found,
|
||||
tools_found,
|
||||
partitions,
|
||||
error: None,
|
||||
}
|
||||
@@ -403,7 +404,7 @@ pub async fn preflight(host: &str) -> SlurmPreflight {
|
||||
Err(e) => SlurmPreflight {
|
||||
reachable: false,
|
||||
slurm_found: false,
|
||||
git_found: false,
|
||||
tools_found: false,
|
||||
partitions: Vec::new(),
|
||||
error: Some(e.to_string()),
|
||||
},
|
||||
|
||||
+148
-16
@@ -7,7 +7,7 @@
|
||||
//! polls reuse one TCP session instead of a handshake apiece.
|
||||
//!
|
||||
//! The handle is a remote run directory `~/.orx/runs/<run_id>/` holding:
|
||||
//! run.sh the launcher (exported env + clone-and-run payload)
|
||||
//! run.sh the launcher (exported env + snapshot-and-run payload)
|
||||
//! log merged stdout/stderr
|
||||
//! pid the detached process-group leader
|
||||
//! exit_code written when the payload finishes
|
||||
@@ -22,11 +22,49 @@ use tokio::process::Command;
|
||||
|
||||
use crate::error::{anyhow, Result};
|
||||
|
||||
/// Where ssh keeps its ControlMaster sockets. Created on first use.
|
||||
/// Keep sockets out of config paths, which can exceed macOS's 104-byte limit.
|
||||
#[cfg(unix)]
|
||||
fn control_dir() -> PathBuf {
|
||||
use std::hash::{Hash as _, Hasher as _};
|
||||
|
||||
let uid = unsafe { libc::geteuid() };
|
||||
let mut namespace = std::collections::hash_map::DefaultHasher::new();
|
||||
crate::config::config_dir().hash(&mut namespace);
|
||||
PathBuf::from("/tmp").join(format!("orx-ssh-{uid}-{:08x}", namespace.finish() as u32))
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
fn control_dir() -> PathBuf {
|
||||
crate::config::config_dir().join("ssh-cm")
|
||||
}
|
||||
|
||||
fn prepare_control_dir() -> Result<()> {
|
||||
let dir = control_dir();
|
||||
std::fs::create_dir_all(&dir).map_err(|e| {
|
||||
anyhow!(
|
||||
"Could not create SSH control directory {}: {e}",
|
||||
dir.display()
|
||||
)
|
||||
})?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::{MetadataExt as _, PermissionsExt as _};
|
||||
|
||||
let metadata = std::fs::symlink_metadata(&dir)?;
|
||||
let uid = unsafe { libc::geteuid() };
|
||||
if !metadata.file_type().is_dir() || metadata.uid() != uid {
|
||||
return Err(anyhow!(
|
||||
"SSH control path {} is not an owner-controlled directory.",
|
||||
dir.display()
|
||||
));
|
||||
}
|
||||
let mut permissions = metadata.permissions();
|
||||
permissions.set_mode(0o700);
|
||||
std::fs::set_permissions(&dir, permissions)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// An ssh endpoint. The classic ssh backend connects by `~/.ssh/config` alias
|
||||
/// (`SshTarget::alias`); backends that learn an endpoint at runtime (an
|
||||
/// OpenResearch box on a provider-assigned host:port) pass an explicit
|
||||
@@ -96,12 +134,8 @@ impl SshTarget {
|
||||
/// Shared ssh options: BatchMode (never hang on a prompt) + connection
|
||||
/// multiplexing so repeated polls are cheap.
|
||||
fn ssh_opts(target: &SshTarget) -> Vec<String> {
|
||||
// Not ssh's %C token: the expanded path must fit in sun_path (104 bytes
|
||||
// on macOS) and `<config dir>/ssh-cm/<40-hex>.<12-char tmp suffix>`
|
||||
// overflows it for ordinary home dirs — ssh then fails outright rather
|
||||
// than skip multiplexing. A 16-hex hash keeps it short. It folds in the
|
||||
// extra opts (where %C folds in user/host/port) so `user@host -p 2222`
|
||||
// and `user@host -p 2223` never share a control socket.
|
||||
// A 16-hex hash leaves room for ssh's temporary bind suffix. It folds in
|
||||
// the extra opts so different ports never share a control socket.
|
||||
use std::hash::{Hash, Hasher};
|
||||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||||
target.dest.hash(&mut h);
|
||||
@@ -132,7 +166,15 @@ pub(crate) async fn ssh_run(
|
||||
remote_cmd: &str,
|
||||
stdin: Option<&str>,
|
||||
) -> Result<String> {
|
||||
let _ = std::fs::create_dir_all(control_dir());
|
||||
ssh_run_bytes(target, remote_cmd, stdin.map(str::as_bytes)).await
|
||||
}
|
||||
|
||||
async fn ssh_run_bytes(
|
||||
target: &SshTarget,
|
||||
remote_cmd: &str,
|
||||
stdin: Option<&[u8]>,
|
||||
) -> Result<String> {
|
||||
prepare_control_dir()?;
|
||||
let mut cmd = Command::new("ssh");
|
||||
cmd.args(ssh_opts(target))
|
||||
.arg("--")
|
||||
@@ -155,7 +197,7 @@ pub(crate) async fn ssh_run(
|
||||
if let Some(input) = stdin {
|
||||
use tokio::io::AsyncWriteExt as _;
|
||||
if let Some(mut pipe) = child.stdin.take() {
|
||||
let _ = pipe.write_all(input.as_bytes()).await;
|
||||
let _ = pipe.write_all(input).await;
|
||||
drop(pipe); // EOF
|
||||
}
|
||||
}
|
||||
@@ -179,6 +221,78 @@ pub(crate) async fn ssh_run(
|
||||
Ok(String::from_utf8_lossy(&out.stdout).into_owned())
|
||||
}
|
||||
|
||||
async fn ssh_run_file(
|
||||
target: &SshTarget,
|
||||
remote_cmd: &str,
|
||||
source: &std::path::Path,
|
||||
) -> Result<String> {
|
||||
prepare_control_dir()?;
|
||||
let mut child = Command::new("ssh")
|
||||
.args(ssh_opts(target))
|
||||
.arg("--")
|
||||
.arg(&target.dest)
|
||||
.arg(remote_cmd)
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.map_err(|e| anyhow!("Could not run ssh: {e}"))?;
|
||||
let mut file = tokio::fs::File::open(source).await?;
|
||||
if let Some(mut pipe) = child.stdin.take() {
|
||||
tokio::io::copy(&mut file, &mut pipe).await?;
|
||||
drop(pipe);
|
||||
}
|
||||
let out = child
|
||||
.wait_with_output()
|
||||
.await
|
||||
.map_err(|e| anyhow!("ssh wait failed: {e}"))?;
|
||||
if !out.status.success() {
|
||||
return Err(anyhow!(
|
||||
"ssh {} failed: {}",
|
||||
target.dest,
|
||||
String::from_utf8_lossy(&out.stderr).trim()
|
||||
));
|
||||
}
|
||||
Ok(String::from_utf8_lossy(&out.stdout).into_owned())
|
||||
}
|
||||
|
||||
/// Upload a content-addressed tar once, then materialize it into this run's
|
||||
/// private `repo/` directory. Both the cache write and extraction are safe to
|
||||
/// repeat after a client or supervisor restart.
|
||||
pub async fn stage_source(
|
||||
target: &SshTarget,
|
||||
run_id: &str,
|
||||
archive: &std::path::Path,
|
||||
digest: &str,
|
||||
) -> Result<String> {
|
||||
let dir = format!(".orx/runs/{run_id}");
|
||||
let cache = format!(".orx/source/{digest}.tar");
|
||||
let present = ssh_run(
|
||||
target,
|
||||
&format!("test -f \"$HOME/{cache}\" && echo present || true"),
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
if present.trim() != "present" {
|
||||
let upload = format!(
|
||||
"umask 077; mkdir -p \"$HOME/.orx/source\"; \
|
||||
tmp=\"$HOME/{cache}.tmp.$$\"; cat > \"$tmp\" && mv \"$tmp\" \"$HOME/{cache}\""
|
||||
);
|
||||
ssh_run_file(target, &upload, archive).await?;
|
||||
}
|
||||
ssh_run(
|
||||
target,
|
||||
&format!(
|
||||
"umask 077; mkdir -p \"$HOME/.orx/runs\" \"$HOME/{dir}/repo\"; \
|
||||
chmod 700 \"$HOME/.orx/runs\" \"$HOME/{dir}\" \"$HOME/{dir}/repo\"; \
|
||||
tar -xf \"$HOME/{cache}\" -C \"$HOME/{dir}/repo\""
|
||||
),
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
Ok(dir)
|
||||
}
|
||||
|
||||
/// Single-quote a value for safe embedding in the remote bash script.
|
||||
pub(crate) fn sh_quote(s: &str) -> String {
|
||||
format!("'{}'", s.replace('\'', "'\\''"))
|
||||
@@ -189,7 +303,7 @@ pub struct SshJobSpec {
|
||||
pub target: SshTarget,
|
||||
/// Names the remote run dir `~/.orx/runs/<run_id>`.
|
||||
pub run_id: String,
|
||||
/// The shared clone-and-run payload (`bash` script body).
|
||||
/// The shared snapshot-and-run payload (`bash` script body).
|
||||
pub script: String,
|
||||
/// Exported inside run.sh on the remote (tokens, synced env).
|
||||
pub env: HashMap<String, String>,
|
||||
@@ -320,29 +434,30 @@ pub async fn cancel_job(target: &SshTarget, dir: &str) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Per-host readiness for the Settings UI: can we reach it, and is `git` there?
|
||||
/// Per-host readiness for the Settings UI: can we reach it and execute snapshots?
|
||||
pub struct SshPreflight {
|
||||
pub reachable: bool,
|
||||
pub git_found: bool,
|
||||
pub tools_found: bool,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn preflight(target: &SshTarget) -> SshPreflight {
|
||||
match ssh_run(
|
||||
target,
|
||||
"command -v git >/dev/null 2>&1 && echo GIT_OK || echo NO_GIT",
|
||||
"command -v bash >/dev/null 2>&1 && command -v tar >/dev/null 2>&1 \
|
||||
&& echo TOOLS_OK || echo NO_TOOLS",
|
||||
None,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(out) => SshPreflight {
|
||||
reachable: true,
|
||||
git_found: out.contains("GIT_OK"),
|
||||
tools_found: out.contains("TOOLS_OK"),
|
||||
error: None,
|
||||
},
|
||||
Err(e) => SshPreflight {
|
||||
reachable: false,
|
||||
git_found: false,
|
||||
tools_found: false,
|
||||
error: Some(e.to_string()),
|
||||
},
|
||||
}
|
||||
@@ -410,4 +525,21 @@ mod tests {
|
||||
assert_ne!(control_path(&mk("22022")), control_path(&mk("22023")));
|
||||
assert_eq!(control_path(&mk("22022")), control_path(&mk("22022")));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn control_path_fits_macos_unix_socket_limit() {
|
||||
let option = ssh_opts(&SshTarget::host_port(
|
||||
"root@ssh3.vast.ai".into(),
|
||||
22,
|
||||
HostKeyPolicy::Ephemeral,
|
||||
))
|
||||
.into_iter()
|
||||
.find(|o| o.starts_with("ControlPath="))
|
||||
.unwrap();
|
||||
let path = option.strip_prefix("ControlPath=").unwrap();
|
||||
|
||||
assert!(path.starts_with("/tmp/orx-ssh-"));
|
||||
assert!(path.len() + 17 < 104, "{path}");
|
||||
}
|
||||
}
|
||||
|
||||
+25
-39
@@ -55,16 +55,13 @@ pub enum SkillSet {
|
||||
// public skill name) ----------------------------------------------------------
|
||||
|
||||
const COMPUTE_LOCAL: &str = include_str!("../../agent-skills/orx-compute/SKILL.local.md");
|
||||
const COMPUTE_LOCAL_ONLY: &str = include_str!("../../agent-skills/orx-compute/SKILL.local-only.md");
|
||||
const COMPUTE_CLOUD: &str = include_str!("../../agent-skills/orx-compute/SKILL.md");
|
||||
const COMPUTE_K8S: &str = include_str!("../../agent-skills/orx-compute-k8s/SKILL.md");
|
||||
const EXPERIMENT_TREE_LOCAL: &str =
|
||||
include_str!("../../agent-skills/orx-experiment-tree/SKILL.local.md");
|
||||
const EXPERIMENT_TREE_CLOUD: &str = include_str!("../../agent-skills/orx-experiment-tree/SKILL.md");
|
||||
const GIT_EDITING: &str = include_str!("../../agent-skills/orx-git/SKILL.md");
|
||||
const EXPERIMENT_TREE_LOCAL_ONLY: &str =
|
||||
include_str!("../../agent-skills/orx-experiment-tree/SKILL.local-only.md");
|
||||
const GIT_LOCAL_ONLY: &str = include_str!("../../agent-skills/orx-git/SKILL.local-only.md");
|
||||
const GIT_LOCAL: &str = include_str!("../../agent-skills/orx-git/SKILL.local.md");
|
||||
const GIT_CLOUD: &str = include_str!("../../agent-skills/orx-git/SKILL.md");
|
||||
const LIT: &str = include_str!("../../agent-skills/orx-lit/SKILL.md");
|
||||
const CREATE: &str = include_str!("../../agent-skills/orx-create/SKILL.md");
|
||||
const REPORTS_LOCAL: &str = include_str!("../../agent-skills/orx-reports/SKILL.local.md");
|
||||
@@ -96,7 +93,7 @@ const S_COMPUTE_CLOUD: AgentSkill = AgentSkill {
|
||||
};
|
||||
const S_COMPUTE_K8S: AgentSkill = AgentSkill {
|
||||
name: "orx-compute-k8s",
|
||||
description: "Run an experiment on your own Kubernetes cluster (`orx exp run --backend k8s`): the committed-manifest contract orx enforces at submit. Use when the user names k8s, kubernetes, or a cluster, before writing or editing `.orx/k8s.yaml`, for multi-node or Indexed Jobs, or when a k8s submit is rejected.",
|
||||
description: "Run an experiment on your own Kubernetes cluster (`orx exp run --backend k8s`): the single-pod committed-manifest contract orx enforces at submit. Use when the user names k8s, kubernetes, or a cluster, before writing or editing `.orx/k8s.yaml`, or when a k8s submit is rejected.",
|
||||
content: COMPUTE_K8S,
|
||||
};
|
||||
const S_EXPERIMENT_TREE_LOCAL: AgentSkill = AgentSkill {
|
||||
@@ -109,10 +106,15 @@ const S_EXPERIMENT_TREE_CLOUD: AgentSkill = AgentSkill {
|
||||
description: D_EXPERIMENT_TREE,
|
||||
content: EXPERIMENT_TREE_CLOUD,
|
||||
};
|
||||
const S_GIT: AgentSkill = AgentSkill {
|
||||
const S_GIT_LOCAL: AgentSkill = AgentSkill {
|
||||
name: "orx-git",
|
||||
description: "Read, edit, and diff a node's code with plain git: sync, commit, and push before running. Use whenever you touch experiment code — before editing any branch, when a checkout or push fails, when comparing two nodes' code, or when a run seems to have picked up stale code.",
|
||||
content: GIT_EDITING,
|
||||
description: "Read, edit, commit, and diff experiment code with local Git. Use whenever you touch an experiment branch, compare nodes, prepare a run, or diagnose stale code.",
|
||||
content: GIT_LOCAL,
|
||||
};
|
||||
const S_GIT_CLOUD: AgentSkill = AgentSkill {
|
||||
name: "orx-git",
|
||||
description: "Read, edit, commit, and diff experiment code with Git. Use whenever you touch a branch, compare nodes, prepare a run, diagnose stale code, or publish changes.",
|
||||
content: GIT_CLOUD,
|
||||
};
|
||||
const S_LIT: AgentSkill = AgentSkill {
|
||||
name: "orx-lit",
|
||||
@@ -152,7 +154,7 @@ pub fn skills(set: SkillSet) -> Vec<&'static AgentSkill> {
|
||||
match set {
|
||||
SkillSet::Local => vec![
|
||||
&S_EXPERIMENT_TREE_LOCAL,
|
||||
&S_GIT,
|
||||
&S_GIT_LOCAL,
|
||||
&S_COMPUTE_LOCAL,
|
||||
&S_COMPUTE_K8S,
|
||||
&S_EVIDENCE_LOCAL,
|
||||
@@ -162,7 +164,7 @@ pub fn skills(set: SkillSet) -> Vec<&'static AgentSkill> {
|
||||
SkillSet::Full => vec![
|
||||
&S_CREATE,
|
||||
&S_EXPERIMENT_TREE_CLOUD,
|
||||
&S_GIT,
|
||||
&S_GIT_CLOUD,
|
||||
&S_COMPUTE_CLOUD,
|
||||
&S_COMPUTE_K8S,
|
||||
&S_EVIDENCE_CLOUD,
|
||||
@@ -183,31 +185,16 @@ pub fn find(name: &str, set: SkillSet) -> Option<&'static AgentSkill> {
|
||||
.find(|s| s.name == want || s.name.strip_prefix("orx-") == Some(want))
|
||||
}
|
||||
|
||||
pub fn available_in_session(skill: &AgentSkill, github_enabled: bool) -> bool {
|
||||
github_enabled || skill.name != "orx-compute-k8s"
|
||||
pub fn available_in_session(_skill: &AgentSkill, _github_enabled: bool) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
pub fn session_content(skill: &AgentSkill, github_enabled: bool) -> &'static str {
|
||||
if github_enabled {
|
||||
return skill.content;
|
||||
}
|
||||
match skill.name {
|
||||
"orx-git" => GIT_LOCAL_ONLY,
|
||||
"orx-compute" => COMPUTE_LOCAL_ONLY,
|
||||
"orx-experiment-tree" => EXPERIMENT_TREE_LOCAL_ONLY,
|
||||
_ => skill.content,
|
||||
}
|
||||
pub fn session_content(skill: &AgentSkill, _github_enabled: bool) -> &'static str {
|
||||
skill.content
|
||||
}
|
||||
|
||||
pub fn session_description(skill: &AgentSkill, github_enabled: bool) -> &'static str {
|
||||
if github_enabled {
|
||||
return skill.description;
|
||||
}
|
||||
match skill.name {
|
||||
"orx-git" => "Read, edit, commit, and diff experiment code with local Git only.",
|
||||
"orx-compute" => "Launch committed experiments on this machine and inspect their logs.",
|
||||
_ => skill.description,
|
||||
}
|
||||
pub fn session_description(skill: &AgentSkill, _github_enabled: bool) -> &'static str {
|
||||
skill.description
|
||||
}
|
||||
|
||||
/// Write the [`SkillSet::Local`] modules as `<worktree>/<skills_dir_rel>/<name>/SKILL.md`,
|
||||
@@ -438,7 +425,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_only_session_skills_override_push_instructions() {
|
||||
fn github_disabled_sessions_keep_snapshot_compute_available() {
|
||||
let tmp = std::env::temp_dir().join(format!(
|
||||
"orx-local-only-skills-test-{}",
|
||||
uuid::Uuid::new_v4()
|
||||
@@ -448,13 +435,12 @@ mod tests {
|
||||
assert!(tmp.join(rel).join("orx-compute-k8s").exists());
|
||||
ensure_session_skills(&tmp, rel, false).unwrap();
|
||||
let git = std::fs::read_to_string(tmp.join(rel).join("orx-git/SKILL.md")).unwrap();
|
||||
assert!(git.contains("This project is local-only"));
|
||||
assert!(!git.contains("git push"));
|
||||
assert!(!git.contains("origin/"));
|
||||
assert!(git.contains("never part of compute transport"));
|
||||
assert!(git.contains("do not push merely to launch compute"));
|
||||
let compute = std::fs::read_to_string(tmp.join(rel).join("orx-compute/SKILL.md")).unwrap();
|
||||
assert!(!compute.contains("branch tip"));
|
||||
assert!(!compute.contains("git push"));
|
||||
assert!(!tmp.join(rel).join("orx-compute-k8s").exists());
|
||||
assert!(compute.contains("immutable source snapshot"));
|
||||
assert!(compute.contains("Hugging Face Jobs"));
|
||||
assert!(tmp.join(rel).join("orx-compute-k8s").exists());
|
||||
let _ = std::fs::remove_dir_all(tmp);
|
||||
}
|
||||
|
||||
|
||||
@@ -496,12 +496,6 @@ async fn spawn_client(session_id: &str) -> Result<Arc<CodexClient>> {
|
||||
if let Some(dir) = ensure_orx_data_dir() {
|
||||
cmd.env("ORX_DATA_DIR", &dir);
|
||||
}
|
||||
// The sandbox blocks the keyring `gh` keeps its token in; resolve it out
|
||||
// here and pass it down. Resolved once per child, not per turn.
|
||||
if let Some(token) = crate::local::git::resolve_github_token() {
|
||||
cmd.env("GH_TOKEN", &token);
|
||||
cmd.env("GITHUB_TOKEN", token);
|
||||
}
|
||||
// Own process group: a terminal SIGINT reaches orx up alone, which then
|
||||
// tears the child down deliberately (kill_on_drop / shutdown()).
|
||||
#[cfg(unix)]
|
||||
|
||||
@@ -64,8 +64,8 @@ pub fn legacy_root_warning(project: &LocalProject, experiment: &LocalExperiment)
|
||||
})
|
||||
}
|
||||
|
||||
/// Create a local experiment. Every node gets its own `orx/<slug>` branch,
|
||||
/// pushed to origin: a child forks off its parent's tip, a baseline/root off
|
||||
/// Create a local experiment. Every node gets its own `orx/<slug>` branch:
|
||||
/// a child forks off its parent's tip, a baseline/root off
|
||||
/// the project's base branch. The base branch itself is never an experiment
|
||||
/// node — it stays mutable (README, notebooks, publication surface) while
|
||||
/// `orx/*` branches hold the experiment nodes' recorded code. Matches the
|
||||
@@ -98,10 +98,7 @@ pub fn create_experiment(
|
||||
.map(|p| p.branch_name.as_str())
|
||||
.unwrap_or(&project.baseline_branch);
|
||||
let branch_name = format!("orx/{slug}");
|
||||
let publication = project
|
||||
.github_enabled()
|
||||
.then_some((project.github_owner.as_str(), project.github_repo.as_str()));
|
||||
git::create_experiment_branch(repo, fork_point, &branch_name, publication)?;
|
||||
git::create_experiment_branch(repo, fork_point, &branch_name)?;
|
||||
|
||||
// Inherit: explicit > parent's command > project default > "".
|
||||
let run_command = run_command
|
||||
|
||||
+252
-305
@@ -3,8 +3,13 @@
|
||||
//! `~/.cache/openresearch/repos/<owner>/<repo>`, the same convention SKILL.md
|
||||
//! documents for manual diffing.
|
||||
|
||||
use std::io::Read;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
use std::process::{Child, Command, Stdio};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
#[cfg(unix)]
|
||||
use std::os::unix::process::CommandExt;
|
||||
|
||||
use crate::error::{anyhow, Result};
|
||||
|
||||
@@ -170,7 +175,15 @@ pub fn initialize_repository(path: &Path) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn clone_public(url: &str, path: &Path) -> Result<()> {
|
||||
fn public_clone_history_args(shallow: bool) -> &'static [&'static str] {
|
||||
if shallow {
|
||||
&["--depth=1", "--single-branch"]
|
||||
} else {
|
||||
&[]
|
||||
}
|
||||
}
|
||||
|
||||
pub fn clone_public(url: &str, path: &Path, shallow: bool) -> Result<()> {
|
||||
let url = public_clone_url(url)?;
|
||||
let empty_config = std::env::temp_dir().join(format!(
|
||||
"orx-public-clone-{}.gitconfig",
|
||||
@@ -180,7 +193,8 @@ pub fn clone_public(url: &str, path: &Path) -> Result<()> {
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.open(&empty_config)?;
|
||||
let output = Command::new("git")
|
||||
let mut command = Command::new("git");
|
||||
command
|
||||
.current_dir(std::env::temp_dir())
|
||||
.env("GIT_TERMINAL_PROMPT", "0")
|
||||
.env("GIT_ASKPASS", "")
|
||||
@@ -197,16 +211,11 @@ pub fn clone_public(url: &str, path: &Path) -> Result<()> {
|
||||
.env_remove("GIT_OBJECT_DIRECTORY")
|
||||
.env_remove("GIT_ALTERNATE_OBJECT_DIRECTORIES")
|
||||
.env("GIT_SSH_COMMAND", "false")
|
||||
.args([
|
||||
"-c",
|
||||
"credential.helper=",
|
||||
"-c",
|
||||
"core.askPass=",
|
||||
"clone",
|
||||
&url,
|
||||
&path.to_string_lossy(),
|
||||
])
|
||||
.output();
|
||||
.args(["-c", "credential.helper=", "-c", "core.askPass=", "clone"])
|
||||
.args(public_clone_history_args(shallow))
|
||||
.arg(&url)
|
||||
.arg(path);
|
||||
let output = command.output();
|
||||
let _ = std::fs::remove_file(&empty_config);
|
||||
let out = output.map_err(|error| anyhow!("Could not run git clone: {error}"))?;
|
||||
if !out.status.success() {
|
||||
@@ -276,7 +285,14 @@ pub fn validate_project_repository(path: &Path) -> Result<()> {
|
||||
}
|
||||
|
||||
pub fn local_head_sha(path: &Path, branch: &str) -> Result<String> {
|
||||
git(Some(path), &["rev-parse", &format!("{branch}^{{commit}}")])
|
||||
git(
|
||||
Some(path),
|
||||
&[
|
||||
"rev-parse",
|
||||
"--verify",
|
||||
&format!("refs/heads/{branch}^{{commit}}"),
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
pub fn remotes(path: &Path) -> Result<Vec<(String, String)>> {
|
||||
@@ -315,11 +331,13 @@ fn sanitize_remote_url(url: &str) -> String {
|
||||
}
|
||||
|
||||
pub fn github_publication(path: &Path) -> Option<(String, String)> {
|
||||
[GITHUB_REMOTE, "origin"].into_iter().find_map(|remote| {
|
||||
let url = git(Some(path), &["remote", "get-url", remote]).ok()?;
|
||||
let (owner, repo) = parse_github_url(&url)?;
|
||||
remote_matches_publication(path, remote, &owner, &repo).then_some((owner, repo))
|
||||
})
|
||||
[GITHUB_REMOTE, "origin", "upstream"]
|
||||
.into_iter()
|
||||
.find_map(|remote| {
|
||||
let url = git(Some(path), &["remote", "get-url", remote]).ok()?;
|
||||
let (owner, repo) = parse_github_url(&url)?;
|
||||
remote_matches_publication(path, remote, &owner, &repo).then_some((owner, repo))
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_github_url(url: &str) -> Option<(String, String)> {
|
||||
@@ -349,6 +367,10 @@ fn parse_github_url(url: &str) -> Option<(String, String)> {
|
||||
.then(|| (owner.to_string(), repo.to_string()))
|
||||
}
|
||||
|
||||
pub fn github_repository(url: &str) -> Option<(String, String)> {
|
||||
parse_github_url(url)
|
||||
}
|
||||
|
||||
fn github_repository_matches(url: &str, owner: &str, repo: &str) -> bool {
|
||||
parse_github_url(url).is_some_and(|(remote_owner, remote_repo)| {
|
||||
remote_owner.eq_ignore_ascii_case(owner) && remote_repo.eq_ignore_ascii_case(repo)
|
||||
@@ -413,24 +435,22 @@ pub fn identity(
|
||||
(name, email, name_source, email_source)
|
||||
}
|
||||
|
||||
/// `GITHUB_TOKEN` env, else the synced env file (UI-pasted token), else
|
||||
/// `gh auth token`, else None (public-repo fallback).
|
||||
pub fn resolve_github_token() -> Option<String> {
|
||||
if let Ok(t) = std::env::var("GITHUB_TOKEN") {
|
||||
let t = t.trim().to_string();
|
||||
if !t.is_empty() {
|
||||
return Some(t);
|
||||
if let Ok(token) = std::env::var("GITHUB_TOKEN") {
|
||||
let token = token.trim().to_string();
|
||||
if !token.is_empty() {
|
||||
return Some(token);
|
||||
}
|
||||
}
|
||||
if let Some(t) = crate::config::synced_env_var("GITHUB_TOKEN") {
|
||||
return Some(t);
|
||||
if let Some(token) = crate::config::synced_env_var("GITHUB_TOKEN") {
|
||||
return Some(token);
|
||||
}
|
||||
let out = Command::new("gh").args(["auth", "token"]).output().ok()?;
|
||||
if !out.status.success() {
|
||||
let output = Command::new("gh").args(["auth", "token"]).output().ok()?;
|
||||
if !output.status.success() {
|
||||
return None;
|
||||
}
|
||||
let t = String::from_utf8_lossy(&out.stdout).trim().to_string();
|
||||
(!t.is_empty()).then_some(t)
|
||||
let token = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
(!token.is_empty()).then_some(token)
|
||||
}
|
||||
|
||||
/// Fail early on a typo'd baseline branch — otherwise it only surfaces much
|
||||
@@ -738,8 +758,19 @@ fn seed_copy_in(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn local_branches(repo_path: &Path) -> Result<Vec<String>> {
|
||||
Ok(git(
|
||||
Some(repo_path),
|
||||
&["for-each-ref", "--format=%(refname:short)", "refs/heads"],
|
||||
)?
|
||||
.lines()
|
||||
.filter(|branch| !branch.is_empty())
|
||||
.map(str::to_string)
|
||||
.collect())
|
||||
}
|
||||
|
||||
fn publication_remote(repo_path: &Path, owner: &str, repo: &str) -> Result<String> {
|
||||
for remote in [GITHUB_REMOTE, "origin"] {
|
||||
for remote in [GITHUB_REMOTE, "origin", "upstream"] {
|
||||
if remote_matches_publication(repo_path, remote, owner, repo) {
|
||||
return Ok(remote.to_string());
|
||||
}
|
||||
@@ -749,10 +780,82 @@ fn publication_remote(repo_path: &Path, owner: &str, repo: &str) -> Result<Strin
|
||||
))
|
||||
}
|
||||
|
||||
pub fn is_shallow_repository(repo_path: &Path) -> Result<bool> {
|
||||
Ok(git(Some(repo_path), &["rev-parse", "--is-shallow-repository"])? == "true")
|
||||
}
|
||||
|
||||
pub fn reroot_shallow_repository(
|
||||
repo_path: &Path,
|
||||
baseline_branch: &str,
|
||||
source: Option<&(String, String)>,
|
||||
) -> Result<()> {
|
||||
if !is_shallow_repository(repo_path)? {
|
||||
return Ok(());
|
||||
}
|
||||
if !is_clean(repo_path)? {
|
||||
return Err(anyhow!(
|
||||
"Cannot prepare a shallow paper import for publication because the working tree has changes."
|
||||
));
|
||||
}
|
||||
let temporary = format!("orx-import-{}", uuid::Uuid::new_v4().simple());
|
||||
git(Some(repo_path), &["checkout", "--orphan", &temporary])?;
|
||||
git(Some(repo_path), &["add", "-A"])?;
|
||||
let source_name = source
|
||||
.map(|(owner, repo)| format!("{owner}/{repo}"))
|
||||
.unwrap_or_else(|| "paper repository".to_string());
|
||||
git(
|
||||
Some(repo_path),
|
||||
&[
|
||||
"-c",
|
||||
"user.name=OpenResearch",
|
||||
"-c",
|
||||
"user.email=local@openresearch.sh",
|
||||
"commit",
|
||||
"--allow-empty",
|
||||
"-m",
|
||||
&format!("Import snapshot from {source_name}"),
|
||||
],
|
||||
)?;
|
||||
git(Some(repo_path), &["branch", "-M", baseline_branch])?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn prepare_shallow_repository_for_publication(repo_path: &Path) -> Result<bool> {
|
||||
if !is_shallow_repository(repo_path)? {
|
||||
return Ok(false);
|
||||
}
|
||||
if local_branches(repo_path)?
|
||||
.iter()
|
||||
.any(|branch| branch.starts_with("orx/"))
|
||||
{
|
||||
let remote = ["upstream", "origin"]
|
||||
.into_iter()
|
||||
.find(|remote| git(Some(repo_path), &["remote", "get-url", remote]).is_ok())
|
||||
.ok_or_else(|| anyhow!("The shallow project has no source remote to deepen."))?;
|
||||
git(Some(repo_path), &["fetch", "--unshallow", remote])?;
|
||||
return Ok(false);
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
const GITHUB_CREDENTIAL_HELPER: &str =
|
||||
"!f() { host=; while IFS='=' read key value; do [ \"$key\" = host ] && host=$value; done; [ \"$host\" = github.com ] || exit 0; echo username=x-access-token; echo \"password=$ORX_GITHUB_TOKEN\"; }; f";
|
||||
|
||||
fn authenticated_git(repo_path: &Path, args: &[&str]) -> Result<String> {
|
||||
fn redact_remote_urls(text: &str) -> String {
|
||||
text.split_whitespace()
|
||||
.map(|word| {
|
||||
let bare = word.trim_matches(|ch: char| "'\"`()[]{}<>,".contains(ch));
|
||||
if bare.to_ascii_lowercase().contains("://") {
|
||||
word.replace(bare, &sanitize_remote_url(bare))
|
||||
} else {
|
||||
word.to_string()
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
}
|
||||
|
||||
fn authenticated_git(repo_path: &Path, args: &[&str], timeout: Duration) -> Result<String> {
|
||||
let mut command = Command::new("git");
|
||||
command
|
||||
.current_dir(repo_path)
|
||||
@@ -772,93 +875,82 @@ fn authenticated_git(repo_path: &Path, args: &[&str]) -> Result<String> {
|
||||
.env("GIT_CONFIG_VALUE_2", "/dev/null")
|
||||
.env("ORX_GITHUB_TOKEN", token);
|
||||
}
|
||||
let output = command
|
||||
#[cfg(unix)]
|
||||
command.process_group(0);
|
||||
let mut child = command
|
||||
.args(args)
|
||||
.output()
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.map_err(|error| anyhow!("Could not run git: {error}"))?;
|
||||
if !output.status.success() {
|
||||
let mut error = String::from_utf8_lossy(&output.stderr).trim().to_string();
|
||||
if let Some(token) = token {
|
||||
error = error.replace(&token, "[redacted]");
|
||||
let mut stdout = child
|
||||
.stdout
|
||||
.take()
|
||||
.ok_or_else(|| anyhow!("Could not capture git stdout"))?;
|
||||
let mut stderr = child
|
||||
.stderr
|
||||
.take()
|
||||
.ok_or_else(|| anyhow!("Could not capture git stderr"))?;
|
||||
let stdout_reader = std::thread::spawn(move || {
|
||||
let mut output = Vec::new();
|
||||
stdout.read_to_end(&mut output).map(|_| output)
|
||||
});
|
||||
let stderr_reader = std::thread::spawn(move || {
|
||||
let mut output = Vec::new();
|
||||
stderr.read_to_end(&mut output).map(|_| output)
|
||||
});
|
||||
let deadline = Instant::now() + timeout;
|
||||
let (status, timed_out) = loop {
|
||||
if let Some(status) = child.try_wait()? {
|
||||
break (status, false);
|
||||
}
|
||||
error = redact_remote_urls(&error);
|
||||
if Instant::now() >= deadline {
|
||||
terminate_git_process_tree(&mut child);
|
||||
break (child.wait()?, true);
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
};
|
||||
let stdout = stdout_reader
|
||||
.join()
|
||||
.map_err(|_| anyhow!("Could not collect git stdout"))??;
|
||||
let stderr = stderr_reader
|
||||
.join()
|
||||
.map_err(|_| anyhow!("Could not collect git stderr"))??;
|
||||
if timed_out {
|
||||
return Err(anyhow!(
|
||||
"git {} failed: {error}",
|
||||
args.first().copied().unwrap_or("command")
|
||||
"git {} timed out after {} seconds",
|
||||
args.first().copied().unwrap_or("command"),
|
||||
timeout.as_secs()
|
||||
));
|
||||
}
|
||||
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
|
||||
}
|
||||
|
||||
fn isolated_github_refs(url: &str, branch: &str) -> Result<String> {
|
||||
let scratch =
|
||||
std::env::temp_dir().join(format!("orx-github-preflight-{}", uuid::Uuid::new_v4()));
|
||||
std::fs::create_dir(&scratch)?;
|
||||
let empty_config = scratch.join("global.gitconfig");
|
||||
std::fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.open(&empty_config)?;
|
||||
let token = resolve_github_token();
|
||||
let mut command = Command::new("git");
|
||||
command
|
||||
.current_dir(&scratch)
|
||||
.env("GIT_TERMINAL_PROMPT", "0")
|
||||
.env("GIT_ASKPASS", "")
|
||||
.env("SSH_ASKPASS", "")
|
||||
.env("SSH_ASKPASS_REQUIRE", "never")
|
||||
.env("GIT_CONFIG_NOSYSTEM", "1")
|
||||
.env("GIT_CONFIG_GLOBAL", &empty_config)
|
||||
.env_remove("GIT_CONFIG_PARAMETERS")
|
||||
.env_remove("GIT_DIR")
|
||||
.env_remove("GIT_WORK_TREE")
|
||||
.env_remove("GIT_COMMON_DIR")
|
||||
.env("GIT_SSH_COMMAND", "false");
|
||||
if let Some(token) = &token {
|
||||
command
|
||||
.env("GIT_CONFIG_COUNT", "2")
|
||||
.env("GIT_CONFIG_KEY_0", "credential.helper")
|
||||
.env("GIT_CONFIG_VALUE_0", "")
|
||||
.env("GIT_CONFIG_KEY_1", "credential.helper")
|
||||
.env("GIT_CONFIG_VALUE_1", GITHUB_CREDENTIAL_HELPER)
|
||||
.env("ORX_GITHUB_TOKEN", token);
|
||||
} else {
|
||||
command.env_remove("GIT_CONFIG_COUNT");
|
||||
}
|
||||
let output = command.args(["ls-remote", "--heads", url, branch]).output();
|
||||
let _ = std::fs::remove_dir_all(&scratch);
|
||||
let output = output.map_err(|error| anyhow!("Could not run GitHub preflight: {error}"))?;
|
||||
if !output.status.success() {
|
||||
let mut error = String::from_utf8_lossy(&output.stderr).trim().to_string();
|
||||
if !status.success() {
|
||||
let mut error = String::from_utf8_lossy(&stderr).trim().to_string();
|
||||
if let Some(token) = token {
|
||||
error = error.replace(&token, "[redacted]");
|
||||
}
|
||||
return Err(anyhow!(
|
||||
"GitHub HTTPS preflight failed: {}",
|
||||
"git {} failed: {}",
|
||||
args.first().copied().unwrap_or("command"),
|
||||
redact_remote_urls(&error)
|
||||
));
|
||||
}
|
||||
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
|
||||
Ok(String::from_utf8_lossy(&stdout).trim().to_string())
|
||||
}
|
||||
|
||||
fn redact_remote_urls(text: &str) -> String {
|
||||
text.split_whitespace()
|
||||
.map(|word| {
|
||||
let bare = word.trim_matches(|ch: char| "'\"`()[]{}<>,".contains(ch));
|
||||
if bare.to_ascii_lowercase().contains("://") {
|
||||
word.replace(bare, &sanitize_remote_url(bare))
|
||||
} else {
|
||||
word.to_string()
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
fn terminate_git_process_tree(child: &mut Child) {
|
||||
#[cfg(unix)]
|
||||
unsafe {
|
||||
// Git owns this process group, including credential-bearing transport children.
|
||||
libc::kill(-(child.id() as i32), libc::SIGKILL);
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
let _ = child.kill();
|
||||
}
|
||||
|
||||
fn push(repo_path: &Path, args: &[&str]) -> Result<()> {
|
||||
let mut command = vec!["push"];
|
||||
command.extend_from_slice(args);
|
||||
authenticated_git(repo_path, &command)?;
|
||||
authenticated_git(repo_path, &command, Duration::from_secs(600))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -900,31 +992,48 @@ pub fn add_github_remote(repo_path: &Path, owner: &str, repo: &str) -> Result<()
|
||||
|
||||
pub fn push_all(repo_path: &Path, baseline_branch: &str, owner: &str, repo: &str) -> Result<()> {
|
||||
let remote = publication_remote(repo_path, owner, repo)?;
|
||||
push_all_to(repo_path, baseline_branch, &remote)
|
||||
}
|
||||
|
||||
fn push_all_to(repo_path: &Path, baseline_branch: &str, remote: &str) -> Result<()> {
|
||||
let mut branches = local_branches(repo_path)?;
|
||||
if let Some(index) = branches.iter().position(|branch| branch == baseline_branch) {
|
||||
let baseline = branches.remove(index);
|
||||
branches.insert(0, baseline);
|
||||
}
|
||||
let mut branches = local_branches(repo_path)?
|
||||
.into_iter()
|
||||
.filter(|branch| branch == baseline_branch || branch.starts_with("orx/"))
|
||||
.collect::<Vec<_>>();
|
||||
branches.sort_by_key(|branch| branch != baseline_branch);
|
||||
for branch in branches {
|
||||
push(repo_path, &["-u", remote, &branch])?;
|
||||
push(repo_path, &["-u", &remote, &branch])?;
|
||||
}
|
||||
push(repo_path, &[remote, "--tags"])?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn local_branches(repo_path: &Path) -> Result<Vec<String>> {
|
||||
Ok(git(
|
||||
Some(repo_path),
|
||||
&["for-each-ref", "--format=%(refname:short)", "refs/heads"],
|
||||
)?
|
||||
.lines()
|
||||
.filter(|branch| !branch.is_empty())
|
||||
.map(str::to_string)
|
||||
.collect())
|
||||
pub fn push_branch(repo_path: &Path, branch: &str, owner: &str, repo: &str) -> Result<()> {
|
||||
let remote = publication_remote(repo_path, owner, repo)?;
|
||||
push(repo_path, &["-u", &remote, branch])
|
||||
}
|
||||
|
||||
pub fn spawn_branch_publication(
|
||||
repo_path: &Path,
|
||||
branch: &str,
|
||||
owner: &str,
|
||||
repo: &str,
|
||||
) -> Result<()> {
|
||||
let executable = std::env::current_exe()?;
|
||||
let mut command = Command::new(executable);
|
||||
command
|
||||
.arg("publish-branch")
|
||||
.arg(repo_path)
|
||||
.arg(branch)
|
||||
.arg(owner)
|
||||
.arg(repo)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null());
|
||||
#[cfg(unix)]
|
||||
command.process_group(0);
|
||||
let mut child = command
|
||||
.spawn()
|
||||
.map_err(|error| anyhow!("Could not start GitHub publication worker: {error}"))?;
|
||||
std::thread::spawn(move || {
|
||||
let _ = child.wait();
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn publication_sync_status(
|
||||
@@ -936,18 +1045,20 @@ pub fn publication_sync_status(
|
||||
let Ok(remote) = publication_remote(repo_path, owner, repo) else {
|
||||
return "not configured";
|
||||
};
|
||||
let Ok(remote_refs) = authenticated_git(
|
||||
repo_path,
|
||||
&["ls-remote", "--heads", &remote],
|
||||
Duration::from_secs(30),
|
||||
) else {
|
||||
return "unknown";
|
||||
};
|
||||
let Ok(branches) = local_branches(repo_path) else {
|
||||
return "unknown";
|
||||
};
|
||||
if !branches.iter().any(|branch| branch == baseline_branch) {
|
||||
return "unknown";
|
||||
}
|
||||
let Ok(remote_refs) =
|
||||
authenticated_git(repo_path, &["ls-remote", "--heads", "--tags", &remote])
|
||||
else {
|
||||
return "unknown";
|
||||
};
|
||||
for branch in branches {
|
||||
for branch in branches
|
||||
.into_iter()
|
||||
.filter(|branch| branch == baseline_branch || branch.starts_with("orx/"))
|
||||
{
|
||||
let Ok(local) = local_head_sha(repo_path, &branch) else {
|
||||
return "unknown";
|
||||
};
|
||||
@@ -963,72 +1074,22 @@ pub fn publication_sync_status(
|
||||
};
|
||||
}
|
||||
}
|
||||
let Ok(local_tags) = git(Some(repo_path), &["tag", "--list"]) else {
|
||||
return "unknown";
|
||||
};
|
||||
if local_tags.lines().all(|tag| {
|
||||
let Ok(sha) = git(
|
||||
Some(repo_path),
|
||||
&["rev-parse", &format!("{tag}^{{commit}}")],
|
||||
) else {
|
||||
return false;
|
||||
};
|
||||
let direct = format!("refs/tags/{tag}");
|
||||
let peeled = format!("{direct}^{{}}");
|
||||
remote_refs.lines().any(|remote_line| {
|
||||
remote_line == format!("{sha}\t{direct}") || remote_line == format!("{sha}\t{peeled}")
|
||||
})
|
||||
}) {
|
||||
"synced"
|
||||
} else {
|
||||
"local changes to push"
|
||||
}
|
||||
"synced"
|
||||
}
|
||||
|
||||
/// Create `new_branch` from `parent_branch`'s local tip and optionally publish it.
|
||||
/// Create `new_branch` from `parent_branch`'s local tip.
|
||||
pub fn create_experiment_branch(
|
||||
repo_path: &Path,
|
||||
parent_branch: &str,
|
||||
new_branch: &str,
|
||||
publication: Option<(&str, &str)>,
|
||||
) -> Result<()> {
|
||||
git(
|
||||
Some(repo_path),
|
||||
&["branch", "--no-track", new_branch, parent_branch],
|
||||
)?;
|
||||
let Some((owner, repo)) = publication else {
|
||||
return Ok(());
|
||||
};
|
||||
if let Err(err) = push_branch(repo_path, new_branch, owner, repo) {
|
||||
// Leave nothing behind — a retry re-picks the same slug.
|
||||
let _ = git(Some(repo_path), &["branch", "-D", new_branch]);
|
||||
return Err(err);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Head SHA of a branch — the *remote* tip when it exists (that's what a job
|
||||
/// clones), the local ref otherwise. The opposite preference of
|
||||
/// `resolve_branch_commit`, which serves the code browser and wants the
|
||||
/// agent's not-yet-pushed local work.
|
||||
pub fn branch_head_sha(repo_path: &Path, branch: &str, owner: &str, repo: &str) -> Result<String> {
|
||||
let remote = format!(
|
||||
"refs/remotes/{}/{branch}",
|
||||
publication_remote(repo_path, owner, repo)?
|
||||
);
|
||||
if let Ok(sha) = git(Some(repo_path), &["rev-parse", &remote]) {
|
||||
return Ok(sha);
|
||||
}
|
||||
git(Some(repo_path), &["rev-parse", branch])
|
||||
}
|
||||
|
||||
/// Whether origin already has the branch (a cheap network check).
|
||||
pub fn branch_on_remote(repo_path: &Path, branch: &str, owner: &str, repo: &str) -> Result<bool> {
|
||||
let remote = publication_remote(repo_path, owner, repo)?;
|
||||
let out = git(Some(repo_path), &["ls-remote", "--heads", &remote, branch])?;
|
||||
Ok(!out.is_empty())
|
||||
}
|
||||
|
||||
/// A file's content at a specific commit (`git show <sha>:<path>`), i.e.
|
||||
/// exactly what a job cloning that sha will see — not the working tree.
|
||||
pub fn file_at(repo_path: &Path, sha: &str, path: &str) -> Result<String> {
|
||||
@@ -1044,56 +1105,6 @@ pub fn is_tracked(repo_path: &Path, path: &str) -> bool {
|
||||
.is_ok()
|
||||
}
|
||||
|
||||
pub fn push_branch(repo_path: &Path, branch: &str, owner: &str, repo: &str) -> Result<()> {
|
||||
let remote = publication_remote(repo_path, owner, repo)?;
|
||||
push(repo_path, &["-u", &remote, branch])?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn publish_branch_commit(
|
||||
project: &crate::local::model::LocalProject,
|
||||
branch: &str,
|
||||
) -> Result<String> {
|
||||
if !project.github_enabled() {
|
||||
return Err(anyhow!(
|
||||
"Remote compute requires this project's GitHub repository. Enable GitHub syncing for this project, then retry."
|
||||
));
|
||||
}
|
||||
let repo_path = Path::new(&project.repo_path);
|
||||
if !is_repository(repo_path) {
|
||||
return Err(anyhow!("{} is not a Git repository", repo_path.display()));
|
||||
}
|
||||
push_branch(
|
||||
repo_path,
|
||||
branch,
|
||||
&project.github_owner,
|
||||
&project.github_repo,
|
||||
)?;
|
||||
let commit_sha = branch_head_sha(
|
||||
repo_path,
|
||||
branch,
|
||||
&project.github_owner,
|
||||
&project.github_repo,
|
||||
)?;
|
||||
let url = format!(
|
||||
"https://github.com/{}/{}.git",
|
||||
project.github_owner, project.github_repo
|
||||
);
|
||||
let remote = isolated_github_refs(&url, branch)?;
|
||||
let reference = format!("refs/heads/{branch}");
|
||||
if !remote
|
||||
.lines()
|
||||
.any(|line| line == format!("{commit_sha}\t{reference}"))
|
||||
{
|
||||
return Err(anyhow!(
|
||||
"The recorded commit cannot be cloned through the HTTPS credentials used by remote jobs. Connect a GitHub token with access to {}/{}.",
|
||||
project.github_owner,
|
||||
project.github_repo
|
||||
));
|
||||
}
|
||||
Ok(commit_sha)
|
||||
}
|
||||
|
||||
// --- diffs ------------------------------------------------------------------
|
||||
|
||||
/// Whole-diff cap, mirroring the OpenResearch api's MAX_DIFF_BYTES.
|
||||
@@ -1688,38 +1699,6 @@ mod tests {
|
||||
let _ = std::fs::remove_dir_all(&repo);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn push_all_publishes_every_branch_and_tag() {
|
||||
let repo = temp_repo();
|
||||
run(&repo, &["branch", "orx/experiment"]);
|
||||
run(&repo, &["branch", "notes"]);
|
||||
run(&repo, &["tag", "v1"]);
|
||||
let remote = repo.with_extension("bare.git");
|
||||
let remote_string = remote.to_string_lossy().into_owned();
|
||||
run(&repo, &["init", "--bare", &remote_string]);
|
||||
run(&repo, &["remote", "add", GITHUB_REMOTE, &remote_string]);
|
||||
|
||||
push_all_to(&repo, "main", GITHUB_REMOTE).unwrap();
|
||||
|
||||
let heads = run(&repo, &["ls-remote", "--heads", GITHUB_REMOTE]);
|
||||
assert!(heads.contains("refs/heads/main"));
|
||||
assert!(heads.contains("refs/heads/orx/experiment"));
|
||||
assert!(heads.contains("refs/heads/notes"));
|
||||
assert_eq!(run(&repo, &["config", "branch.main.remote"]), GITHUB_REMOTE);
|
||||
assert_eq!(
|
||||
run(&repo, &["config", "branch.orx/experiment.remote"]),
|
||||
GITHUB_REMOTE
|
||||
);
|
||||
assert_eq!(
|
||||
run(&repo, &["config", "branch.notes.remote"]),
|
||||
GITHUB_REMOTE
|
||||
);
|
||||
let tags = run(&repo, &["ls-remote", "--tags", GITHUB_REMOTE]);
|
||||
assert!(tags.contains("refs/tags/v1"));
|
||||
let _ = std::fs::remove_dir_all(&repo);
|
||||
let _ = std::fs::remove_dir_all(&remote);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn experiment_branch_collision_never_rewrites_existing_work() {
|
||||
let repo = temp_repo();
|
||||
@@ -1729,52 +1708,11 @@ mod tests {
|
||||
run(&repo, &["add", "later.txt"]);
|
||||
run(&repo, &["commit", "-q", "-m", "later"]);
|
||||
|
||||
assert!(create_experiment_branch(&repo, "main", "orx/existing", None).is_err());
|
||||
assert!(create_experiment_branch(&repo, "main", "orx/existing").is_err());
|
||||
assert_eq!(run(&repo, &["rev-parse", "orx/existing"]), before);
|
||||
let _ = std::fs::remove_dir_all(&repo);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn publication_remote_must_match_exact_repository() {
|
||||
let repo = temp_repo();
|
||||
run(
|
||||
&repo,
|
||||
&[
|
||||
"remote",
|
||||
"add",
|
||||
GITHUB_REMOTE,
|
||||
"https://github.com/owner/project-backup.git",
|
||||
],
|
||||
);
|
||||
run(
|
||||
&repo,
|
||||
&[
|
||||
"remote",
|
||||
"add",
|
||||
"origin",
|
||||
"git@github.com:owner/project.git",
|
||||
],
|
||||
);
|
||||
assert_eq!(
|
||||
publication_remote(&repo, "owner", "project").unwrap(),
|
||||
"origin"
|
||||
);
|
||||
run(
|
||||
&repo,
|
||||
&[
|
||||
"remote",
|
||||
"set-url",
|
||||
"--add",
|
||||
"--push",
|
||||
"origin",
|
||||
"https://github.com/owner/elsewhere.git",
|
||||
],
|
||||
);
|
||||
assert!(publication_remote(&repo, "owner", "project").is_err());
|
||||
assert!(publication_remote(&repo, "owner", "missing").is_err());
|
||||
let _ = std::fs::remove_dir_all(&repo);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn public_clone_urls_reject_credentials_and_secret_suffixes() {
|
||||
assert!(public_clone_url("https://user:token@example.com/repo.git").is_err());
|
||||
@@ -1789,4 +1727,13 @@ mod tests {
|
||||
"SSH://example.com/repo.git"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn public_clone_history_is_shallow_only_when_requested() {
|
||||
assert_eq!(
|
||||
public_clone_history_args(true),
|
||||
&["--depth=1", "--single-branch"]
|
||||
);
|
||||
assert!(public_clone_history_args(false).is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
+83
-129
@@ -1,7 +1,4 @@
|
||||
//! Minimal GitHub REST calls for local mode — create a repo on the signed-in
|
||||
//! user's account or in an organization, check push access, fork-by-copy.
|
||||
//! Token from `GITHUB_TOKEN` or `gh auth token`, same resolution the clone path
|
||||
//! uses.
|
||||
//! Minimal GitHub REST calls for optional project publication.
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
@@ -11,6 +8,11 @@ use super::git::resolve_github_token;
|
||||
use crate::error::{anyhow, Error, Result};
|
||||
|
||||
const UA: &str = concat!("orx/", env!("CARGO_PKG_VERSION"));
|
||||
pub const SHALLOW_CLONE_THRESHOLD_KB: u64 = 250 * 1024;
|
||||
|
||||
pub fn should_shallow_clone(size_kb: Option<u64>) -> bool {
|
||||
size_kb.is_some_and(|size| size >= SHALLOW_CLONE_THRESHOLD_KB)
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct RepositoryNameExists;
|
||||
@@ -23,35 +25,43 @@ impl std::fmt::Display for RepositoryNameExists {
|
||||
|
||||
impl std::error::Error for RepositoryNameExists {}
|
||||
|
||||
/// Create a blank private repo with an auto-init commit so the clone/branch
|
||||
/// flow works immediately. An organization target is optional; without one,
|
||||
/// GitHub creates the repo under the token's user.
|
||||
pub async fn create_repo(
|
||||
repo: &str,
|
||||
organization: Option<&str>,
|
||||
) -> Result<(String, String, String)> {
|
||||
create_repo_api(repo, true, organization).await
|
||||
}
|
||||
|
||||
/// Create an empty private repository for a local project. The local history
|
||||
/// remains authoritative and becomes the remote's first history on push.
|
||||
pub async fn create_project_repo(repo: &str) -> Result<(String, String, String)> {
|
||||
match create_repo_api(repo, false, None).await {
|
||||
Err(error) if error.downcast_ref::<RepositoryNameExists>().is_some() => {
|
||||
let suffix = &uuid::Uuid::new_v4().simple().to_string()[..8];
|
||||
create_repo_api(&format!("{repo}-{suffix}"), false, None).await
|
||||
for suffix in 1..=100 {
|
||||
let candidate = if suffix == 1 {
|
||||
repo.to_string()
|
||||
} else {
|
||||
format!("{repo}-{suffix}")
|
||||
};
|
||||
match create_repo_api(&candidate, false).await {
|
||||
Err(error) if error.downcast_ref::<RepositoryNameExists>().is_some() => continue,
|
||||
result => return result,
|
||||
}
|
||||
result => result,
|
||||
}
|
||||
Err(anyhow!(
|
||||
"Could not find an available GitHub repository name for '{repo}'."
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn available_project_repo_name(repo: &str) -> String {
|
||||
let Some(owner) = viewer_login().await else {
|
||||
return repo.to_string();
|
||||
};
|
||||
for suffix in 1..=100 {
|
||||
let candidate = if suffix == 1 {
|
||||
repo.to_string()
|
||||
} else {
|
||||
format!("{repo}-{suffix}")
|
||||
};
|
||||
if repo_meta(&owner, &candidate).await.is_none() {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
repo.to_string()
|
||||
}
|
||||
|
||||
/// GET an api.github.com URL with the resolved token. `None` when there's no
|
||||
/// token or the request fails — callers decide what that means.
|
||||
async fn authed_get(url: &str) -> Option<reqwest::Response> {
|
||||
let token = resolve_github_token()?;
|
||||
reqwest::Client::builder()
|
||||
// Without a timeout a black-holed connection hangs the caller — and
|
||||
// the New project form blocks on this check.
|
||||
.timeout(Duration::from_secs(10))
|
||||
.build()
|
||||
.ok()?
|
||||
@@ -64,49 +74,60 @@ async fn authed_get(url: &str) -> Option<reqwest::Response> {
|
||||
.ok()
|
||||
}
|
||||
|
||||
/// What the New project form needs about a repo, from one API call.
|
||||
pub async fn public_repo_size_kb(url: &str) -> Option<u64> {
|
||||
let (owner, repo) = super::git::github_repository(url)?;
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(10))
|
||||
.build()
|
||||
.ok()?;
|
||||
let mut request = client
|
||||
.get(format!(
|
||||
"https://api.github.com/repos/{}/{}",
|
||||
urlencoding::encode(&owner),
|
||||
urlencoding::encode(&repo)
|
||||
))
|
||||
.header("user-agent", UA)
|
||||
.header("accept", "application/vnd.github+json")
|
||||
.header("x-github-api-version", "2022-11-28");
|
||||
if let Some(token) = resolve_github_token() {
|
||||
request = request.bearer_auth(token);
|
||||
}
|
||||
let response = request.send().await.ok()?;
|
||||
if !response.status().is_success() {
|
||||
return None;
|
||||
}
|
||||
let body: Value = response.json().await.ok()?;
|
||||
body.get("size").and_then(Value::as_u64)
|
||||
}
|
||||
|
||||
pub struct RepoMeta {
|
||||
pub can_push: bool,
|
||||
pub archived: bool,
|
||||
/// The repo's own default branch — the honest baseline when the user
|
||||
/// doesn't pick one (`create_project` would otherwise assume "main").
|
||||
pub default_branch: Option<String>,
|
||||
}
|
||||
|
||||
/// The signed-in GitHub login, so the UI can name the account a new repo lands
|
||||
/// on instead of guessing "you". `None` when there's no usable token.
|
||||
pub async fn viewer_login() -> Option<String> {
|
||||
let res = authed_get("https://api.github.com/user").await?;
|
||||
if !res.status().is_success() {
|
||||
let response = authed_get("https://api.github.com/user").await?;
|
||||
if !response.status().is_success() {
|
||||
return None;
|
||||
}
|
||||
let body: Value = res.json().await.ok()?;
|
||||
let body: Value = response.json().await.ok()?;
|
||||
body.get("login")
|
||||
.and_then(Value::as_str)
|
||||
.filter(|s| !s.is_empty())
|
||||
.filter(|login| !login.is_empty())
|
||||
.map(str::to_string)
|
||||
}
|
||||
|
||||
/// Repo permissions + default branch. `None` means we couldn't tell (no token,
|
||||
/// API hiccup); callers treat that as "assume access" so a check outage never
|
||||
/// forces a surprise fork.
|
||||
pub async fn repo_meta(owner: &str, repo: &str) -> Option<RepoMeta> {
|
||||
// Encoded: owner/repo reach here straight from a text field, and a stray
|
||||
// `/` or `..` would otherwise re-point the request at another endpoint.
|
||||
let res = authed_get(&format!(
|
||||
let response = authed_get(&format!(
|
||||
"https://api.github.com/repos/{}/{}",
|
||||
urlencoding::encode(owner),
|
||||
urlencoding::encode(repo)
|
||||
))
|
||||
.await?;
|
||||
match res.status() {
|
||||
// Invisible to *this token* — which is also what a private repo looks
|
||||
// like to an SSH-only user who can push to it perfectly well. Report
|
||||
// unknown rather than "can't push": a wrong `false` silently snapshots
|
||||
// their repo into a new one and drops its history.
|
||||
match response.status() {
|
||||
reqwest::StatusCode::NOT_FOUND => None,
|
||||
s if s.is_success() => {
|
||||
let body: Value = res.json().await.ok()?;
|
||||
status if status.is_success() => {
|
||||
let body: Value = response.json().await.ok()?;
|
||||
Some(RepoMeta {
|
||||
can_push: body
|
||||
.pointer("/permissions/push")
|
||||
@@ -116,89 +137,28 @@ pub async fn repo_meta(owner: &str, repo: &str) -> Option<RepoMeta> {
|
||||
.get("archived")
|
||||
.and_then(Value::as_bool)
|
||||
.unwrap_or(false),
|
||||
default_branch: body
|
||||
.get("default_branch")
|
||||
.and_then(Value::as_str)
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(str::to_string),
|
||||
})
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Fork-by-copy: snapshot `src_owner/src_repo` (at `src_branch`, or its
|
||||
/// default) into a fresh private repo `<slug>-<hash>` on the token's user —
|
||||
/// the same import convention the platform uses — so the project always ends
|
||||
/// up on a repo the user can push to. Returns (owner, repo, default_branch).
|
||||
pub async fn fork_copy_repo(
|
||||
src_owner: &str,
|
||||
src_repo: &str,
|
||||
src_branch: Option<String>,
|
||||
destination_organization: Option<&str>,
|
||||
) -> Result<(String, String, String)> {
|
||||
let hash = &uuid::Uuid::new_v4().simple().to_string()[..8];
|
||||
let name = format!("{}-{hash}", crate::local::slugify(src_repo));
|
||||
let (owner, name, _) = create_repo_api(&name, false, destination_organization).await?;
|
||||
let (src_owner, src_repo) = (src_owner.to_string(), src_repo.to_string());
|
||||
let (dst_owner, dst_repo) = (owner.clone(), name.clone());
|
||||
tokio::task::spawn_blocking(move || {
|
||||
super::git::seed_copy(
|
||||
&src_owner,
|
||||
&src_repo,
|
||||
src_branch.as_deref(),
|
||||
&dst_owner,
|
||||
&dst_repo,
|
||||
)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| anyhow!("seed task failed: {e}"))??;
|
||||
Ok((owner, name, "main".to_string()))
|
||||
}
|
||||
|
||||
fn create_repo_endpoint(organization: Option<&str>) -> Result<String> {
|
||||
let Some(organization) = organization
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
else {
|
||||
return Ok("https://api.github.com/user/repos".to_string());
|
||||
};
|
||||
let valid = organization.len() <= 39
|
||||
&& !organization.starts_with('-')
|
||||
&& !organization.ends_with('-')
|
||||
&& !organization.contains("--")
|
||||
&& organization
|
||||
.chars()
|
||||
.all(|character| character.is_ascii_alphanumeric() || character == '-');
|
||||
if !valid {
|
||||
return Err(anyhow!("Invalid GitHub organization login: {organization}"));
|
||||
}
|
||||
Ok(format!("https://api.github.com/orgs/{organization}/repos"))
|
||||
}
|
||||
|
||||
async fn create_repo_api(
|
||||
repo: &str,
|
||||
auto_init: bool,
|
||||
organization: Option<&str>,
|
||||
) -> Result<(String, String, String)> {
|
||||
async fn create_repo_api(repo: &str, auto_init: bool) -> Result<(String, String, String)> {
|
||||
let token = resolve_github_token().ok_or_else(|| {
|
||||
anyhow!(
|
||||
"Creating a GitHub repo needs credentials — run `gh auth login` or set GITHUB_TOKEN."
|
||||
)
|
||||
anyhow!("Creating a GitHub repo needs credentials — run `gh auth login` or connect a GitHub token.")
|
||||
})?;
|
||||
let res = reqwest::Client::new()
|
||||
.post(create_repo_endpoint(organization)?)
|
||||
let response = reqwest::Client::new()
|
||||
.post("https://api.github.com/user/repos")
|
||||
.bearer_auth(&token)
|
||||
.header("user-agent", UA)
|
||||
.header("accept", "application/vnd.github+json")
|
||||
.json(&json!({ "name": repo, "private": true, "auto_init": auto_init }))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| anyhow!("GitHub API unreachable: {e}"))?;
|
||||
let status = res.status();
|
||||
let body: Value = res.json().await.unwrap_or_default();
|
||||
.map_err(|error| anyhow!("GitHub API unreachable: {error}"))?;
|
||||
let status = response.status();
|
||||
let body: Value = response.json().await.unwrap_or_default();
|
||||
if status == reqwest::StatusCode::UNPROCESSABLE_ENTITY {
|
||||
// Typically "name already exists on this account".
|
||||
let detail = body
|
||||
.pointer("/errors/0/message")
|
||||
.and_then(Value::as_str)
|
||||
@@ -211,11 +171,11 @@ async fn create_repo_api(
|
||||
return Err(anyhow!("Could not create '{repo}': {detail}."));
|
||||
}
|
||||
if !status.is_success() {
|
||||
let msg = body
|
||||
let message = body
|
||||
.get("message")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("unknown error");
|
||||
return Err(anyhow!("GitHub repo create failed ({status}): {msg}"));
|
||||
return Err(anyhow!("GitHub repo create failed ({status}): {message}"));
|
||||
}
|
||||
let owner = body
|
||||
.pointer("/owner/login")
|
||||
@@ -237,18 +197,12 @@ async fn create_repo_api(
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::create_repo_endpoint;
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn repository_endpoint_targets_user_or_organization() {
|
||||
assert_eq!(
|
||||
create_repo_endpoint(None).unwrap(),
|
||||
"https://api.github.com/user/repos"
|
||||
);
|
||||
assert_eq!(
|
||||
create_repo_endpoint(Some("alphaXiv")).unwrap(),
|
||||
"https://api.github.com/orgs/alphaXiv/repos"
|
||||
);
|
||||
assert!(create_repo_endpoint(Some("not/an/org")).is_err());
|
||||
fn shallow_clone_is_reserved_for_large_repositories() {
|
||||
assert!(!should_shallow_clone(None));
|
||||
assert!(!should_shallow_clone(Some(SHALLOW_CLONE_THRESHOLD_KB - 1)));
|
||||
assert!(should_shallow_clone(Some(SHALLOW_CLONE_THRESHOLD_KB)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2491,14 +2491,6 @@ async fn run_turn_exec(ctx: &mut TurnCtx) -> Result<()> {
|
||||
if let Some(dir) = &data_dir_pin {
|
||||
cmd.env("ORX_DATA_DIR", dir);
|
||||
}
|
||||
// The sandbox blocks the keyring `gh` keeps its token in ("stored token is
|
||||
// invalid" from inside the workspace), so resolve it out here and pass it
|
||||
// down; both `gh` and its git credential helper prefer these env vars.
|
||||
if let Some(token) = crate::local::git::resolve_github_token() {
|
||||
cmd.env("GH_TOKEN", &token);
|
||||
cmd.env("GITHUB_TOKEN", token);
|
||||
}
|
||||
|
||||
let mut child = cmd
|
||||
.spawn()
|
||||
.map_err(|e| anyhow!("Could not spawn {}: {}", bin.display(), e))?;
|
||||
|
||||
+27
-43
@@ -4,10 +4,10 @@
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::commands::exp::{default_hf_image, hf_clone_script, spawn_detached_supervise};
|
||||
use crate::commands::exp::{default_hf_image, spawn_detached_supervise};
|
||||
use crate::compute::SourceSnapshot;
|
||||
use crate::error::{anyhow, Result};
|
||||
use crate::jobs::{huggingface as hf, BackendDescriptor};
|
||||
use crate::local::git;
|
||||
use crate::store::{now_ms, Store, StoredRun};
|
||||
|
||||
/// CLI wrapper around `submit_local_hf`: submit, then print the summary.
|
||||
@@ -34,6 +34,14 @@ pub async fn launch_local_hf(args: &crate::ExpRunArgs) -> Result<()> {
|
||||
/// supervisor. `args.exp_id` must exist in `local_experiments`; requires
|
||||
/// `--backend hf` and `--flavor`. Shared by the CLI and the `orx up` API.
|
||||
pub async fn submit_local_hf(args: &crate::ExpRunArgs) -> Result<StoredRun> {
|
||||
crate::compute::submit(args).await
|
||||
}
|
||||
|
||||
pub async fn submit_local_hf_with_source(
|
||||
args: &crate::ExpRunArgs,
|
||||
source: SourceSnapshot,
|
||||
run_id: String,
|
||||
) -> Result<StoredRun> {
|
||||
if args.sandbox.is_some() || args.gpu.is_some() || args.cpu.is_some() {
|
||||
return Err(anyhow!(
|
||||
"Local experiments run on Hugging Face Jobs; drop --gpu/--cpu/--sandbox \
|
||||
@@ -78,60 +86,24 @@ pub async fn submit_local_hf(args: &crate::ExpRunArgs) -> Result<StoredRun> {
|
||||
|
||||
// One run in flight per experiment unless the caller deliberately forces
|
||||
// a concurrent launch — the double-click / double-submit guard.
|
||||
if !args.force {
|
||||
if let Some(r) = store
|
||||
.list_runs_by_experiment(&exp.id)?
|
||||
.into_iter()
|
||||
.find(|r| !crate::local::is_terminal(&r.status))
|
||||
{
|
||||
return Err(anyhow!(
|
||||
"Run {} is already in flight for this experiment ({}). \
|
||||
Cancel it with `orx exp cancel {}` or pass --force to launch anyway.",
|
||||
r.id,
|
||||
r.status,
|
||||
exp.id
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
let token = hf::resolve_token()?;
|
||||
let namespace = hf::whoami(&token).await?;
|
||||
|
||||
// The job clones from GitHub, so the branch tip must exist there. Fetch
|
||||
// via ensure_clone so branch_head_sha matches what the job will check out.
|
||||
// Git shells out (network, can stall) — keep it off the async workers.
|
||||
let commit_sha = {
|
||||
let project = project.clone();
|
||||
let branch = exp.branch_name.clone();
|
||||
tokio::task::spawn_blocking(move || git::publish_branch_commit(&project, &branch))
|
||||
.await
|
||||
.map_err(|e| anyhow!("git task failed: {e}"))??
|
||||
};
|
||||
|
||||
let run_id = uuid::Uuid::new_v4().to_string();
|
||||
let image = args
|
||||
.image
|
||||
.clone()
|
||||
.unwrap_or_else(|| default_hf_image(&flavor));
|
||||
let script = hf_clone_script(
|
||||
&commit_sha,
|
||||
&project.github_owner,
|
||||
&project.github_repo,
|
||||
&run_command,
|
||||
);
|
||||
let script = crate::compute::snapshot_script("/orx-source/source.tar", &run_command);
|
||||
|
||||
// Tokens travel as job secrets only — the command line stays tokenless.
|
||||
let mut secrets = HashMap::new();
|
||||
secrets.insert("HF_TOKEN".to_string(), token.clone());
|
||||
if let Some(gh) = git::resolve_github_token() {
|
||||
secrets.insert("GITHUB_TOKEN".to_string(), gh);
|
||||
}
|
||||
let mut labels = HashMap::new();
|
||||
labels.insert("or_run".to_string(), run_id.clone());
|
||||
labels.insert("or_experiment".to_string(), exp.id.clone());
|
||||
labels.insert("or_project".to_string(), project.id.clone());
|
||||
|
||||
let job = hf::run_job(
|
||||
let job = hf::run_job_with_source(
|
||||
&token,
|
||||
&namespace,
|
||||
&hf::JobSubmission {
|
||||
@@ -143,10 +115,12 @@ pub async fn submit_local_hf(args: &crate::ExpRunArgs) -> Result<StoredRun> {
|
||||
timeout_seconds,
|
||||
labels,
|
||||
},
|
||||
&source.path,
|
||||
&source.digest,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let descriptor = BackendDescriptor {
|
||||
let mut descriptor = BackendDescriptor {
|
||||
kind: "hf_job".to_string(),
|
||||
namespace: Some(namespace.clone()),
|
||||
job_id: Some(job.id.clone()),
|
||||
@@ -160,7 +134,15 @@ pub async fn submit_local_hf(args: &crate::ExpRunArgs) -> Result<StoredRun> {
|
||||
ssh_port: None,
|
||||
ssh_user: None,
|
||||
timeout_secs: None,
|
||||
source_digest: None,
|
||||
source_path: None,
|
||||
source_size: None,
|
||||
};
|
||||
source.apply_to_descriptor(&mut descriptor);
|
||||
if let Err(error) = crate::compute::record_submission_handle(&run_id, &descriptor) {
|
||||
let _ = hf::cancel_job(&token, &namespace, &job.id).await;
|
||||
return Err(error);
|
||||
}
|
||||
let run = StoredRun {
|
||||
id: run_id.clone(),
|
||||
experiment_id: exp.id.clone(),
|
||||
@@ -172,9 +154,11 @@ pub async fn submit_local_hf(args: &crate::ExpRunArgs) -> Result<StoredRun> {
|
||||
updated_at: now_ms(),
|
||||
ended_at: None,
|
||||
exit_code: None,
|
||||
commit_sha: Some(commit_sha),
|
||||
commit_sha: Some(source.revision),
|
||||
result_markdown: None,
|
||||
cancel_requested: false,
|
||||
cancel_requested: store
|
||||
.get_run(&run_id)?
|
||||
.is_some_and(|run| run.cancel_requested),
|
||||
chat_session_id: crate::local::chat::launching_chat_session(),
|
||||
};
|
||||
store.upsert_run(&run)?;
|
||||
|
||||
+40
-51
@@ -5,13 +5,14 @@
|
||||
//!
|
||||
//! There are no flavors: the run's shape is a **manifest committed on the
|
||||
//! experiment branch** (default `.orx/k8s.yaml`, or `--manifest <path>`),
|
||||
//! read at the branch tip — the same commit the job clones — so unpushed
|
||||
//! manifest edits never run. See `jobs/kubernetes.rs` for the contract orx
|
||||
//! read at the recorded revision — the same commit the job receives — so
|
||||
//! uncommitted manifest edits never run. See `jobs/kubernetes.rs` for the contract orx
|
||||
//! enforces on it.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::commands::exp::{hf_clone_script, spawn_detached_supervise};
|
||||
use crate::commands::exp::spawn_detached_supervise;
|
||||
use crate::compute::SourceSnapshot;
|
||||
use crate::error::{anyhow, Result};
|
||||
use crate::jobs::{huggingface as hf, kubernetes as k8s, BackendDescriptor};
|
||||
use crate::local::git;
|
||||
@@ -49,6 +50,14 @@ pub async fn launch_local_k8s(args: &crate::ExpRunArgs) -> Result<()> {
|
||||
/// Submit the local experiment's run from its committed manifest and detach a
|
||||
/// supervisor.
|
||||
pub async fn submit_local_k8s(args: &crate::ExpRunArgs) -> Result<StoredRun> {
|
||||
crate::compute::submit(args).await
|
||||
}
|
||||
|
||||
pub async fn submit_local_k8s_with_source(
|
||||
args: &crate::ExpRunArgs,
|
||||
source: SourceSnapshot,
|
||||
run_id: String,
|
||||
) -> Result<StoredRun> {
|
||||
if args.sandbox.is_some() || args.gpu.is_some() || args.cpu.is_some() {
|
||||
return Err(anyhow!(
|
||||
"--backend k8s runs from a manifest committed on the experiment branch; \
|
||||
@@ -101,62 +110,31 @@ pub async fn submit_local_k8s(args: &crate::ExpRunArgs) -> Result<StoredRun> {
|
||||
})?;
|
||||
|
||||
// One run in flight per experiment unless deliberately forced.
|
||||
if !args.force {
|
||||
if let Some(r) = store
|
||||
.list_runs_by_experiment(&exp.id)?
|
||||
.into_iter()
|
||||
.find(|r| !crate::local::is_terminal(&r.status))
|
||||
{
|
||||
return Err(anyhow!(
|
||||
"Run {} is already in flight for this experiment ({}). \
|
||||
Cancel it with `orx exp cancel {}` or pass --force to launch anyway.",
|
||||
r.id,
|
||||
r.status,
|
||||
exp.id
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// The job clones from GitHub, so the branch tip must exist there — and the
|
||||
// manifest is read from that same tip, not the working tree.
|
||||
let (commit_sha, manifest) = {
|
||||
let project = project.clone();
|
||||
let branch = exp.branch_name.clone();
|
||||
let manifest = {
|
||||
let repo_path = project.repo_path.clone();
|
||||
let revision = source.revision.clone();
|
||||
let path = manifest_path.clone();
|
||||
tokio::task::spawn_blocking(move || -> Result<(String, String)> {
|
||||
let sha = git::publish_branch_commit(&project, &branch)?;
|
||||
let manifest = git::file_at(std::path::Path::new(&project.repo_path), &sha, &path)
|
||||
.map_err(|_| {
|
||||
tokio::task::spawn_blocking(move || -> Result<String> {
|
||||
git::file_at(std::path::Path::new(&repo_path), &revision, &path).map_err(|_| {
|
||||
anyhow!(
|
||||
"No manifest at '{path}' on branch '{branch}' — write one, commit, \
|
||||
and push (jobs run the branch tip, so an uncommitted manifest \
|
||||
doesn't exist yet). Pass --manifest <path> if it lives elsewhere."
|
||||
"No manifest at '{path}' in committed revision {revision} — write one \
|
||||
and commit it before running. Pass --manifest <path> if it lives elsewhere."
|
||||
)
|
||||
})?;
|
||||
Ok((sha, manifest))
|
||||
})
|
||||
})
|
||||
.await
|
||||
.map_err(|e| anyhow!("git task failed: {e}"))??
|
||||
};
|
||||
|
||||
let run_id = uuid::Uuid::new_v4().to_string();
|
||||
let script = hf_clone_script(
|
||||
&commit_sha,
|
||||
&project.github_owner,
|
||||
&project.github_repo,
|
||||
&run_command,
|
||||
);
|
||||
let script = crate::compute::gated_script("/tmp/orx-source/source.tar", &run_command);
|
||||
|
||||
// The pod's env: everything the user synced (API keys), plus the tokens
|
||||
// the clone script and common tooling expect. Travels via a k8s Secret,
|
||||
// the run script and common tooling expect. Travels via a k8s Secret,
|
||||
// never on a command line.
|
||||
let mut env: HashMap<String, String> = crate::config::list_synced_env().into_iter().collect();
|
||||
if let Ok(hf_token) = hf::resolve_token() {
|
||||
env.entry("HF_TOKEN".to_string()).or_insert(hf_token);
|
||||
}
|
||||
if let Some(gh) = git::resolve_github_token() {
|
||||
env.insert("GITHUB_TOKEN".to_string(), gh);
|
||||
}
|
||||
let mut labels = HashMap::new();
|
||||
labels.insert("or_run".to_string(), run_id.clone());
|
||||
labels.insert("or_experiment".to_string(), exp.id.clone());
|
||||
@@ -181,25 +159,34 @@ pub async fn submit_local_k8s(args: &crate::ExpRunArgs) -> Result<StoredRun> {
|
||||
env,
|
||||
timeout_seconds,
|
||||
labels,
|
||||
source_archive: source.path.clone(),
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
let descriptor = BackendDescriptor {
|
||||
let mut descriptor = BackendDescriptor {
|
||||
kind: "k8s_job".to_string(),
|
||||
namespace: Some(namespace),
|
||||
job_id: Some(submitted.job_name),
|
||||
namespace: Some(namespace.clone()),
|
||||
job_id: Some(submitted.job_name.clone()),
|
||||
flavor: None,
|
||||
image: None,
|
||||
url: None,
|
||||
context,
|
||||
context: context.clone(),
|
||||
manifest: Some(manifest_path),
|
||||
resources: Some(submitted.resources),
|
||||
resources: Some(submitted.resources.clone()),
|
||||
ssh_host: None,
|
||||
ssh_port: None,
|
||||
ssh_user: None,
|
||||
timeout_secs: None,
|
||||
source_digest: None,
|
||||
source_path: None,
|
||||
source_size: None,
|
||||
};
|
||||
source.apply_to_descriptor(&mut descriptor);
|
||||
if let Err(error) = crate::compute::record_submission_handle(&run_id, &descriptor) {
|
||||
let _ = k8s::delete_resources(context.as_deref(), &namespace, &submitted.resources).await;
|
||||
return Err(error);
|
||||
}
|
||||
let run = StoredRun {
|
||||
id: run_id.clone(),
|
||||
experiment_id: exp.id.clone(),
|
||||
@@ -211,9 +198,11 @@ pub async fn submit_local_k8s(args: &crate::ExpRunArgs) -> Result<StoredRun> {
|
||||
updated_at: now_ms(),
|
||||
ended_at: None,
|
||||
exit_code: None,
|
||||
commit_sha: Some(commit_sha),
|
||||
commit_sha: Some(source.revision),
|
||||
result_markdown: None,
|
||||
cancel_requested: false,
|
||||
cancel_requested: store
|
||||
.get_run(&run_id)?
|
||||
.is_some_and(|run| run.cancel_requested),
|
||||
chat_session_id: crate::local::chat::launching_chat_session(),
|
||||
};
|
||||
store.upsert_run(&run)?;
|
||||
|
||||
+27
-36
@@ -1,15 +1,15 @@
|
||||
//! Local launch — the on-this-machine twin of `local/ssh.rs`: run the
|
||||
//! experiment as a detached process on the machine running orx. Same clone
|
||||
//! contract as every backend — the run clones the branch's GitHub tip into
|
||||
//! experiment as a detached process on the machine running orx. Same snapshot
|
||||
//! contract as every backend — the run extracts the recorded revision into
|
||||
//! its own run dir, never the agent's worktree. The run row lives in the
|
||||
//! local store only; a detached `orx supervise` watches the process.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::commands::exp::{local_clone_script, spawn_detached_supervise};
|
||||
use crate::commands::exp::spawn_detached_supervise;
|
||||
use crate::compute::SourceSnapshot;
|
||||
use crate::error::{anyhow, Result};
|
||||
use crate::jobs::{localbox, BackendDescriptor};
|
||||
use crate::local::git;
|
||||
use crate::store::{now_ms, Store, StoredRun};
|
||||
|
||||
/// CLI wrapper around `submit_local_run`: submit, then print the summary.
|
||||
@@ -30,6 +30,14 @@ pub async fn launch_local_run(args: &crate::ExpRunArgs) -> Result<()> {
|
||||
/// and detach a supervisor. Requires `--backend local`; there is nothing else
|
||||
/// to pick — the hardware is whatever this machine has.
|
||||
pub async fn submit_local_run(args: &crate::ExpRunArgs) -> Result<StoredRun> {
|
||||
crate::compute::submit(args).await
|
||||
}
|
||||
|
||||
pub async fn submit_local_run_with_source(
|
||||
args: &crate::ExpRunArgs,
|
||||
source: SourceSnapshot,
|
||||
run_id: String,
|
||||
) -> Result<StoredRun> {
|
||||
if args.sandbox.is_some() || args.gpu.is_some() || args.cpu.is_some() {
|
||||
return Err(anyhow!(
|
||||
"--backend local runs on this machine; drop --gpu/--cpu/--sandbox — \
|
||||
@@ -71,37 +79,10 @@ pub async fn submit_local_run(args: &crate::ExpRunArgs) -> Result<StoredRun> {
|
||||
})?;
|
||||
|
||||
// One run in flight per experiment unless deliberately forced.
|
||||
if !args.force {
|
||||
if let Some(r) = store
|
||||
.list_runs_by_experiment(&exp.id)?
|
||||
.into_iter()
|
||||
.find(|r| !crate::local::is_terminal(&r.status))
|
||||
{
|
||||
return Err(anyhow!(
|
||||
"Run {} is already in flight for this experiment ({}). \
|
||||
Cancel it with `orx exp cancel {}` or pass --force to launch anyway.",
|
||||
r.id,
|
||||
r.status,
|
||||
exp.id
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
let commit_sha = {
|
||||
let repo_path = project.repo_path.clone();
|
||||
let branch = exp.branch_name.clone();
|
||||
tokio::task::spawn_blocking(move || -> Result<String> {
|
||||
git::local_head_sha(std::path::Path::new(&repo_path), &branch)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| anyhow!("git task failed: {e}"))??
|
||||
};
|
||||
|
||||
let run_id = uuid::Uuid::new_v4().to_string();
|
||||
let script = local_clone_script(&project.repo_path, &commit_sha, &run_command);
|
||||
let script = crate::compute::snapshot_script(&source.path.to_string_lossy(), &run_command);
|
||||
|
||||
// The run's env: everything the user synced (API keys), plus the tokens
|
||||
// the clone script expects. Exported inside run.sh (written owner-only).
|
||||
// the run script expects. Exported inside run.sh (written owner-only).
|
||||
let mut env: HashMap<String, String> = crate::config::list_synced_env().into_iter().collect();
|
||||
if let Ok(hf_token) = crate::jobs::huggingface::resolve_token() {
|
||||
env.entry("HF_TOKEN".to_string()).or_insert(hf_token);
|
||||
@@ -113,7 +94,7 @@ pub async fn submit_local_run(args: &crate::ExpRunArgs) -> Result<StoredRun> {
|
||||
env,
|
||||
})?;
|
||||
|
||||
let descriptor = BackendDescriptor {
|
||||
let mut descriptor = BackendDescriptor {
|
||||
kind: "local_job".to_string(),
|
||||
namespace: None,
|
||||
job_id: Some(dir.to_string_lossy().into_owned()),
|
||||
@@ -127,7 +108,15 @@ pub async fn submit_local_run(args: &crate::ExpRunArgs) -> Result<StoredRun> {
|
||||
ssh_port: None,
|
||||
ssh_user: None,
|
||||
timeout_secs: None,
|
||||
source_digest: None,
|
||||
source_path: None,
|
||||
source_size: None,
|
||||
};
|
||||
source.apply_to_descriptor(&mut descriptor);
|
||||
if let Err(error) = crate::compute::record_submission_handle(&run_id, &descriptor) {
|
||||
let _ = localbox::cancel_job(&dir);
|
||||
return Err(error);
|
||||
}
|
||||
let run = StoredRun {
|
||||
id: run_id.clone(),
|
||||
experiment_id: exp.id.clone(),
|
||||
@@ -139,9 +128,11 @@ pub async fn submit_local_run(args: &crate::ExpRunArgs) -> Result<StoredRun> {
|
||||
updated_at: now_ms(),
|
||||
ended_at: None,
|
||||
exit_code: None,
|
||||
commit_sha: Some(commit_sha),
|
||||
commit_sha: Some(source.revision),
|
||||
result_markdown: None,
|
||||
cancel_requested: false,
|
||||
cancel_requested: store
|
||||
.get_run(&run_id)?
|
||||
.is_some_and(|run| run.cancel_requested),
|
||||
chat_session_id: crate::local::chat::launching_chat_session(),
|
||||
};
|
||||
store.upsert_run(&run)?;
|
||||
|
||||
+28
-43
@@ -6,10 +6,10 @@
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::commands::exp::{hf_clone_script, spawn_detached_supervise};
|
||||
use crate::commands::exp::spawn_detached_supervise;
|
||||
use crate::compute::SourceSnapshot;
|
||||
use crate::error::{anyhow, Result};
|
||||
use crate::jobs::{huggingface as hf, modal, BackendDescriptor};
|
||||
use crate::local::git;
|
||||
use crate::store::{now_ms, Store, StoredRun};
|
||||
|
||||
/// Modal app all orx sandboxes are grouped under (visible in the Modal dashboard).
|
||||
@@ -38,6 +38,14 @@ pub async fn launch_local_modal(args: &crate::ExpRunArgs) -> Result<()> {
|
||||
/// is a Modal GPU (t4, l4, a10g, a100, a100-80gb, l40s, h100, h200, …) or
|
||||
/// `cpu` / `cpu-large` for CPU-only.
|
||||
pub async fn submit_local_modal(args: &crate::ExpRunArgs) -> Result<StoredRun> {
|
||||
crate::compute::submit(args).await
|
||||
}
|
||||
|
||||
pub async fn submit_local_modal_with_source(
|
||||
args: &crate::ExpRunArgs,
|
||||
source: SourceSnapshot,
|
||||
run_id: String,
|
||||
) -> Result<StoredRun> {
|
||||
if args.sandbox.is_some() || args.gpu.is_some() || args.cpu.is_some() {
|
||||
return Err(anyhow!(
|
||||
"--backend modal runs on Modal serverless GPUs; drop --gpu/--cpu/--sandbox \
|
||||
@@ -52,7 +60,7 @@ pub async fn submit_local_modal(args: &crate::ExpRunArgs) -> Result<StoredRun> {
|
||||
)
|
||||
})?;
|
||||
let resources = modal::resolve_flavor(&flavor_name);
|
||||
// Fail before the git push if Modal plainly isn't set up on this box.
|
||||
// Fail before source staging if Modal plainly isn't set up on this box.
|
||||
modal::preflight().await?;
|
||||
// Same default as the HF/k8s paths — no-timeout jobs are a footgun.
|
||||
let timeout_seconds = match &args.timeout {
|
||||
@@ -83,53 +91,19 @@ pub async fn submit_local_modal(args: &crate::ExpRunArgs) -> Result<StoredRun> {
|
||||
})?;
|
||||
|
||||
// One run in flight per experiment unless deliberately forced.
|
||||
if !args.force {
|
||||
if let Some(r) = store
|
||||
.list_runs_by_experiment(&exp.id)?
|
||||
.into_iter()
|
||||
.find(|r| !crate::local::is_terminal(&r.status))
|
||||
{
|
||||
return Err(anyhow!(
|
||||
"Run {} is already in flight for this experiment ({}). \
|
||||
Cancel it with `orx exp cancel {}` or pass --force to launch anyway.",
|
||||
r.id,
|
||||
r.status,
|
||||
exp.id
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// The sandbox clones from GitHub, so the branch tip must exist there.
|
||||
let commit_sha = {
|
||||
let project = project.clone();
|
||||
let branch = exp.branch_name.clone();
|
||||
tokio::task::spawn_blocking(move || git::publish_branch_commit(&project, &branch))
|
||||
.await
|
||||
.map_err(|e| anyhow!("git task failed: {e}"))??
|
||||
};
|
||||
|
||||
let run_id = uuid::Uuid::new_v4().to_string();
|
||||
let image = args
|
||||
.image
|
||||
.clone()
|
||||
.unwrap_or_else(|| modal::default_image(resources.gpu.is_some()));
|
||||
let script = hf_clone_script(
|
||||
&commit_sha,
|
||||
&project.github_owner,
|
||||
&project.github_repo,
|
||||
&run_command,
|
||||
);
|
||||
let script = crate::compute::gated_script("/tmp/orx-source.tar", &run_command);
|
||||
|
||||
// The sandbox's env: everything the user synced (API keys), plus the tokens
|
||||
// the clone script and common tooling expect. Rides an ephemeral Modal
|
||||
// the run script and common tooling expect. Rides an ephemeral Modal
|
||||
// Secret, never the plain env arg.
|
||||
let mut env: HashMap<String, String> = crate::config::list_synced_env().into_iter().collect();
|
||||
if let Ok(hf_token) = hf::resolve_token() {
|
||||
env.entry("HF_TOKEN".to_string()).or_insert(hf_token);
|
||||
}
|
||||
if let Some(gh) = git::resolve_github_token() {
|
||||
env.insert("GITHUB_TOKEN".to_string(), gh);
|
||||
}
|
||||
let mut tags = HashMap::new();
|
||||
tags.insert("or_run".to_string(), run_id.clone());
|
||||
tags.insert("or_experiment".to_string(), exp.id.clone());
|
||||
@@ -145,13 +119,14 @@ pub async fn submit_local_modal(args: &crate::ExpRunArgs) -> Result<StoredRun> {
|
||||
timeout_seconds,
|
||||
app: MODAL_APP.to_string(),
|
||||
tags,
|
||||
source_archive: Some(source.path.clone()),
|
||||
})
|
||||
.await?;
|
||||
|
||||
let descriptor = BackendDescriptor {
|
||||
let mut descriptor = BackendDescriptor {
|
||||
kind: "modal_job".to_string(),
|
||||
namespace: Some(MODAL_APP.to_string()),
|
||||
job_id: Some(sandbox_id),
|
||||
job_id: Some(sandbox_id.clone()),
|
||||
flavor: Some(flavor_name),
|
||||
image: Some(image),
|
||||
url: None,
|
||||
@@ -162,7 +137,15 @@ pub async fn submit_local_modal(args: &crate::ExpRunArgs) -> Result<StoredRun> {
|
||||
ssh_port: None,
|
||||
ssh_user: None,
|
||||
timeout_secs: None,
|
||||
source_digest: None,
|
||||
source_path: None,
|
||||
source_size: None,
|
||||
};
|
||||
source.apply_to_descriptor(&mut descriptor);
|
||||
if let Err(error) = crate::compute::record_submission_handle(&run_id, &descriptor) {
|
||||
let _ = modal::cancel_job(&sandbox_id).await;
|
||||
return Err(error);
|
||||
}
|
||||
let run = StoredRun {
|
||||
id: run_id.clone(),
|
||||
experiment_id: exp.id.clone(),
|
||||
@@ -174,9 +157,11 @@ pub async fn submit_local_modal(args: &crate::ExpRunArgs) -> Result<StoredRun> {
|
||||
updated_at: now_ms(),
|
||||
ended_at: None,
|
||||
exit_code: None,
|
||||
commit_sha: Some(commit_sha),
|
||||
commit_sha: Some(source.revision),
|
||||
result_markdown: None,
|
||||
cancel_requested: false,
|
||||
cancel_requested: store
|
||||
.get_run(&run_id)?
|
||||
.is_some_and(|run| run.cancel_requested),
|
||||
chat_session_id: crate::local::chat::launching_chat_session(),
|
||||
};
|
||||
store.upsert_run(&run)?;
|
||||
|
||||
+1
-1
@@ -17,7 +17,7 @@ pub struct LocalProject {
|
||||
/// where any experiment lives (legacy roots predating per-baseline
|
||||
/// branches may still ride it).
|
||||
pub baseline_branch: String,
|
||||
/// Local clone path (`~/.cache/openresearch/repos/<owner>/<repo>`).
|
||||
/// Local repository path.
|
||||
pub repo_path: String,
|
||||
pub run_command: Option<String>,
|
||||
/// arXiv id the project starts from (versionless, e.g. `2401.12345`).
|
||||
|
||||
+25
-49
@@ -110,33 +110,17 @@ fn opencode_config_json(model: Option<&str>, instructions: &str) -> String {
|
||||
const SYSTEM_PROMPT: &str = include_str!("../../SYSTEM_PROMPT.md");
|
||||
|
||||
fn playbook_md(project: &LocalProject) -> String {
|
||||
let all_backends_enabled = true;
|
||||
let id = &project.id;
|
||||
let name = &project.name;
|
||||
let publication_line = if project.github_enabled() {
|
||||
format!(
|
||||
"- GitHub repository: {}",
|
||||
project
|
||||
.github_url()
|
||||
.expect("enabled project has publication metadata")
|
||||
)
|
||||
"- Source: immutable snapshots from committed local Git revisions\n- GitHub publication: enabled for experiment visibility; never used for compute transport"
|
||||
} else {
|
||||
"- GitHub: not enabled — this project is local-only".to_string()
|
||||
};
|
||||
let experiment_publish_clause = if project.github_enabled() {
|
||||
"created locally and pushed to GitHub"
|
||||
} else {
|
||||
"created locally and never pushed"
|
||||
};
|
||||
let edit_step = if project.github_enabled() {
|
||||
"2. **Edit** in this worktree: check out `<branch>`, change the code, commit, and `git push`. Remote runs use the pushed commit."
|
||||
} else {
|
||||
"2. **Edit** in this worktree: check out `<branch>`, change the code, and commit. Never push; local runs clone the recorded local commit."
|
||||
};
|
||||
let compute_contract = if project.github_enabled() {
|
||||
"Remote backends clone the pushed GitHub commit; the local backend clones the recorded commit directly from the project folder."
|
||||
} else {
|
||||
"This project is local-only: only the `local` backend is available, and it clones the recorded commit directly from the project folder."
|
||||
"- Source: immutable snapshots from committed local Git revisions\n- GitHub publication: disabled"
|
||||
};
|
||||
let experiment_publish_clause = "created locally";
|
||||
let edit_step = "2. **Edit** in this worktree: check out `<branch>`, change the code, and commit. Do not push just to launch compute; `orx` snapshots the committed revision directly.";
|
||||
let compute_contract = "Every backend receives the same immutable archive of the recorded local Git revision; no backend clones a repository or needs GitHub credentials.";
|
||||
let baseline = &project.baseline_branch;
|
||||
let artifacts = super::files::files_dir(project)
|
||||
.to_string_lossy()
|
||||
@@ -157,7 +141,7 @@ fn playbook_md(project: &LocalProject) -> String {
|
||||
// told to OMIT `--backend`, never to echo the default back, so even a
|
||||
// stale prompt launches on the current default.
|
||||
let configured_compute_default = crate::config::compute_default();
|
||||
let compute_default = if project.github_enabled() {
|
||||
let compute_default = if all_backends_enabled {
|
||||
Some(
|
||||
configured_compute_default
|
||||
.clone()
|
||||
@@ -171,9 +155,8 @@ fn playbook_md(project: &LocalProject) -> String {
|
||||
} else {
|
||||
"the local fallback"
|
||||
};
|
||||
let compute_bullet = if !project.github_enabled() {
|
||||
"- Compute: **local only** — run recorded commits on this machine. External backends remain unavailable until the user enables GitHub syncing for this project."
|
||||
.to_string()
|
||||
let compute_bullet = if !all_backends_enabled {
|
||||
"- Compute: run immutable snapshots of recorded commits on configured backends.".to_string()
|
||||
} else {
|
||||
match &compute_default {
|
||||
Some((b, f)) => {
|
||||
@@ -194,9 +177,8 @@ fn playbook_md(project: &LocalProject) -> String {
|
||||
}
|
||||
}
|
||||
};
|
||||
let backends_intro = if !project.github_enabled() {
|
||||
"`orx exp run` uses only the `local` backend for this project. Do not select, configure, or contact an external backend."
|
||||
.to_string()
|
||||
let backends_intro = if !all_backends_enabled {
|
||||
"`orx exp run` transfers the recorded source snapshot to the selected backend.".to_string()
|
||||
} else {
|
||||
match &compute_default {
|
||||
Some((b, f)) => {
|
||||
@@ -235,7 +217,7 @@ fn playbook_md(project: &LocalProject) -> String {
|
||||
.to_string(),
|
||||
}
|
||||
};
|
||||
let (run_invocation, run_guidance, compute_guidance) = if project.github_enabled() {
|
||||
let (run_invocation, run_guidance, compute_guidance) = if all_backends_enabled {
|
||||
(
|
||||
"`orx exp run <expId> [--backend <hf|modal|k8s|ssh|slurm|openresearch|local>] [flags]`",
|
||||
"Launch the node's run. Backend flags, flavors, and sizing: **`orx-compute` skill** (k8s manifest: **`orx-compute-k8s`**).",
|
||||
@@ -243,18 +225,18 @@ fn playbook_md(project: &LocalProject) -> String {
|
||||
)
|
||||
} else {
|
||||
(
|
||||
"`orx exp run <expId> --backend local`",
|
||||
"Launch the recorded commit on this machine. External backends are unavailable until the user enables GitHub.",
|
||||
"Load `orx-compute` for the local launch/wait contract. Do not configure or contact external providers.",
|
||||
"`orx exp run <expId> [--backend <name>] [flags]`",
|
||||
"Launch the recorded commit on a configured backend.",
|
||||
"Load `orx-compute` for the launch and wait contract.",
|
||||
)
|
||||
};
|
||||
let skills_scope = if project.github_enabled() {
|
||||
let skills_scope = if all_backends_enabled {
|
||||
"backend flags and sizing, the k8s manifest, tree shaping, git recipes, log analysis, and artifact naming"
|
||||
} else {
|
||||
"local runs, tree shaping, local git recipes, log analysis, and artifact naming"
|
||||
};
|
||||
let launch_step = if !project.github_enabled() {
|
||||
"3. **Launch locally**: `orx exp run <expId> --backend local`. External backends are unavailable until the user enables GitHub syncing for this project."
|
||||
let launch_step = if !all_backends_enabled {
|
||||
"3. **Launch**: `orx exp run <expId>` using the configured backend."
|
||||
} else if compute_default.is_some() {
|
||||
"3. **Launch**: `orx exp run <expId>` — omitting `--backend` uses the default\n \
|
||||
target (flags the default still needs are listed under \"Compute backends\") —\n \
|
||||
@@ -270,11 +252,7 @@ fn playbook_md(project: &LocalProject) -> String {
|
||||
// the playbook index and the files on disk can never drift.
|
||||
let skills_list = super::agent_skills::skills(super::agent_skills::SkillSet::Local)
|
||||
.iter()
|
||||
.filter(|skill| super::agent_skills::available_in_session(skill, project.github_enabled()))
|
||||
.map(|s| {
|
||||
let description = super::agent_skills::session_description(s, project.github_enabled());
|
||||
format!("- **{}** — {description}", s.name)
|
||||
})
|
||||
.map(|s| format!("- **{}** — {}", s.name, s.description))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
let template = SYSTEM_PROMPT
|
||||
@@ -284,7 +262,7 @@ fn playbook_md(project: &LocalProject) -> String {
|
||||
template
|
||||
.replace("{name}", name)
|
||||
.replace("{id}", id)
|
||||
.replace("{publication_line}", &publication_line)
|
||||
.replace("{publication_line}", publication_line)
|
||||
.replace("{experiment_publish_clause}", experiment_publish_clause)
|
||||
.replace("{edit_step}", edit_step)
|
||||
.replace("{compute_contract}", compute_contract)
|
||||
@@ -770,17 +748,15 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_only_playbook_requires_commits_without_pushes() {
|
||||
fn playbook_requires_commits_without_pushes() {
|
||||
let mut project = sample_project();
|
||||
project.github_owner.clear();
|
||||
project.github_repo.clear();
|
||||
let md = playbook_md(&project);
|
||||
assert!(md.contains("GitHub: not enabled"));
|
||||
assert!(md.contains("Never push; local runs clone the recorded local commit"));
|
||||
assert!(md.contains("Compute: **local only**"));
|
||||
assert!(md.contains("`orx exp run <expId> --backend local`"));
|
||||
assert!(!md.contains("orx-compute-k8s"));
|
||||
assert!(!md.contains("--backend <hf|modal"));
|
||||
assert!(md.contains("immutable archive"));
|
||||
assert!(md.contains("Do not push just to launch compute"));
|
||||
assert!(md.contains("orx-compute-k8s"));
|
||||
assert!(md.contains("--backend <hf|modal"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+22
-31
@@ -6,9 +6,9 @@
|
||||
|
||||
use crate::client::{create_sandbox, list_orgs, CreateSandboxBody};
|
||||
use crate::commands::exp::spawn_detached_supervise;
|
||||
use crate::compute::SourceSnapshot;
|
||||
use crate::error::{anyhow, require_credentials, Result};
|
||||
use crate::jobs::{openresearch, BackendDescriptor};
|
||||
use crate::local::git;
|
||||
use crate::local::ssh_identity;
|
||||
use crate::store::{now_ms, Store, StoredRun};
|
||||
|
||||
@@ -41,6 +41,14 @@ pub async fn launch_local_openresearch(args: &crate::ExpRunArgs) -> Result<()> {
|
||||
/// supervisor. Requires `--backend openresearch` and `--flavor <shape>`
|
||||
/// (`h100_sxm[:count]` or `cpu5c|cpu5g|cpu5m[:vcpus]`), plus `orx login`.
|
||||
pub async fn submit_local_openresearch(args: &crate::ExpRunArgs) -> Result<StoredRun> {
|
||||
crate::compute::submit(args).await
|
||||
}
|
||||
|
||||
pub async fn submit_local_openresearch_with_source(
|
||||
args: &crate::ExpRunArgs,
|
||||
source: SourceSnapshot,
|
||||
run_id: String,
|
||||
) -> Result<StoredRun> {
|
||||
if args.sandbox.is_some() || args.gpu.is_some() || args.cpu.is_some() {
|
||||
return Err(anyhow!(
|
||||
"--gpu/--cpu/--sandbox are the managed server-experiment flags; with \
|
||||
@@ -157,32 +165,6 @@ pub async fn submit_local_openresearch(args: &crate::ExpRunArgs) -> Result<Store
|
||||
})?;
|
||||
|
||||
// One run in flight per experiment unless deliberately forced.
|
||||
if !args.force {
|
||||
if let Some(r) = store
|
||||
.list_runs_by_experiment(&exp.id)?
|
||||
.into_iter()
|
||||
.find(|r| !crate::local::is_terminal(&r.status))
|
||||
{
|
||||
return Err(anyhow!(
|
||||
"Run {} is already in flight for this experiment ({}). \
|
||||
Cancel it with `orx exp cancel {}` or pass --force to launch anyway.",
|
||||
r.id,
|
||||
r.status,
|
||||
exp.id
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// The box clones from GitHub, so the branch tip must exist there — push
|
||||
// BEFORE provisioning so a git failure never bills a box.
|
||||
let commit_sha = {
|
||||
let project = project.clone();
|
||||
let branch = exp.branch_name.clone();
|
||||
tokio::task::spawn_blocking(move || git::publish_branch_commit(&project, &branch))
|
||||
.await
|
||||
.map_err(|e| anyhow!("git task failed: {e}"))??
|
||||
};
|
||||
|
||||
let sandbox = create_sandbox(
|
||||
&creds,
|
||||
&CreateSandboxBody {
|
||||
@@ -194,8 +176,7 @@ pub async fn submit_local_openresearch(args: &crate::ExpRunArgs) -> Result<Store
|
||||
.map_err(billing_friendly)?
|
||||
.sandbox;
|
||||
|
||||
let run_id = uuid::Uuid::new_v4().to_string();
|
||||
let descriptor = BackendDescriptor {
|
||||
let mut descriptor = BackendDescriptor {
|
||||
kind: "openresearch_job".to_string(),
|
||||
namespace: Some(org_id),
|
||||
job_id: Some(sandbox.id.clone()),
|
||||
@@ -209,7 +190,15 @@ pub async fn submit_local_openresearch(args: &crate::ExpRunArgs) -> Result<Store
|
||||
ssh_port: None,
|
||||
ssh_user: None,
|
||||
timeout_secs: Some(timeout_secs),
|
||||
source_digest: None,
|
||||
source_path: None,
|
||||
source_size: None,
|
||||
};
|
||||
source.apply_to_descriptor(&mut descriptor);
|
||||
if let Err(error) = crate::compute::record_submission_handle(&run_id, &descriptor) {
|
||||
let _ = openresearch::teardown(&creds, &sandbox.id).await;
|
||||
return Err(error);
|
||||
}
|
||||
let run = StoredRun {
|
||||
id: run_id.clone(),
|
||||
experiment_id: exp.id.clone(),
|
||||
@@ -221,9 +210,11 @@ pub async fn submit_local_openresearch(args: &crate::ExpRunArgs) -> Result<Store
|
||||
updated_at: now_ms(),
|
||||
ended_at: None,
|
||||
exit_code: None,
|
||||
commit_sha: Some(commit_sha),
|
||||
commit_sha: Some(source.revision),
|
||||
result_markdown: None,
|
||||
cancel_requested: false,
|
||||
cancel_requested: store
|
||||
.get_run(&run_id)?
|
||||
.is_some_and(|run| run.cancel_requested),
|
||||
chat_session_id: crate::local::chat::launching_chat_session(),
|
||||
};
|
||||
|
||||
|
||||
+21
-7
@@ -33,6 +33,10 @@ fn unique_project_slug(store: &Store, base: &str) -> Result<String> {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn project_slug_preview(store: &Store, name: &str) -> Result<String> {
|
||||
unique_project_slug(store, &slugify(name))
|
||||
}
|
||||
|
||||
pub(crate) fn expand_path(path: &str) -> Result<PathBuf> {
|
||||
let trimmed = path.trim();
|
||||
if trimmed.is_empty() {
|
||||
@@ -60,6 +64,7 @@ fn prepare_path(
|
||||
create_folder: bool,
|
||||
initialize_git: bool,
|
||||
clone_url: Option<&str>,
|
||||
shallow_clone: bool,
|
||||
) -> Result<PathBuf> {
|
||||
let path = expand_path(path)?;
|
||||
if let Some(url) = clone_url.map(str::trim).filter(|url| !url.is_empty()) {
|
||||
@@ -74,7 +79,7 @@ fn prepare_path(
|
||||
} else if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
git::clone_public(url, &path)?;
|
||||
git::clone_public(url, &path, shallow_clone)?;
|
||||
git::rename_origin_to_upstream(&path)?;
|
||||
} else if !path.exists() {
|
||||
if !create_folder {
|
||||
@@ -115,11 +120,18 @@ pub fn create_project(
|
||||
create_folder,
|
||||
initialize_git,
|
||||
clone_url,
|
||||
shallow_clone,
|
||||
run_command,
|
||||
paper_id,
|
||||
} = options;
|
||||
let slug = unique_project_slug(store, &slugify(name))?;
|
||||
let repo_path = prepare_path(path, create_folder, initialize_git, clone_url.as_deref())?;
|
||||
let repo_path = prepare_path(
|
||||
path,
|
||||
create_folder,
|
||||
initialize_git,
|
||||
clone_url.as_deref(),
|
||||
shallow_clone,
|
||||
)?;
|
||||
if store
|
||||
.list_local_projects()?
|
||||
.iter()
|
||||
@@ -131,9 +143,9 @@ pub fn create_project(
|
||||
));
|
||||
}
|
||||
let baseline_branch = git::require_current_branch(&repo_path)?;
|
||||
|
||||
let publication = git::github_publication(&repo_path);
|
||||
let (github_owner, github_repo) = publication.unwrap_or_default();
|
||||
|
||||
let now = now_ms();
|
||||
let project = LocalProject {
|
||||
id: uuid::Uuid::new_v4().to_string(),
|
||||
@@ -158,6 +170,7 @@ pub struct CreateProjectOptions {
|
||||
pub create_folder: bool,
|
||||
pub initialize_git: bool,
|
||||
pub clone_url: Option<String>,
|
||||
pub shallow_clone: bool,
|
||||
pub run_command: Option<String>,
|
||||
pub paper_id: Option<String>,
|
||||
}
|
||||
@@ -282,12 +295,12 @@ mod tests {
|
||||
let remotes = git::remotes(Path::new(&project.repo_path)).unwrap();
|
||||
assert_eq!(remotes[0].0, "upstream");
|
||||
assert!(!remotes.iter().any(|(name, _)| name == "origin"));
|
||||
assert!(!project.github_enabled());
|
||||
assert!(project.github_owner.is_empty());
|
||||
std::fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ordinary_github_origin_remains_local_only() {
|
||||
fn ordinary_github_origin_remains_opt_in() {
|
||||
let root = root();
|
||||
let project_path = root.join("project");
|
||||
initialized(&project_path);
|
||||
@@ -315,7 +328,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dedicated_github_remote_is_recognized() {
|
||||
fn dedicated_github_remote_is_recognized_but_remains_opt_in() {
|
||||
let root = root();
|
||||
let project_path = root.join("project");
|
||||
initialized(&project_path);
|
||||
@@ -324,7 +337,7 @@ mod tests {
|
||||
&[
|
||||
"remote",
|
||||
"add",
|
||||
git::GITHUB_REMOTE,
|
||||
"github",
|
||||
"git@github.com:example/research.git",
|
||||
],
|
||||
);
|
||||
@@ -336,6 +349,7 @@ mod tests {
|
||||
CreateProjectOptions::default(),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(!project.github_enabled());
|
||||
assert_eq!(project.github_owner, "example");
|
||||
assert_eq!(project.github_repo, "research");
|
||||
std::fs::remove_dir_all(root).unwrap();
|
||||
|
||||
+32
-44
@@ -3,11 +3,11 @@
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::commands::exp::{hf_clone_script, spawn_detached_supervise};
|
||||
use crate::commands::exp::spawn_detached_supervise;
|
||||
use crate::compute::SourceSnapshot;
|
||||
use crate::error::{anyhow, Result};
|
||||
use crate::jobs::ssh::sh_quote;
|
||||
use crate::jobs::{huggingface, ray, BackendDescriptor};
|
||||
use crate::local::git;
|
||||
use crate::store::{now_ms, Store, StoredRun};
|
||||
|
||||
/// CLI wrapper: submit, then print the summary.
|
||||
@@ -31,6 +31,14 @@ pub async fn launch_local_ray(args: &crate::ExpRunArgs) -> Result<()> {
|
||||
|
||||
/// Submit the local experiment's run as a Ray Job and detach a supervisor.
|
||||
pub async fn submit_local_ray(args: &crate::ExpRunArgs) -> Result<StoredRun> {
|
||||
crate::compute::submit(args).await
|
||||
}
|
||||
|
||||
pub async fn submit_local_ray_with_source(
|
||||
args: &crate::ExpRunArgs,
|
||||
source: SourceSnapshot,
|
||||
run_id: String,
|
||||
) -> Result<StoredRun> {
|
||||
if args.sandbox.is_some() || args.gpu.is_some() || args.cpu.is_some() {
|
||||
return Err(anyhow!(
|
||||
"--backend ray submits to your Ray cluster; drop --gpu/--cpu/--sandbox and \
|
||||
@@ -84,22 +92,6 @@ pub async fn submit_local_ray(args: &crate::ExpRunArgs) -> Result<StoredRun> {
|
||||
)
|
||||
})?;
|
||||
|
||||
if !args.force {
|
||||
if let Some(r) = store
|
||||
.list_runs_by_experiment(&exp.id)?
|
||||
.into_iter()
|
||||
.find(|r| !crate::local::is_terminal(&r.status))
|
||||
{
|
||||
return Err(anyhow!(
|
||||
"Run {} is already in flight for this experiment ({}). \
|
||||
Cancel it with `orx exp cancel {}` or pass --force to launch anyway.",
|
||||
r.id,
|
||||
r.status,
|
||||
exp.id
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// Reachability check before we touch git / allocate a run id.
|
||||
ray::preflight(&address).await.map_err(|e| {
|
||||
anyhow!(
|
||||
@@ -109,58 +101,44 @@ pub async fn submit_local_ray(args: &crate::ExpRunArgs) -> Result<StoredRun> {
|
||||
)
|
||||
})?;
|
||||
|
||||
let commit_sha = {
|
||||
let project = project.clone();
|
||||
let branch = exp.branch_name.clone();
|
||||
tokio::task::spawn_blocking(move || git::publish_branch_commit(&project, &branch))
|
||||
.await
|
||||
.map_err(|e| anyhow!("git task failed: {e}"))??
|
||||
};
|
||||
|
||||
let run_id = uuid::Uuid::new_v4().to_string();
|
||||
// Ray submission ids: letters, digits, dashes, underscores.
|
||||
let submission_id = format!("orx-{}", run_id.replace('-', ""));
|
||||
let script = hf_clone_script(
|
||||
&commit_sha,
|
||||
&project.github_owner,
|
||||
&project.github_repo,
|
||||
&run_command,
|
||||
);
|
||||
// The job env: everything the user synced (API keys), plus the tokens the
|
||||
// clone step expects. Ray renders runtime_env in its dashboard, but anyone
|
||||
// run step expects. Ray renders runtime_env in its dashboard, but anyone
|
||||
// with dashboard access can submit jobs anyway — same trust boundary.
|
||||
let mut env: HashMap<String, String> = crate::config::list_synced_env().into_iter().collect();
|
||||
if let Ok(hf_token) = huggingface::resolve_token() {
|
||||
env.entry("HF_TOKEN".to_string()).or_insert(hf_token);
|
||||
}
|
||||
if let Some(gh) = git::resolve_github_token() {
|
||||
// Overrides any synced GITHUB_TOKEN: the clone URL embeds exactly this
|
||||
// variable, and it must be the token the branch was pushed with.
|
||||
env.insert("GITHUB_TOKEN".to_string(), gh);
|
||||
}
|
||||
let mut metadata = HashMap::new();
|
||||
metadata.insert("or_run".to_string(), run_id.clone());
|
||||
metadata.insert("or_experiment".to_string(), exp.id.clone());
|
||||
metadata.insert("or_project".to_string(), project.id.clone());
|
||||
|
||||
let (package_digest, package_path) = source
|
||||
.ray_package
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow!("Ray source package was not created."))?;
|
||||
let working_dir = ray::stage_working_dir(&address, package_digest, package_path).await?;
|
||||
ray::run_job(
|
||||
&address,
|
||||
&ray::JobSubmission {
|
||||
entrypoint: format!("bash -c {}", sh_quote(&script)),
|
||||
entrypoint: format!("bash -c {}", sh_quote(&run_command)),
|
||||
submission_id: submission_id.clone(),
|
||||
resources,
|
||||
env,
|
||||
metadata,
|
||||
working_dir: Some(working_dir),
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
let watch = ray::job_url(&address, &submission_id);
|
||||
|
||||
let descriptor = BackendDescriptor {
|
||||
let mut descriptor = BackendDescriptor {
|
||||
kind: "ray_job".to_string(),
|
||||
namespace: Some(address.clone()),
|
||||
job_id: Some(submission_id),
|
||||
job_id: Some(submission_id.clone()),
|
||||
flavor: args.flavor.clone(),
|
||||
image: None,
|
||||
url: Some(watch),
|
||||
@@ -171,7 +149,15 @@ pub async fn submit_local_ray(args: &crate::ExpRunArgs) -> Result<StoredRun> {
|
||||
ssh_port: None,
|
||||
ssh_user: None,
|
||||
timeout_secs: None,
|
||||
source_digest: None,
|
||||
source_path: None,
|
||||
source_size: None,
|
||||
};
|
||||
source.apply_to_descriptor(&mut descriptor);
|
||||
if let Err(error) = crate::compute::record_submission_handle(&run_id, &descriptor) {
|
||||
let _ = ray::stop_job(&address, &submission_id).await;
|
||||
return Err(error);
|
||||
}
|
||||
let run = StoredRun {
|
||||
id: run_id.clone(),
|
||||
experiment_id: exp.id.clone(),
|
||||
@@ -183,9 +169,11 @@ pub async fn submit_local_ray(args: &crate::ExpRunArgs) -> Result<StoredRun> {
|
||||
updated_at: now_ms(),
|
||||
ended_at: None,
|
||||
exit_code: None,
|
||||
commit_sha: Some(commit_sha),
|
||||
commit_sha: Some(source.revision),
|
||||
result_markdown: None,
|
||||
cancel_requested: false,
|
||||
cancel_requested: store
|
||||
.get_run(&run_id)?
|
||||
.is_some_and(|run| run.cancel_requested),
|
||||
chat_session_id: crate::local::chat::launching_chat_session(),
|
||||
};
|
||||
store.upsert_run(&run)?;
|
||||
|
||||
+19
-21
@@ -55,16 +55,13 @@ Workflow:
|
||||
4. Publish: `trackio logbook publish <hf-username>/<openreview-id>` (the OpenReview ID from the paper reference above; after later edits, `trackio logbook sync`).
|
||||
"#;
|
||||
|
||||
const ICML_REPRO_LOCAL_TEMPLATE: &str = r#"Prepare an ICML 2026 reproduction in this local-only project.
|
||||
const ICML_REPRO_LOCAL_TEMPLATE: &str = r#"Prepare an ICML 2026 reproduction in this project.
|
||||
|
||||
Paper: {args}
|
||||
|
||||
The challenge workflow requires Hugging Face Jobs and publication, which are
|
||||
not available while this project is local-only. Do not launch `hf jobs` or
|
||||
bypass the OpenResearch compute gate. Explain that the user must explicitly
|
||||
enable GitHub syncing for this project before the challenge workflow can
|
||||
run. Until then, you may read the paper and outline a local experiment plan,
|
||||
but do not provision external compute or publish a logbook.
|
||||
Use `orx exp run --backend hf` for formal runs; source snapshots are uploaded
|
||||
directly and do not require repository publication. Publishing the Trackio
|
||||
logbook is a separate explicit workflow.
|
||||
"#;
|
||||
|
||||
const REPRODUCE_PAPER_TEMPLATE: &str = r#"Reproduce a research paper claim by claim on the user's compute.
|
||||
@@ -133,7 +130,7 @@ orx never binds a new experiment root to `main` — every node gets its own `orx
|
||||
Reproduce the claim:
|
||||
1. Create a child experiment for the selected claim.
|
||||
2. Encode all parameters in committed code or configuration and keep the inherited run command unchanged.
|
||||
3. Commit and push before launching; remote jobs clone the pushed branch.
|
||||
3. Commit before launching; `orx` transfers an immutable source snapshot directly.
|
||||
4. Launch with `orx exp run <experiment-id> --backend <backend> ...` (omit `--backend` to use the configured default target).
|
||||
5. Hold the turn open with `orx exp wait` until the run is terminal, then read the evidence with `orx logs <run-id>`.
|
||||
6. Record findings immediately with `orx exp desc`.
|
||||
@@ -223,16 +220,17 @@ Finish by reporting:
|
||||
Do not stop after creating the notebook. Finish only after the reproduction is analyzed, the notebook is validated, public links work, and provenance is recorded in the experiment tree.
|
||||
"#;
|
||||
|
||||
const REPRODUCE_PAPER_LOCAL_TEMPLATE: &str = r#"Reproduce a research paper claim by claim in this local-only project.
|
||||
const REPRODUCE_PAPER_LOCAL_TEMPLATE: &str = r#"Reproduce a research paper claim by claim in this unpublished project.
|
||||
|
||||
Paper and compute: {args}
|
||||
|
||||
Use the configured local repository and `orx` experiment tree. Read the paper,
|
||||
enumerate its empirical claims, and choose the smallest honest reproduction
|
||||
that fits this machine. Create experiment nodes for meaningful variants, make
|
||||
each change on its printed local branch, commit it, and launch only with
|
||||
`orx exp run <experiment-id> --backend local`. Keep the inherited run command
|
||||
fixed. Wait with `orx exp wait --project <project-id>`, inspect `orx runs`, and
|
||||
that fits the available compute. Create experiment nodes for meaningful variants,
|
||||
make each change on its printed local branch, and commit it. Use the configured
|
||||
default compute target, or the explicit backend and flavor/host the user chose;
|
||||
source snapshots do not require GitHub. Keep the inherited run command fixed.
|
||||
Wait with `orx exp wait --project <project-id>`, inspect `orx runs`, and
|
||||
read every terminal run with `orx logs <run-id>`.
|
||||
|
||||
Record the paper number, observed number, scale or substitutions, sample size,
|
||||
@@ -244,17 +242,17 @@ Produce a self-contained local report under the project's Artifacts directory,
|
||||
with figures in an adjacent `images/` folder. Open with the strongest result,
|
||||
separate paper evidence from observed evidence, and link experiment branches by
|
||||
name without assuming hosted URLs. Do not publish, change repository visibility,
|
||||
or contact a Git hosting service. If the user later wants hosted artifacts or
|
||||
external compute, ask them to enable GitHub syncing for this project first.
|
||||
or contact a Git hosting service unless they explicitly request publication.
|
||||
"#;
|
||||
|
||||
const PAPER_TO_MARIMO_LOCAL_TEMPLATE: &str = r#"Reproduce a paper's main illustrative claim and create a self-contained local marimo tutorial.
|
||||
|
||||
Paper, compute, and preferences: {args}
|
||||
|
||||
This project is local-only. Read the paper, choose one illustrative empirical
|
||||
claim, and use committed `orx/*` experiment branches with
|
||||
`orx exp run <experiment-id> --backend local`. Keep formal evidence in run
|
||||
This project is unpublished. Read the paper, choose one illustrative empirical
|
||||
claim, and use committed `orx/*` experiment branches on the configured default
|
||||
compute target or the explicit backend the user chose. Source snapshots do not
|
||||
require GitHub. Keep formal evidence in run
|
||||
logs, record conclusions with `orx exp desc`, and state every downscaling or
|
||||
substitution honestly.
|
||||
|
||||
@@ -426,15 +424,15 @@ mod tests {
|
||||
#[test]
|
||||
fn local_publication_skills_keep_artifacts_local() {
|
||||
let reproduce = super::expand("/reproduce-paper example", false).unwrap();
|
||||
assert!(reproduce.contains("local-only"));
|
||||
assert!(reproduce.contains("unpublished"));
|
||||
assert!(!reproduce.contains("git push"));
|
||||
assert!(!reproduce.contains("public GitHub"));
|
||||
let marimo = super::expand("/paper-to-marimo example", false).unwrap();
|
||||
assert!(marimo.contains("Artifacts directory"));
|
||||
assert!(!marimo.contains("git push"));
|
||||
let icml = super::expand("/icml-repro example", false).unwrap();
|
||||
assert!(icml.contains("local-only"));
|
||||
assert!(icml.contains("Do not launch `hf jobs`"));
|
||||
assert!(icml.contains("source snapshots"));
|
||||
assert!(!icml.contains("GitHub syncing"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+33
-60
@@ -7,9 +7,9 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::commands::exp::spawn_detached_supervise;
|
||||
use crate::compute::SourceSnapshot;
|
||||
use crate::error::{anyhow, Result};
|
||||
use crate::jobs::{huggingface, slurm, BackendDescriptor};
|
||||
use crate::local::git;
|
||||
use crate::store::{now_ms, Store, StoredRun};
|
||||
|
||||
/// CLI wrapper around `submit_local_slurm`: submit, then print the summary.
|
||||
@@ -34,6 +34,14 @@ pub async fn launch_local_slurm(args: &crate::ExpRunArgs) -> Result<()> {
|
||||
/// supervisor. Requires `--backend slurm`; the login node comes from
|
||||
/// `--host <alias>` or the slurm settings default.
|
||||
pub async fn submit_local_slurm(args: &crate::ExpRunArgs) -> Result<StoredRun> {
|
||||
crate::compute::submit(args).await
|
||||
}
|
||||
|
||||
pub async fn submit_local_slurm_with_source(
|
||||
args: &crate::ExpRunArgs,
|
||||
source: SourceSnapshot,
|
||||
run_id: String,
|
||||
) -> Result<StoredRun> {
|
||||
if args.sandbox.is_some() || args.gpu.is_some() || args.cpu.is_some() {
|
||||
return Err(anyhow!(
|
||||
"--backend slurm runs on your own cluster; drop --gpu/--cpu/--sandbox and \
|
||||
@@ -101,68 +109,23 @@ pub async fn submit_local_slurm(args: &crate::ExpRunArgs) -> Result<StoredRun> {
|
||||
})?;
|
||||
|
||||
// One run in flight per experiment unless deliberately forced.
|
||||
if !args.force {
|
||||
if let Some(r) = store
|
||||
.list_runs_by_experiment(&exp.id)?
|
||||
.into_iter()
|
||||
.find(|r| !crate::local::is_terminal(&r.status))
|
||||
{
|
||||
return Err(anyhow!(
|
||||
"Run {} is already in flight for this experiment ({}). \
|
||||
Cancel it with `orx exp cancel {}` or pass --force to launch anyway.",
|
||||
r.id,
|
||||
r.status,
|
||||
exp.id
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// The login node clones from GitHub, so the branch tip must exist there.
|
||||
let commit_sha = {
|
||||
let project = project.clone();
|
||||
let branch = exp.branch_name.clone();
|
||||
tokio::task::spawn_blocking(move || git::publish_branch_commit(&project, &branch))
|
||||
.await
|
||||
.map_err(|e| anyhow!("git task failed: {e}"))??
|
||||
};
|
||||
|
||||
// Clone on the login node at submit time (compute nodes often lack
|
||||
// internet); no apt-get fallback — nobody has root there, and preflight
|
||||
// reports missing git. Unlike hf_clone_script the token must NOT ride in
|
||||
// the URL: login nodes are multi-tenant (`/proc/<pid>/cmdline` is world-
|
||||
// readable during the clone) and git would persist the credentialed URL
|
||||
// in repo/.git/config on the shared filesystem. The inline credential
|
||||
// helper reads the env at auth time instead.
|
||||
let setup_script = format!(
|
||||
"set -eo pipefail; \
|
||||
git init -q repo; cd repo; git remote add origin {url}; \
|
||||
git -c credential.helper= -c credential.helper='!f() {{ echo username=x-access-token; echo \"password=$GITHUB_TOKEN\"; }}; f' \
|
||||
fetch --depth 1 origin {commit}; git checkout --detach FETCH_HEAD",
|
||||
commit = crate::jobs::ssh::sh_quote(&commit_sha),
|
||||
url = crate::jobs::ssh::sh_quote(&format!(
|
||||
"https://github.com/{}/{}.git",
|
||||
project.github_owner, project.github_repo
|
||||
)),
|
||||
);
|
||||
|
||||
// The job env: everything the user synced (API keys), plus the tokens the
|
||||
// clone step expects. Exported in the setup script and job.sbatch.
|
||||
// run step expects. Exported in the setup script and job.sbatch.
|
||||
let mut env: HashMap<String, String> = crate::config::list_synced_env().into_iter().collect();
|
||||
if let Ok(hf_token) = huggingface::resolve_token() {
|
||||
env.entry("HF_TOKEN".to_string()).or_insert(hf_token);
|
||||
}
|
||||
if let Some(gh) = git::resolve_github_token() {
|
||||
// Overrides any synced GITHUB_TOKEN (unlike HF_TOKEN's or_insert):
|
||||
// the credential helper reads exactly this variable, and it must be
|
||||
// the token the branch was pushed with.
|
||||
env.insert("GITHUB_TOKEN".to_string(), gh);
|
||||
}
|
||||
|
||||
let run_id = uuid::Uuid::new_v4().to_string();
|
||||
crate::jobs::ssh::stage_source(
|
||||
&crate::jobs::ssh::SshTarget::alias(&host),
|
||||
&run_id,
|
||||
&source.path,
|
||||
&source.digest,
|
||||
)
|
||||
.await?;
|
||||
let job_id = slurm::run_job(&slurm::SlurmJobSpec {
|
||||
host: host.clone(),
|
||||
run_id: run_id.clone(),
|
||||
setup_script,
|
||||
setup_script: "test -d repo".to_string(),
|
||||
command: run_command.clone(),
|
||||
env,
|
||||
gres: args.flavor.as_deref().and_then(slurm::resolve_gres),
|
||||
@@ -172,10 +135,10 @@ pub async fn submit_local_slurm(args: &crate::ExpRunArgs) -> Result<StoredRun> {
|
||||
})
|
||||
.await?;
|
||||
|
||||
let descriptor = BackendDescriptor {
|
||||
let mut descriptor = BackendDescriptor {
|
||||
kind: "slurm_job".to_string(),
|
||||
namespace: Some(host),
|
||||
job_id: Some(job_id),
|
||||
namespace: Some(host.clone()),
|
||||
job_id: Some(job_id.clone()),
|
||||
flavor: args.flavor.clone(),
|
||||
image: None,
|
||||
url: None,
|
||||
@@ -186,7 +149,15 @@ pub async fn submit_local_slurm(args: &crate::ExpRunArgs) -> Result<StoredRun> {
|
||||
ssh_port: None,
|
||||
ssh_user: None,
|
||||
timeout_secs: None,
|
||||
source_digest: None,
|
||||
source_path: None,
|
||||
source_size: None,
|
||||
};
|
||||
source.apply_to_descriptor(&mut descriptor);
|
||||
if let Err(error) = crate::compute::record_submission_handle(&run_id, &descriptor) {
|
||||
let _ = slurm::cancel_job(&host, &job_id).await;
|
||||
return Err(error);
|
||||
}
|
||||
let run = StoredRun {
|
||||
id: run_id.clone(),
|
||||
experiment_id: exp.id.clone(),
|
||||
@@ -198,9 +169,11 @@ pub async fn submit_local_slurm(args: &crate::ExpRunArgs) -> Result<StoredRun> {
|
||||
updated_at: now_ms(),
|
||||
ended_at: None,
|
||||
exit_code: None,
|
||||
commit_sha: Some(commit_sha),
|
||||
commit_sha: Some(source.revision),
|
||||
result_markdown: None,
|
||||
cancel_requested: false,
|
||||
cancel_requested: store
|
||||
.get_run(&run_id)?
|
||||
.is_some_and(|run| run.cancel_requested),
|
||||
chat_session_id: crate::local::chat::launching_chat_session(),
|
||||
};
|
||||
store.upsert_run(&run)?;
|
||||
|
||||
+29
-43
@@ -6,10 +6,10 @@
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::commands::exp::{hf_clone_script, spawn_detached_supervise};
|
||||
use crate::commands::exp::spawn_detached_supervise;
|
||||
use crate::compute::SourceSnapshot;
|
||||
use crate::error::{anyhow, Result};
|
||||
use crate::jobs::{ssh, BackendDescriptor};
|
||||
use crate::local::git;
|
||||
use crate::store::{now_ms, Store, StoredRun};
|
||||
|
||||
/// CLI wrapper around `submit_local_ssh`: submit, then print the summary.
|
||||
@@ -34,6 +34,14 @@ pub async fn launch_local_ssh(args: &crate::ExpRunArgs) -> Result<()> {
|
||||
/// detach a supervisor. Requires `--backend ssh` and `--flavor <host>` where
|
||||
/// the host is an `~/.ssh/config` alias.
|
||||
pub async fn submit_local_ssh(args: &crate::ExpRunArgs) -> Result<StoredRun> {
|
||||
crate::compute::submit(args).await
|
||||
}
|
||||
|
||||
pub async fn submit_local_ssh_with_source(
|
||||
args: &crate::ExpRunArgs,
|
||||
source: SourceSnapshot,
|
||||
run_id: String,
|
||||
) -> Result<StoredRun> {
|
||||
if args.sandbox.is_some() || args.gpu.is_some() || args.cpu.is_some() {
|
||||
return Err(anyhow!(
|
||||
"--backend ssh runs on your own box; drop --gpu/--cpu/--sandbox and pass \
|
||||
@@ -81,61 +89,29 @@ pub async fn submit_local_ssh(args: &crate::ExpRunArgs) -> Result<StoredRun> {
|
||||
})?;
|
||||
|
||||
// One run in flight per experiment unless deliberately forced.
|
||||
if !args.force {
|
||||
if let Some(r) = store
|
||||
.list_runs_by_experiment(&exp.id)?
|
||||
.into_iter()
|
||||
.find(|r| !crate::local::is_terminal(&r.status))
|
||||
{
|
||||
return Err(anyhow!(
|
||||
"Run {} is already in flight for this experiment ({}). \
|
||||
Cancel it with `orx exp cancel {}` or pass --force to launch anyway.",
|
||||
r.id,
|
||||
r.status,
|
||||
exp.id
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// The remote clones from GitHub, so the branch tip must exist there.
|
||||
let commit_sha = {
|
||||
let project = project.clone();
|
||||
let branch = exp.branch_name.clone();
|
||||
tokio::task::spawn_blocking(move || git::publish_branch_commit(&project, &branch))
|
||||
.await
|
||||
.map_err(|e| anyhow!("git task failed: {e}"))??
|
||||
};
|
||||
|
||||
let run_id = uuid::Uuid::new_v4().to_string();
|
||||
let script = hf_clone_script(
|
||||
&commit_sha,
|
||||
&project.github_owner,
|
||||
&project.github_repo,
|
||||
&run_command,
|
||||
);
|
||||
let target = ssh::SshTarget::alias(&host);
|
||||
ssh::stage_source(&target, &run_id, &source.path, &source.digest).await?;
|
||||
let script = crate::compute::staged_script(&run_command);
|
||||
|
||||
// The remote env: everything the user synced (API keys), plus the tokens
|
||||
// the clone script expects. Exported inside run.sh (written owner-only).
|
||||
// the run script expects. Exported inside run.sh (written owner-only).
|
||||
let mut env: HashMap<String, String> = crate::config::list_synced_env().into_iter().collect();
|
||||
if let Ok(hf_token) = crate::jobs::huggingface::resolve_token() {
|
||||
env.entry("HF_TOKEN".to_string()).or_insert(hf_token);
|
||||
}
|
||||
if let Some(gh) = git::resolve_github_token() {
|
||||
env.insert("GITHUB_TOKEN".to_string(), gh);
|
||||
}
|
||||
|
||||
let remote_dir = ssh::run_job(&ssh::SshJobSpec {
|
||||
target: ssh::SshTarget::alias(&host),
|
||||
target: target.clone(),
|
||||
run_id: run_id.clone(),
|
||||
script,
|
||||
env,
|
||||
})
|
||||
.await?;
|
||||
|
||||
let descriptor = BackendDescriptor {
|
||||
let mut descriptor = BackendDescriptor {
|
||||
kind: "ssh_job".to_string(),
|
||||
namespace: Some(host),
|
||||
job_id: Some(remote_dir),
|
||||
job_id: Some(remote_dir.clone()),
|
||||
flavor: None,
|
||||
image: None,
|
||||
url: None,
|
||||
@@ -146,7 +122,15 @@ pub async fn submit_local_ssh(args: &crate::ExpRunArgs) -> Result<StoredRun> {
|
||||
ssh_port: None,
|
||||
ssh_user: None,
|
||||
timeout_secs: None,
|
||||
source_digest: None,
|
||||
source_path: None,
|
||||
source_size: None,
|
||||
};
|
||||
source.apply_to_descriptor(&mut descriptor);
|
||||
if let Err(error) = crate::compute::record_submission_handle(&run_id, &descriptor) {
|
||||
let _ = ssh::cancel_job(&target, &remote_dir).await;
|
||||
return Err(error);
|
||||
}
|
||||
let run = StoredRun {
|
||||
id: run_id.clone(),
|
||||
experiment_id: exp.id.clone(),
|
||||
@@ -158,9 +142,11 @@ pub async fn submit_local_ssh(args: &crate::ExpRunArgs) -> Result<StoredRun> {
|
||||
updated_at: now_ms(),
|
||||
ended_at: None,
|
||||
exit_code: None,
|
||||
commit_sha: Some(commit_sha),
|
||||
commit_sha: Some(source.revision),
|
||||
result_markdown: None,
|
||||
cancel_requested: false,
|
||||
cancel_requested: store
|
||||
.get_run(&run_id)?
|
||||
.is_some_and(|run| run.cancel_requested),
|
||||
chat_session_id: crate::local::chat::launching_chat_session(),
|
||||
};
|
||||
store.upsert_run(&run)?;
|
||||
|
||||
+25
@@ -14,6 +14,7 @@ mod browser;
|
||||
#[allow(dead_code)]
|
||||
mod client;
|
||||
mod commands;
|
||||
mod compute;
|
||||
mod config;
|
||||
mod error;
|
||||
mod folder_picker;
|
||||
@@ -177,6 +178,18 @@ enum Command {
|
||||
/// an approval card and blocks until answered. Not a user command.
|
||||
#[command(name = "mcp-gate", hide = true)]
|
||||
McpGate,
|
||||
|
||||
/// Internal: detached worker for optional local-project publication.
|
||||
#[command(name = "publish-branch", hide = true)]
|
||||
PublishBranch(PublishBranchArgs),
|
||||
}
|
||||
|
||||
#[derive(Args, Debug)]
|
||||
struct PublishBranchArgs {
|
||||
repo_path: std::path::PathBuf,
|
||||
branch: String,
|
||||
owner: String,
|
||||
repo: String,
|
||||
}
|
||||
|
||||
#[derive(Args, Debug)]
|
||||
@@ -897,6 +910,15 @@ async fn main() {
|
||||
}
|
||||
return;
|
||||
}
|
||||
if let Command::PublishBranch(args) = &command {
|
||||
if let Err(err) =
|
||||
local::git::push_branch(&args.repo_path, &args.branch, &args.owner, &args.repo)
|
||||
{
|
||||
eprintln!("orx publish-branch: {err}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let warning = (!matches!(
|
||||
command,
|
||||
@@ -964,6 +986,7 @@ fn command_name(command: &Command) -> &'static str {
|
||||
Command::Telemetry(_) => "telemetry",
|
||||
Command::PlanGate => "plan-gate",
|
||||
Command::McpGate => "mcp-gate",
|
||||
Command::PublishBranch(_) => "publish-branch",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1019,6 +1042,7 @@ async fn dispatch(command: Command) -> error::Result<()> {
|
||||
// Handled before dispatch (fast path, no telemetry/update check).
|
||||
Command::PlanGate => commands::plan_gate::run().await,
|
||||
Command::McpGate => commands::mcp_gate::run().await,
|
||||
Command::PublishBranch(_) => unreachable!("handled before dispatch"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1035,5 +1059,6 @@ fn command_uses_lifecycle_lock(command: &Command) -> bool {
|
||||
| Command::Telemetry(_)
|
||||
| Command::PlanGate
|
||||
| Command::McpGate
|
||||
| Command::PublishBranch(_)
|
||||
)
|
||||
}
|
||||
|
||||
+17
-34
@@ -137,12 +137,6 @@ impl ControlPlane for LocalPlane {
|
||||
println!(" id: {}", project.id);
|
||||
println!(" repo: {}", project.repo_path);
|
||||
println!(" branch: {} (baseline)", project.baseline_branch);
|
||||
if project.github_enabled() {
|
||||
println!(
|
||||
" GitHub: {}/{}",
|
||||
project.github_owner, project.github_repo
|
||||
);
|
||||
}
|
||||
match project
|
||||
.run_command
|
||||
.as_deref()
|
||||
@@ -296,29 +290,9 @@ impl ControlPlane for LocalPlane {
|
||||
// BEFORE the flag validations below, so e.g. `--host box1` with a default
|
||||
// of `ssh` is a valid launch, and before `backend_label` is captured, so
|
||||
// telemetry records the resolved backend.
|
||||
let project_id = &self.experiment()?.project_id;
|
||||
let github_enabled = self
|
||||
.store
|
||||
.get_local_project(project_id)?
|
||||
.ok_or_else(|| anyhow!("Local project {project_id} not found."))?
|
||||
.github_enabled();
|
||||
if !github_enabled {
|
||||
match args.backend.as_deref() {
|
||||
None => {
|
||||
args.backend = Some("local".to_string());
|
||||
}
|
||||
Some("local") => {}
|
||||
Some(_) => {
|
||||
return Err(anyhow!(
|
||||
"Remote compute requires this project's GitHub repository. Enable GitHub syncing for this project, then retry."
|
||||
));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
crate::local::apply_compute_default(&mut args.backend, &mut args.flavor);
|
||||
if args.backend.is_none() {
|
||||
args.backend = Some("local".to_string());
|
||||
}
|
||||
crate::local::apply_compute_default(&mut args.backend, &mut args.flavor);
|
||||
if args.backend.is_none() {
|
||||
args.backend = Some("local".to_string());
|
||||
}
|
||||
if args.manifest.is_some() && args.backend.as_deref() != Some("k8s") {
|
||||
return Err(anyhow!("--manifest only applies with --backend k8s."));
|
||||
@@ -527,6 +501,19 @@ impl ControlPlane for LocalPlane {
|
||||
run_command,
|
||||
)?;
|
||||
|
||||
if project.github_enabled() {
|
||||
if let Err(error) = crate::local::git::spawn_branch_publication(
|
||||
std::path::Path::new(&project.repo_path),
|
||||
&experiment.branch_name,
|
||||
&project.github_owner,
|
||||
&project.github_repo,
|
||||
) {
|
||||
eprintln!(
|
||||
" warning: experiment created locally, but GitHub sync could not start: {error}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
println!("\u{2713} Created local {} experiment", kind);
|
||||
if defaulted_to_root {
|
||||
let root = parent_exp.as_ref().unwrap();
|
||||
@@ -555,11 +542,7 @@ impl ControlPlane for LocalPlane {
|
||||
println!(" cd {}", project.repo_path);
|
||||
println!(" git checkout {}", experiment.branch_name);
|
||||
println!(" # …edit, then…");
|
||||
if project.github_enabled() {
|
||||
println!(" git commit -am \"<msg>\" && git push");
|
||||
} else {
|
||||
println!(" git commit -am \"<msg>\"");
|
||||
}
|
||||
println!(" git commit -am \"<msg>\"");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
+15
-294
@@ -17,19 +17,16 @@ use super::{
|
||||
ControlPlane, CreateExperimentSpec, DescInput, LogRequest, ProjectEdit, Run, RunListing, RunLog,
|
||||
};
|
||||
use crate::client::{
|
||||
cancel_experiment_run, create_baseline_experiment, create_child_experiment,
|
||||
create_external_run, create_report, download_report_file, find_project, get_experiment,
|
||||
get_project, get_report, list_experiments, list_reports, list_runs, read_run_log,
|
||||
start_experiment_run, update_experiment, update_project, upload_to_presigned,
|
||||
CreateBaselineExperimentBody, CreateChildBody, CreateReportBody, RunTarget,
|
||||
UpdateExperimentBody, UpdateProjectBody,
|
||||
cancel_experiment_run, create_baseline_experiment, create_child_experiment, create_report,
|
||||
download_report_file, find_project, get_experiment, get_project, get_report, list_experiments,
|
||||
list_reports, list_runs, read_run_log, start_experiment_run, update_experiment, update_project,
|
||||
upload_to_presigned, CreateBaselineExperimentBody, CreateChildBody, CreateReportBody,
|
||||
RunTarget, UpdateExperimentBody, UpdateProjectBody,
|
||||
};
|
||||
use crate::commands::experiments::print_tree;
|
||||
use crate::config::Credentials;
|
||||
use crate::error::{anyhow, require_credentials, Result};
|
||||
use crate::jobs::{huggingface as hf, BackendDescriptor};
|
||||
use crate::output::format_duration;
|
||||
use crate::store::{now_ms, Store, StoredRun};
|
||||
use crate::{ExpRunArgs, ReportCommand};
|
||||
|
||||
/// The cloud-api plane. `id` is the project/experiment/run id the command
|
||||
@@ -703,8 +700,14 @@ impl ServerPlane {
|
||||
));
|
||||
}
|
||||
match args.backend.as_deref() {
|
||||
Some("hf") => return self.launch_hf(args).await,
|
||||
Some("modal") => return self.launch_modal(args).await,
|
||||
Some("hf" | "modal") => {
|
||||
return Err(anyhow!(
|
||||
"--backend {} requires a local experiment (`orx up`) so orx can stage its \
|
||||
immutable source snapshot directly. Server experiments run through managed \
|
||||
OpenResearch compute with --gpu/--cpu/--sandbox.",
|
||||
args.backend.as_deref().unwrap_or_default()
|
||||
));
|
||||
}
|
||||
Some("k8s") => {
|
||||
return Err(anyhow!(
|
||||
"--backend k8s is supported for local experiments (`orx up`) only for now."
|
||||
@@ -738,9 +741,8 @@ impl ServerPlane {
|
||||
}
|
||||
Some(other) => {
|
||||
return Err(anyhow!(
|
||||
"Unknown --backend '{}'. Supported: hf (Hugging Face Jobs), \
|
||||
modal (Modal serverless GPUs), k8s/ssh/slurm/ray/openresearch/local \
|
||||
(local experiments only).",
|
||||
"Unknown --backend '{}'. External backends are available for local \
|
||||
experiments (`orx up`) only.",
|
||||
other
|
||||
));
|
||||
}
|
||||
@@ -824,287 +826,6 @@ impl ServerPlane {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `--backend hf` — run the experiment as a Hugging Face Job on the user's
|
||||
/// own HF account. (Former `exp::launch_hf`.)
|
||||
async fn launch_hf(&self, args: ExpRunArgs) -> Result<()> {
|
||||
let creds = &self.creds;
|
||||
if args.sandbox.is_some() || args.gpu.is_some() || args.cpu.is_some() {
|
||||
return Err(anyhow!(
|
||||
"--backend hf runs on Hugging Face Jobs; drop --gpu/--cpu/--sandbox \
|
||||
and pass --flavor instead (e.g. --flavor a10g-small)."
|
||||
));
|
||||
}
|
||||
let flavor = args.flavor.clone().ok_or_else(|| {
|
||||
anyhow!(
|
||||
"--backend hf requires --flavor: t4-small, a10g-small/large, l4x1, \
|
||||
l40sx1, a100-large, h200, … (cpu-basic/cpu-upgrade for CPU). \
|
||||
Priced per minute on your Hugging Face account."
|
||||
)
|
||||
})?;
|
||||
// HF's own default is 30 minutes — a footgun for training runs, so
|
||||
// default generously and let --timeout tighten it.
|
||||
let timeout_seconds = match &args.timeout {
|
||||
Some(t) => hf::parse_timeout(t)?,
|
||||
None => 4 * 3600,
|
||||
};
|
||||
let token = hf::resolve_token()?;
|
||||
let namespace = hf::whoami(&token).await?;
|
||||
|
||||
// Register first: the run must exist in the tree before compute starts,
|
||||
// and the response carries the repo/branch/command orx needs to submit.
|
||||
let mut descriptor = BackendDescriptor {
|
||||
kind: "hf_job".to_string(),
|
||||
namespace: Some(namespace.clone()),
|
||||
job_id: None,
|
||||
flavor: Some(flavor.clone()),
|
||||
image: args.image.clone(),
|
||||
url: None,
|
||||
context: None,
|
||||
manifest: None,
|
||||
resources: None,
|
||||
ssh_host: None,
|
||||
ssh_port: None,
|
||||
ssh_user: None,
|
||||
timeout_secs: None,
|
||||
};
|
||||
let created =
|
||||
create_external_run(creds, &args.exp_id, serde_json::to_value(&descriptor)?).await?;
|
||||
let run_id = created.run.id.clone();
|
||||
|
||||
let image = args
|
||||
.image
|
||||
.clone()
|
||||
.unwrap_or_else(|| crate::commands::exp::default_hf_image(&flavor));
|
||||
let script = crate::commands::exp::hf_clone_script(
|
||||
&created.branch_name,
|
||||
&created.github_owner,
|
||||
&created.github_repo,
|
||||
&created.run_command,
|
||||
);
|
||||
|
||||
let mut secrets = HashMap::new();
|
||||
secrets.insert("HF_TOKEN".to_string(), token.clone());
|
||||
// Clone credential precedence: explicit GITHUB_TOKEN (env, then the box's
|
||||
// synced env file) overrides; otherwise the api's repo-scoped
|
||||
// installation token flows automatically from the org's connected GitHub
|
||||
// app — a private repo needs zero extra setup beyond having connected it.
|
||||
let github_token = std::env::var("GITHUB_TOKEN")
|
||||
.ok()
|
||||
.filter(|t| !t.trim().is_empty())
|
||||
.or_else(|| crate::config::synced_env_var("GITHUB_TOKEN"))
|
||||
.or_else(|| created.github_token.clone());
|
||||
if let Some(gh) = github_token {
|
||||
secrets.insert("GITHUB_TOKEN".to_string(), gh);
|
||||
}
|
||||
let mut labels = HashMap::new();
|
||||
labels.insert("or_run".to_string(), run_id.clone());
|
||||
labels.insert("or_experiment".to_string(), args.exp_id.clone());
|
||||
labels.insert("or_project".to_string(), created.project_id.clone());
|
||||
|
||||
let job = hf::run_job(
|
||||
&token,
|
||||
&namespace,
|
||||
&hf::JobSubmission {
|
||||
command: vec!["bash".to_string(), "-c".to_string(), script],
|
||||
docker_image: image.clone(),
|
||||
flavor: flavor.clone(),
|
||||
environment: HashMap::new(),
|
||||
secrets,
|
||||
timeout_seconds,
|
||||
labels,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Record the job handle: local store (the truth orx serve exposes), then
|
||||
// the api mirror (display + reconciliation). Local write must not be lost
|
||||
// even if the PATCH fails — supervise needs it to reattach.
|
||||
descriptor.job_id = Some(job.id.clone());
|
||||
descriptor.url = Some(hf::job_url(&namespace, &job.id));
|
||||
descriptor.image = Some(image);
|
||||
let store = Store::open()?;
|
||||
store.upsert_run(&StoredRun {
|
||||
id: run_id.clone(),
|
||||
experiment_id: args.exp_id.clone(),
|
||||
project_id: created.project_id.clone(),
|
||||
status: "starting".to_string(),
|
||||
backend_json: descriptor.to_json(),
|
||||
command: created.run_command.clone(),
|
||||
created_at: now_ms(),
|
||||
updated_at: now_ms(),
|
||||
ended_at: None,
|
||||
exit_code: None,
|
||||
commit_sha: None,
|
||||
result_markdown: None,
|
||||
cancel_requested: false,
|
||||
chat_session_id: crate::local::chat::launching_chat_session(),
|
||||
})?;
|
||||
if let Err(err) = crate::client::update_external_run(
|
||||
creds,
|
||||
&run_id,
|
||||
serde_json::json!({ "backend": serde_json::to_value(&descriptor)? }),
|
||||
)
|
||||
.await
|
||||
{
|
||||
eprintln!("warning: could not mirror the job handle to the api: {err}");
|
||||
}
|
||||
|
||||
// Detach the supervisor: it tails logs, mirrors transitions, and uploads
|
||||
// the final log. Survives this process exiting (new process group).
|
||||
crate::commands::exp::spawn_detached_supervise(&run_id)?;
|
||||
|
||||
println!("\u{2713} Hugging Face job submitted.");
|
||||
println!(" run {run_id}");
|
||||
println!(" job {}/{} ({flavor})", namespace, job.id);
|
||||
println!(" watch {}", descriptor.url.as_deref().unwrap_or(""));
|
||||
println!(
|
||||
" Follow it with `orx exp wait {}` or `orx logs {run_id}`.",
|
||||
args.exp_id
|
||||
);
|
||||
// Key event, fired only on success. Managed run on the user's HF account.
|
||||
crate::telemetry::capture_experiment_started("run", false, Some("hf"));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `--backend modal` — run the experiment as a Modal Sandbox on the user's
|
||||
/// own Modal account. (Former `exp::launch_modal`.)
|
||||
async fn launch_modal(&self, args: ExpRunArgs) -> Result<()> {
|
||||
use crate::jobs::modal;
|
||||
let creds = &self.creds;
|
||||
if args.sandbox.is_some() || args.gpu.is_some() || args.cpu.is_some() {
|
||||
return Err(anyhow!(
|
||||
"--backend modal runs on Modal serverless GPUs; drop --gpu/--cpu/--sandbox \
|
||||
and pass --flavor instead (e.g. --flavor a10g, --flavor a100-80gb, --flavor cpu)."
|
||||
));
|
||||
}
|
||||
let flavor = args.flavor.clone().ok_or_else(|| {
|
||||
anyhow!(
|
||||
"--backend modal requires --flavor: a Modal GPU (t4, l4, a10g, a100, a100-80gb, \
|
||||
l40s, h100, h200, or e.g. h100:2) — or cpu / cpu-large for CPU-only. \
|
||||
Priced per second on your Modal account."
|
||||
)
|
||||
})?;
|
||||
let resources = modal::resolve_flavor(&flavor);
|
||||
// Fail before registering the run with the api if Modal isn't set up.
|
||||
modal::preflight().await?;
|
||||
let timeout_seconds = match &args.timeout {
|
||||
Some(t) => hf::parse_timeout(t)?,
|
||||
None => 4 * 3600,
|
||||
};
|
||||
const MODAL_APP: &str = "openresearch";
|
||||
|
||||
// Register first: the run must exist in the tree before compute starts,
|
||||
// and the response carries the repo/branch/command orx needs to submit.
|
||||
let mut descriptor = BackendDescriptor {
|
||||
kind: "modal_job".to_string(),
|
||||
namespace: Some(MODAL_APP.to_string()),
|
||||
job_id: None,
|
||||
flavor: Some(flavor.clone()),
|
||||
image: args.image.clone(),
|
||||
url: None,
|
||||
context: None,
|
||||
manifest: None,
|
||||
resources: None,
|
||||
ssh_host: None,
|
||||
ssh_port: None,
|
||||
ssh_user: None,
|
||||
timeout_secs: None,
|
||||
};
|
||||
let created =
|
||||
create_external_run(creds, &args.exp_id, serde_json::to_value(&descriptor)?).await?;
|
||||
let run_id = created.run.id.clone();
|
||||
|
||||
let image = args
|
||||
.image
|
||||
.clone()
|
||||
.unwrap_or_else(|| modal::default_image(resources.gpu.is_some()));
|
||||
let script = crate::commands::exp::hf_clone_script(
|
||||
&created.branch_name,
|
||||
&created.github_owner,
|
||||
&created.github_repo,
|
||||
&created.run_command,
|
||||
);
|
||||
|
||||
// Same clone-credential precedence as the HF path: explicit GITHUB_TOKEN
|
||||
// (env, then the box's synced env file) overrides the api's repo-scoped
|
||||
// installation token.
|
||||
let mut env = HashMap::new();
|
||||
if let Ok(hf_token) = hf::resolve_token() {
|
||||
env.insert("HF_TOKEN".to_string(), hf_token);
|
||||
}
|
||||
let github_token = std::env::var("GITHUB_TOKEN")
|
||||
.ok()
|
||||
.filter(|t| !t.trim().is_empty())
|
||||
.or_else(|| crate::config::synced_env_var("GITHUB_TOKEN"))
|
||||
.or_else(|| created.github_token.clone());
|
||||
if let Some(gh) = github_token {
|
||||
env.insert("GITHUB_TOKEN".to_string(), gh);
|
||||
}
|
||||
let mut tags = HashMap::new();
|
||||
tags.insert("or_run".to_string(), run_id.clone());
|
||||
tags.insert("or_experiment".to_string(), args.exp_id.clone());
|
||||
tags.insert("or_project".to_string(), created.project_id.clone());
|
||||
|
||||
let sandbox_id = modal::run_job(&modal::ModalJobSpec {
|
||||
script,
|
||||
image: image.clone(),
|
||||
gpu: resources.gpu.clone(),
|
||||
cpu: resources.cpu,
|
||||
memory: resources.memory,
|
||||
env,
|
||||
timeout_seconds,
|
||||
app: MODAL_APP.to_string(),
|
||||
tags,
|
||||
})
|
||||
.await?;
|
||||
|
||||
// Record the sandbox handle: local store (the truth orx serve exposes),
|
||||
// then the api mirror. The local write must not be lost even if PATCH
|
||||
// fails.
|
||||
descriptor.job_id = Some(sandbox_id.clone());
|
||||
descriptor.image = Some(image);
|
||||
let store = Store::open()?;
|
||||
store.upsert_run(&StoredRun {
|
||||
id: run_id.clone(),
|
||||
experiment_id: args.exp_id.clone(),
|
||||
project_id: created.project_id.clone(),
|
||||
status: "starting".to_string(),
|
||||
backend_json: descriptor.to_json(),
|
||||
command: created.run_command.clone(),
|
||||
created_at: now_ms(),
|
||||
updated_at: now_ms(),
|
||||
ended_at: None,
|
||||
exit_code: None,
|
||||
commit_sha: None,
|
||||
result_markdown: None,
|
||||
cancel_requested: false,
|
||||
chat_session_id: crate::local::chat::launching_chat_session(),
|
||||
})?;
|
||||
if let Err(err) = crate::client::update_external_run(
|
||||
creds,
|
||||
&run_id,
|
||||
serde_json::json!({ "backend": serde_json::to_value(&descriptor)? }),
|
||||
)
|
||||
.await
|
||||
{
|
||||
eprintln!("warning: could not mirror the sandbox handle to the api: {err}");
|
||||
}
|
||||
|
||||
crate::commands::exp::spawn_detached_supervise(&run_id)?;
|
||||
|
||||
println!("\u{2713} Modal sandbox submitted.");
|
||||
println!(" run {run_id}");
|
||||
println!(" sandbox {sandbox_id} ({flavor})");
|
||||
println!(
|
||||
" Follow it with `orx exp wait {}` or `orx logs {run_id}`.",
|
||||
args.exp_id
|
||||
);
|
||||
// Key event, fired only on success. Managed run on the user's Modal
|
||||
// account.
|
||||
crate::telemetry::capture_experiment_started("run", false, Some("modal"));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// --- report subcommands (former commands::report server fns) ----------
|
||||
|
||||
async fn report_show(&self, project_id: &str, report: &str) -> Result<()> {
|
||||
|
||||
+9
-7
@@ -263,6 +263,7 @@ impl Store {
|
||||
host TEXT PRIMARY KEY,
|
||||
reachable INTEGER NOT NULL,
|
||||
git_found INTEGER NOT NULL,
|
||||
tools_found INTEGER NOT NULL DEFAULT 0,
|
||||
error TEXT,
|
||||
tested_at INTEGER NOT NULL
|
||||
);
|
||||
@@ -292,6 +293,7 @@ impl Store {
|
||||
"ALTER TABLE local_projects ADD COLUMN paper_id TEXT",
|
||||
"ALTER TABLE local_projects ADD COLUMN github_sync_enabled INTEGER NOT NULL DEFAULT 1",
|
||||
"ALTER TABLE local_experiments ADD COLUMN chat_session_id TEXT",
|
||||
"ALTER TABLE ssh_host_tests ADD COLUMN tools_found INTEGER NOT NULL DEFAULT 0",
|
||||
] {
|
||||
let _ = conn.execute(ddl, []);
|
||||
}
|
||||
@@ -1085,14 +1087,14 @@ impl Store {
|
||||
|
||||
pub fn upsert_ssh_host_test(&self, t: &SshHostTest) -> Result<()> {
|
||||
self.conn.execute(
|
||||
"INSERT INTO ssh_host_tests (host, reachable, git_found, error, tested_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5)
|
||||
"INSERT INTO ssh_host_tests (host, reachable, git_found, tools_found, error, tested_at)
|
||||
VALUES (?1, ?2, 0, ?3, ?4, ?5)
|
||||
ON CONFLICT(host) DO UPDATE SET
|
||||
reachable = excluded.reachable,
|
||||
git_found = excluded.git_found,
|
||||
tools_found = excluded.tools_found,
|
||||
error = excluded.error,
|
||||
tested_at = excluded.tested_at",
|
||||
params![t.host, t.reachable, t.git_found, t.error, t.tested_at],
|
||||
params![t.host, t.reachable, t.tools_found, t.error, t.tested_at],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -1100,12 +1102,12 @@ impl Store {
|
||||
pub fn list_ssh_host_tests(&self) -> Result<Vec<SshHostTest>> {
|
||||
let mut stmt = self
|
||||
.conn
|
||||
.prepare("SELECT host, reachable, git_found, error, tested_at FROM ssh_host_tests")?;
|
||||
.prepare("SELECT host, reachable, tools_found, error, tested_at FROM ssh_host_tests")?;
|
||||
let rows = stmt.query_map([], |row| {
|
||||
Ok(SshHostTest {
|
||||
host: row.get(0)?,
|
||||
reachable: row.get(1)?,
|
||||
git_found: row.get(2)?,
|
||||
tools_found: row.get(2)?,
|
||||
error: row.get(3)?,
|
||||
tested_at: row.get(4)?,
|
||||
})
|
||||
@@ -1123,7 +1125,7 @@ pub struct SshHostTest {
|
||||
#[serde(skip_serializing)]
|
||||
pub host: String,
|
||||
pub reachable: bool,
|
||||
pub git_found: bool,
|
||||
pub tools_found: bool,
|
||||
pub error: Option<String>,
|
||||
/// Unix millis.
|
||||
pub tested_at: i64,
|
||||
|
||||
Vendored
-1
File diff suppressed because one or more lines are too long
+150
-145
File diff suppressed because one or more lines are too long
Vendored
+1
File diff suppressed because one or more lines are too long
Vendored
+2
-2
@@ -26,8 +26,8 @@
|
||||
html { background: #ffffff; }
|
||||
html[data-theme="dark"] { background: #0e0c0c; }
|
||||
</style>
|
||||
<script type="module" crossorigin src="/assets/index-HF2E1wNS.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-9-xIg_xY.css">
|
||||
<script type="module" crossorigin src="/assets/index-B4SKGmyd.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-CDKn-8TR.css">
|
||||
</head>
|
||||
<body>
|
||||
<div
|
||||
|
||||
+11
-7
@@ -135,6 +135,8 @@ export interface ProjectPathStatus {
|
||||
directory: boolean | null;
|
||||
empty: boolean | null;
|
||||
initialized: boolean | null;
|
||||
githubOwner?: string | null;
|
||||
githubRepo?: string | null;
|
||||
}
|
||||
|
||||
export const getProjectPathStatus = (path = "") => {
|
||||
@@ -153,6 +155,7 @@ export interface NewProject {
|
||||
cloneUrl?: string;
|
||||
createFolder?: boolean;
|
||||
initializeGit?: boolean;
|
||||
githubSyncEnabled?: boolean;
|
||||
}
|
||||
|
||||
export interface CreateProjectResult {
|
||||
@@ -185,9 +188,10 @@ export const searchPapers = (q: string) =>
|
||||
* `login` is null when there's no usable token. */
|
||||
export const githubAccount = () => get<{ login: string | null }>("/api/github/account");
|
||||
|
||||
/** Whether the stored credentials can push to a repo. An unanswerable check
|
||||
* (no token / API hiccup) reports `true`, matching the server's own fallback,
|
||||
* so an outage never shows a fork choice the server wouldn't honour. */
|
||||
export const githubProjectRepoPreview = (name: string) =>
|
||||
get<{ repo: string }>(`/api/github/project-repo-preview?name=${encodeURIComponent(name)}`);
|
||||
|
||||
/** Whether the stored credentials are explicitly confirmed to push to a repo. */
|
||||
export const repoAccess = (owner: string, repo: string) =>
|
||||
get<{ canPush: boolean }>(
|
||||
`/api/github/repo-access?owner=${encodeURIComponent(owner)}&repo=${encodeURIComponent(repo)}`,
|
||||
@@ -478,13 +482,13 @@ export const getSshHosts = () =>
|
||||
|
||||
export interface SshPreflight {
|
||||
reachable: boolean;
|
||||
gitFound: boolean;
|
||||
toolsFound: boolean;
|
||||
error: string | null;
|
||||
/** Unix millis. */
|
||||
testedAt: number;
|
||||
}
|
||||
|
||||
/** Live-test a host: reachable over ssh (BatchMode) and has `git`. */
|
||||
/** Live-test a host: reachable over ssh and has bash + tar for snapshots. */
|
||||
export const sshPreflight = (host: string) =>
|
||||
post<SshPreflight>("/api/settings/ssh/preflight", { host });
|
||||
|
||||
@@ -514,12 +518,12 @@ export const saveSlurmSettings = (body: {
|
||||
export interface SlurmPreflight {
|
||||
reachable: boolean;
|
||||
slurmFound: boolean;
|
||||
gitFound: boolean;
|
||||
toolsFound: boolean;
|
||||
partitions: string[];
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
/** Live-test a login node: reachable, Slurm CLI + git present, partitions. */
|
||||
/** Live-test a login node: reachable, Slurm CLI + snapshot tools present. */
|
||||
export const slurmPreflight = (host: string) =>
|
||||
post<SlurmPreflight>("/api/settings/slurm/preflight", { host });
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -86,7 +86,7 @@ import { GitTokenForm } from "./GitTokenForm";
|
||||
import { BackendBadge, BackendLogo } from "./BackendLogos";
|
||||
import { ProgressBar } from "./ProgressBar";
|
||||
import { StatusBadge } from "./StatusBadge";
|
||||
import { BADGE_CLASS_NAME, BUTTON_CLASS_NAME, ERROR_BADGE_CLASS_NAME, ICON_BUTTON_CLASS_NAME, MONO_CLASS_NAME, PRIMARY_BUTTON_CLASS_NAME, SETTINGS_LOADING_CLASS_NAME, SMALL_BUTTON_CLASS_NAME, SMALL_PRIMARY_BUTTON_CLASS_NAME, SPINNER_CLASS_NAME, SUCCESS_BADGE_CLASS_NAME, WARNING_BADGE_CLASS_NAME } from "../styleClasses";
|
||||
import { BADGE_CLASS_NAME, BUTTON_CLASS_NAME, ERROR_BADGE_CLASS_NAME, ICON_BUTTON_CLASS_NAME, MONO_CLASS_NAME, PRIMARY_BUTTON_CLASS_NAME, SETTINGS_LOADING_CLASS_NAME, SMALL_BUTTON_CLASS_NAME, SPINNER_CLASS_NAME, SUCCESS_BADGE_CLASS_NAME, WARNING_BADGE_CLASS_NAME } from "../styleClasses";
|
||||
|
||||
const SETTINGS_CARD_CLASS_NAME = [
|
||||
"settings-card [&_>_.error]:text-accent-red [&_>_.error]:text-md",
|
||||
@@ -578,8 +578,8 @@ function HostTestCell({ test }: { test: HostTest | undefined }) {
|
||||
if (test === "testing") return <span className={SPINNER_CLASS_NAME} />;
|
||||
const badge = !test.reachable ? (
|
||||
<span className={ERROR_BADGE_CLASS_NAME} title={test.error ?? undefined}>Unreachable</span>
|
||||
) : !test.gitFound ? (
|
||||
<span className={ERROR_BADGE_CLASS_NAME}>No git</span>
|
||||
) : !test.toolsFound ? (
|
||||
<span className={ERROR_BADGE_CLASS_NAME}>Missing bash/tar</span>
|
||||
) : (
|
||||
<span className={SUCCESS_BADGE_CLASS_NAME}>Ready</span>
|
||||
);
|
||||
@@ -611,7 +611,7 @@ function SshSection() {
|
||||
...t,
|
||||
[host]: {
|
||||
reachable: false,
|
||||
gitFound: false,
|
||||
toolsFound: false,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
testedAt: Date.now(),
|
||||
},
|
||||
@@ -688,7 +688,7 @@ function SlurmTestBadge({ test }: { test: "testing" | SlurmPreflight | null }) {
|
||||
</span>
|
||||
);
|
||||
if (!test.slurmFound) return <span className={ERROR_BADGE_CLASS_NAME}>No Slurm CLI</span>;
|
||||
if (!test.gitFound) return <span className={ERROR_BADGE_CLASS_NAME}>No git</span>;
|
||||
if (!test.toolsFound) return <span className={ERROR_BADGE_CLASS_NAME}>Missing bash/tar</span>;
|
||||
return <span className={SUCCESS_BADGE_CLASS_NAME}>Ready</span>;
|
||||
}
|
||||
|
||||
@@ -754,7 +754,7 @@ function SlurmSection() {
|
||||
setTest({
|
||||
reachable: false,
|
||||
slurmFound: false,
|
||||
gitFound: false,
|
||||
toolsFound: false,
|
||||
partitions: [],
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
@@ -1377,11 +1377,9 @@ function TargetRow({
|
||||
|
||||
function ComputeTab({
|
||||
project,
|
||||
onOpenGit,
|
||||
onViewHistory,
|
||||
}: {
|
||||
project: Project | null;
|
||||
onOpenGit: () => void;
|
||||
onViewHistory: () => void;
|
||||
}) {
|
||||
const [settings, setSettings] = useState<ComputeSettings | null>(null);
|
||||
@@ -1438,14 +1436,6 @@ function ComputeTab({
|
||||
settings.configuredDefaultBackend !== null &&
|
||||
settings.configuredDefaultBackend !== undefined &&
|
||||
settings.configuredDefaultBackend !== "local";
|
||||
const githubBlocksRemoteCompute = Boolean(
|
||||
targets?.some(
|
||||
(target) =>
|
||||
target.id !== "local" &&
|
||||
!target.enabled &&
|
||||
target.disabledReason === "Connect GitHub to enable",
|
||||
),
|
||||
);
|
||||
const renderTarget = (target: ComputeTargetSummary) => (
|
||||
<TargetRow
|
||||
key={`${project?.id ?? "none"}:${target.id}`}
|
||||
@@ -1481,25 +1471,11 @@ function ComputeTab({
|
||||
{error && <div className="error">{error}</div>}
|
||||
<div className="compute-list flex flex-col gap-2.5 mb-3.5">
|
||||
{targets.filter((target) => target.id === "local").map(renderTarget)}
|
||||
{githubBlocksRemoteCompute && (
|
||||
<div className="compute-github-gate flex items-center justify-between gap-6 mt-5.5 mx-0.5 mb-0.5 [&_h3]:m-0 [&_h3]:text-md [&_h3]:font-semibold [&_p]:mt-[3px] [&_p]:mx-0 [&_p]:mb-0 [&_p]:text-subtext [&_p]:text-sm [&_.btn]:flex-none [@media((max-width:_640px))]:items-stretch [@media((max-width:_640px))]:flex-col [@media((max-width:_640px))]:gap-3">
|
||||
<div>
|
||||
<h3>Remote targets</h3>
|
||||
<p>
|
||||
Enable GitHub syncing for this project to push experiment branches and run
|
||||
them on remote compute.
|
||||
</p>
|
||||
</div>
|
||||
<button type="button" className={SMALL_PRIMARY_BUTTON_CLASS_NAME} onClick={onOpenGit}>
|
||||
Enable GitHub syncing
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{targets.filter((target) => target.id !== "local").map(renderTarget)}
|
||||
</div>
|
||||
{fallbackDefault && (
|
||||
<p className={SETTINGS_NOTE_CLASS_NAME}>
|
||||
Using this machine while the project is local-only. Your saved {settings?.configuredDefaultBackend} default will return after GitHub is enabled.
|
||||
Using this machine because the saved {settings?.configuredDefaultBackend} default is not currently configured.
|
||||
</p>
|
||||
)}
|
||||
<p className="compute-footnote flex items-start gap-1.5 mt-0.5 mx-0 mb-0 text-sm text-muted [&_svg]:flex-none [&_svg]:mt-px">
|
||||
@@ -2056,7 +2032,8 @@ function ProjectDefaultsTab() {
|
||||
<div className="project-default-title text-md font-semibold">Enable GitHub syncing for new projects</div>
|
||||
<p>
|
||||
When enabled, each new project gets a private GitHub repository. Experiment
|
||||
branches are pushed automatically so their code can run on remote compute.
|
||||
branches are pushed automatically for collaborator visibility. Compute always
|
||||
uses direct source snapshots.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
@@ -2203,7 +2180,7 @@ function GitTab({
|
||||
{!status.github.authenticated && (
|
||||
<>
|
||||
<p className="git-card-helper text-muted text-sm mt-3.5 mx-0 mb-0">
|
||||
GitHub is optional. Connect only when you want remote compute or a hosted copy.
|
||||
GitHub is optional. Connect only when you want a hosted copy for collaboration.
|
||||
</p>
|
||||
<GitTokenForm onSaved={() => load()} />
|
||||
</>
|
||||
@@ -2212,8 +2189,8 @@ function GitTab({
|
||||
<>
|
||||
<p className="git-card-helper text-muted text-sm mt-3.5 mx-0 mb-0">
|
||||
{hasGithubRepository
|
||||
? "Use this repository for automatic experiment-branch pushes when your connected account can write to it. Otherwise, OpenResearch creates a separate private repository for syncing and remote compute."
|
||||
: "Create a private repository for this project and automatically push experiment branches so they can run on remote compute."}
|
||||
? "Use this repository for automatic experiment-branch pushes when your connected account can write to it. Otherwise, OpenResearch creates a separate private repository for collaboration."
|
||||
: "Create a private repository and automatically push experiment branches for collaborator visibility."}
|
||||
</p>
|
||||
<div className={GIT_CARD_ACTIONS_CLASS_NAME}>
|
||||
{hasGithubRepository && status.github.url && <a className={BUTTON_CLASS_NAME} href={status.github.url} target="_blank" rel="noreferrer">Open on GitHub <ExternalLink size={12} /></a>}
|
||||
@@ -2224,8 +2201,8 @@ function GitTab({
|
||||
{status.github.enabled && (
|
||||
<>
|
||||
<p className="git-card-helper text-muted text-sm mt-3.5 mx-0 mb-0">
|
||||
Disabling syncing stops automatic pushes and remote compute. It does not delete
|
||||
the GitHub repository or any code already pushed there.
|
||||
Disabling syncing stops automatic pushes. Compute continues to use direct source
|
||||
snapshots. This does not delete the GitHub repository or code already pushed.
|
||||
</p>
|
||||
<div className={GIT_CARD_ACTIONS_CLASS_NAME}>
|
||||
{status.github.url && <a className={BUTTON_CLASS_NAME} href={status.github.url} target="_blank" rel="noreferrer">Open on GitHub <ExternalLink size={12} /></a>}
|
||||
@@ -2249,9 +2226,9 @@ function GitTab({
|
||||
>
|
||||
<h2 id="github-default-title">Make GitHub syncing the default?</h2>
|
||||
<p>
|
||||
This is useful if you expect to regularly run projects on remote compute. New
|
||||
projects will enable GitHub syncing automatically, creating a private repository
|
||||
when needed and pushing experiment branches for remote runs.
|
||||
This is useful when collaborators follow project changes on GitHub. New projects
|
||||
will enable syncing automatically, creating a private repository when needed and
|
||||
pushing experiment branches for visibility.
|
||||
</p>
|
||||
{defaultPromptError && <div className="error">{defaultPromptError}</div>}
|
||||
<div className="github-default-actions flex justify-end gap-2.5 mt-5.5">
|
||||
@@ -2754,7 +2731,6 @@ export function SettingsView({
|
||||
{tab === "compute" && (
|
||||
<ComputeTab
|
||||
project={project}
|
||||
onOpenGit={() => onSelectTab("git")}
|
||||
onViewHistory={() => onSelectTab("instances")}
|
||||
/>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user