diff --git a/README.md b/README.md index 13153d9..c63fcef 100644 --- a/README.md +++ b/README.md @@ -80,7 +80,8 @@ cd Blacknode ``` First run: [Beginner Walkthrough](docs/walkthrough.md) · -First robot: [SO-ARM101 Quickstart](docs/so-arm101-quickstart.md) +First robot: [Project Lifecycle](docs/project-lifecycle.md) · +[SO-ARM101 Quickstart](docs/so-arm101-quickstart.md) ## Extension Packages @@ -120,12 +121,41 @@ hardware services and shared runtime, configures active UFW rules, verifies the complete device, and prints the pairing checklist. Rerunning it preserves existing robot identities, calibrations, names, and the runtime token. -In the Blacknode editor, open **Devices**, select **Pair device**, and enter the -device URL printed by the installer, such as `http://DEVICE_IP:8765`. Paste the hardware token -printed by the installer, then paste its shared runtime token. Blacknode checks -both authenticated services before marking the device ready for deployment. -The runtime token is entered once per computer and reused automatically when -more robot hardware services from that computer are paired. +In the Blacknode editor, open **Devices** and select **Add device**. Automatic +SSH setup verifies the host key, authenticates only after confirmation, and +inspects the computer before changing it. The review shows the host OS and +Python, NVIDIA driver and CUDA toolkit, installed ROS 2 distributions, Docker +CLI/daemon state, existing Blacknode runtimes, and occupied runtime ports. +CUDA, ROS 2, and Docker system installations are preserved. Installation shows +live stage progress through package checks, download, isolated Python setup, +service installation, and pairing. If a Blacknode Runtime already exists, +choose to pair the healthy runtime, reinstall that instance, or install an +independent side-by-side instance. Side-by-side instances use separate +directories, state, pairing tokens, systemd services, and the next available +port from `8766` through `8865`. The SSH installer upgrades older downloaded +service scripts in the new checkout when instance isolation support is needed +and verifies that runtimes and robot services which were already active remain +active after the side-by-side setup. + +Runtime instances installed through the editor retain only their non-secret SSH +management identity. **Uninstall runtime** asks for the SSH password again, +removes the selected instance and its UFW rule, and leaves other runtime +instances on the computer untouched. **Remove from editor** deletes only the +local registration. **Pause device** first stops active deployments, stops and +disarms attached robots, then stops that runtime service. **Resume device** +starts the service and reconnects robot monitoring while keeping motion +disarmed; previous deployments stay stopped until explicitly started again. +Each lifecycle action shows stage progress. Each robot also has its own +**Pause robot** and **Resume robot** controls. Robot Pause still sends the +direct hardware stop when its deployment runtime is unavailable and reports +that runtime address as a warning. The robot detail reports physical torque +only when the hardware provider exposes it; otherwise it shows **Not reported** +instead of inferring torque state from the motion-armed flag. +For robots on SSH-managed computers, **Restart robot service** asks for the SSH +password, resolves the exact hardware systemd unit from that robot's saved +hardware port, and restarts only that unit. Restart is blocked while a +deployment is active or the robot is armed. Blacknode then verifies that the +same authenticated robot returned and keeps motion disarmed. The installer detects each computer's current address; it does not hardcode a LAN IP. Use a router DHCP reservation or a resolvable hostname for robots that diff --git a/docs/deployment-architecture.md b/docs/deployment-architecture.md index c2ae5dc..a9d7bff 100644 --- a/docs/deployment-architecture.md +++ b/docs/deployment-architecture.md @@ -254,16 +254,20 @@ Credentials are referenced by a local credential ID or operating-system secret store. They are never embedded in workflows, deployment artifacts, browser responses, logs, or provider-neutral configuration. -Each paired robot record uses the exact hardware service URL printed by -`blacknode-hardware` pairing. On a multi-robot computer, hardware services use -`8765`, `8767`, `8768`, and subsequent assigned ports. Port `8766` remains the -shared deployment runtime. The editor requires the hardware port explicitly, -rejects `8766` as a robot hardware endpoint, and derives the runtime URL from -the same host. Devices checks the hardware token and the shared runtime token -independently. Run `blacknode-runtime/service.sh pairing` on the device when -port `8766` needs a different token from its robot hardware services. The -editor stores that credential once for the shared runtime URL and reuses it -automatically as additional robots on the same computer are paired. +The editor registers the compute device and its shared runtime first. A device +record owns the runtime URL on port `8766` and the server-side runtime pairing +credential. Each child robot record uses the exact hardware service URL printed +by `blacknode-hardware` pairing. On a multi-robot computer, hardware services +use `8765`, `8767`, `8768`, and subsequent assigned ports. The editor rejects +`8766` as a robot hardware endpoint and binds every child robot to its parent +device's runtime. + +Existing paired-robot records migrate into this hierarchy by grouping their +shared runtime URL. Their robot IDs remain unchanged, so saved Project links +and deployment targets continue to resolve. Run +`blacknode-runtime/service.sh pairing` when the device runtime needs to be +paired manually. The editor stores that credential once on the compute device +and reuses it for all of its robots. For a workflow containing one `Robot` node, deployment also embeds the serial path reported by the selected hardware service and disables runtime USB diff --git a/docs/guided-projects.md b/docs/guided-projects.md new file mode 100644 index 0000000..6fd1b02 --- /dev/null +++ b/docs/guided-projects.md @@ -0,0 +1,118 @@ +# Guided Projects + +Guided Projects provide a click-through starting path for robot learning while +preserving the normal workflow editor. A Project can use the +`robot_learning` starter kit or remain fully custom. + +## Robot learning starter + +New Projects offer **Robot learning starter — recommended**. The Project's +**Next step** action creates, saves, links, and opens the appropriate workflow +only when that stage is needed: + +| Stage | Predefined template | +|---|---| +| Collect | `teleoperation-episode-recording` from `blacknode-dataset` | +| Train | `act-training` from `blacknode-training` | +| Simulate | `isaac-act-policy-deployment` from `blacknode-isaac` | + +The generated saved workflow is named for the Project, for example +`Demo Arm · Train ACT policy`. Its metadata records: + +```json +{ + "starter_kit": "robot_learning", + "starter_stage": "train", + "source_template": "act-training", + "project_id": "demo-arm" +} +``` + +Generation is idempotent. Pressing the action again opens the existing linked +starter workflow rather than creating a duplicate. The template is validated +and its package dependencies are checked before a saved workflow is created. +When a required package or template is unavailable, the Project displays the +dependency error and does not create a partial workflow. + +Generated workflows are prefilled from Project context: + +- collection uses a Project-specific dataset ID, task, and run IDs, selects + automatic ROS 2 transport, and remains disarmed; +- training selects the newest completed linked dataset and assigns a + Project-specific training run ID; and +- simulation selects the newest available linked policy and assigns + Project-specific Isaac run IDs while remaining in status mode. + +## Evidence-driven progression + +The helper advances from Project evidence: + +1. install the device runtime, pair the robot, and link it to the Project; +2. create and open the recording workflow; +3. calibrate the robot required by that workflow; +4. record and save at least one dataset episode; +5. create or open the ACT training workflow; +6. export a policy artifact; +7. create or open the Isaac evaluation workflow; and +8. deploy using the Project's robot-specific deployment workflow. + +Every Lifecycle card is also an action: + +- Build opens the linked workflows or prepares the first starter workflow. +- Connect opens Devices. +- Configure opens the first robot workflow missing a saved calibration + selection. +- Collect, Train, and Simulate open a custom workflow or prepare their starter + workflow. +- Deploy and Operate open Deployments. + +Configure turns complete only after every linked robot workflow has a saved +calibration selection. Preparing or opening a workflow alone does not make the +physical calibration complete. + +Artifact capture supplies the evidence for recorded episodes, training +progress, exported policies, and completed evaluations. The helper does not +mark a stage complete merely because its template was opened. + +## Custom workflows + +Users can link any saved workflow at any time. For each lifecycle stage, an +existing custom linked workflow takes priority over a generated starter +workflow. Starter workflows stay ordinary editable saved workflows. + +Selecting **Use custom setup** stops predefined template suggestions and keeps +all already linked workflows and artifacts. Selecting +**Use robot learning starter** enables the helper on an existing Project. + +Custom Projects do not create starter workflows. This keeps Projects for +deterministic controllers, perception, agents, and other applications outside +the robot-learning sequence. + +## API and storage + +The Project record stores: + +```json +{ + "starter_kit": "robot_learning" +} +``` + +Existing Projects load with `starter_kit: null`. + +The editor server exposes: + +```text +POST /projects/{project_id}/starter-workflows/{stage} +``` + +`stage` is `collect`, `train`, or `simulate`. The response includes the +hydrated Project, the saved workflow reference, and whether it was newly +created. + +## Safety + +Starter generation only prepares and opens workflows. It does not arm a robot, +start recording, begin training, start simulation, or deploy automatically. +Robot motion remains disarmed by default and continues through the workflow's +normal calibration, stale-data, limit, and shutdown checks. diff --git a/docs/project-artifacts.md b/docs/project-artifacts.md new file mode 100644 index 0000000..4d097a5 --- /dev/null +++ b/docs/project-artifacts.md @@ -0,0 +1,139 @@ +# Project Artifacts v1 + +Project Artifacts give the Blacknode project lifecycle durable evidence for +data collection, policy training, and simulation. A Project links small typed +references. The extension package that created an artifact remains the owner +of its files and native manifest. + +## Goals + +- Show what a Project has produced, not only which workflows it can run. +- Turn Collect, Train, and Simulate into evidence-backed lifecycle stages. +- Reuse the manifests already produced by Blacknode extension packages. +- Capture supported node outputs automatically while a linked workflow runs. +- Link existing artifacts by an exact path for work created before Projects. +- Keep model binaries, datasets, logs, credentials, and calibration data out of + the Project registry. + +## Ownership + +| Artifact | Authoritative owner | Indexed evidence | +|---|---|---| +| Episode dataset | `blacknode-dataset` | ID, path, task, FPS, episode/frame counts | +| Training run | `blacknode-training` | run ID, output path, phase, progress, losses | +| Checkpoint | `blacknode-training` | checkpoint path, run ID, step | +| Policy | `blacknode-training` | policy path, type, source checkpoint, dimensions | +| Replay evaluation | `blacknode-training` | episode, frames, aggregate errors | +| Simulation run | `blacknode-isaac` | run ID, log path, phase, inference counters | + +The artifact index is local editor state at `.blacknode/artifacts.json`. +Projects store only `artifact_ids` in `.blacknode/projects.json`. Unlinking an +artifact from a Project does not remove the index record or its source files. +Deleting a Project does not delete artifacts. + +## Reference contract + +Each indexed reference has this provider-neutral shape: + +```json +{ + "id": "dataset-2bd193725faf064e5d7a", + "artifact_type": "dataset", + "kind": "blacknode.episode-dataset", + "provider": "blacknode-dataset", + "name": "pick-cube", + "locator": "/home/alex/.blacknode/datasets/pick-cube", + "status": "completed", + "workflow_slugs": ["collect-demonstrations"], + "metadata": { + "task": "Pick cube", + "fps": 30, + "episode_count": 12 + }, + "created_at": "2026-07-24T20:00:00+00:00", + "updated_at": "2026-07-24T20:10:00+00:00" +} +``` + +`artifact_type` is one of `dataset`, `training_run`, `checkpoint`, `policy`, +`evaluation`, or `simulation_run`. `status` is `available`, `running`, +`completed`, or `failed`. + +The ID is deterministic from provider, artifact type, and locator. Repeated +status events update one reference instead of adding duplicate cards. Logical +locators under `blacknode://` are used when a managed run has an identity but +has not produced a file path yet. + +Metadata is an allowlisted summary. Fields whose names look like credentials, +tokens, secrets, passwords, or authorization data are never indexed. Import is +bounded by depth and item count, and checkpoint/model binaries are never +opened. + +## Supported native records + +The v1 importer understands: + +- `blacknode.episode-dataset` +- `blacknode.training-job` +- `blacknode.training-run` +- `blacknode.action-chunking-checkpoint` +- `blacknode.policy-artifact` +- `blacknode.policy-replay-metrics` +- `blacknode.policy-runtime` from an Isaac node + +Exact-path inspection accepts a supported JSON manifest, a directory containing +`dataset.json`, `run.json`, or `manifest.json`, or a checkpoint `.pt` path. It +does not crawl the filesystem or guess from unrelated files. + +## Capture flow + +When a node in a saved workflow linked to the active Project succeeds: + +1. the editor checks the output for a supported native record; +2. it sends only candidate output data to the Project artifact import endpoint; +3. the server sanitizes and normalizes the reference; +4. the artifact index is inserted or updated by deterministic ID; and +5. the Project links the ID and refreshes its lifecycle evidence. + +Artifact capture is auxiliary evidence. A capture error cannot change a +successful node cook into a failed cook. + +## Lifecycle evidence + +| Stage | Complete evidence | +|---|---| +| Collect | A linked dataset reports one or more saved episodes | +| Train | A linked policy artifact exists | +| Simulate | A linked simulation run or evaluation is completed | + +A created empty dataset, running training job, checkpoint, or running +simulation is shown as progress, not completion. If no artifact exists yet, +the presence of the corresponding workflow keeps the stage available. + +The Project overview chooses a guided action from the first relevant gap: +record the first episode, start or monitor training, evaluate a ready policy, +then deploy and operate. + +## HTTP API + +- `POST /projects/{project_id}/artifacts/import` captures supported typed + records from a node output. +- `POST /projects/{project_id}/artifacts/inspect` adds supported records from + one exact local path. +- `PATCH /projects/{project_id}` with `artifact_ids` links or unlinks + references. + +An import carrying `workflow_slug` is accepted only when that workflow is +linked to the Project. Read responses hydrate `artifacts` in the Project +payload and report whether filesystem-backed sources currently exist. + +## Compatibility and next versions + +Projects created before artifact support load with an empty `artifact_ids` +list. Existing package manifests and artifact files do not change. + +Future versions can add provider registration, remote/object-store locators, +lineage between datasets/checkpoints/policies, evaluation thresholds, +deployment-to-policy links, telemetry evidence, and garbage collection for +unlinked index records. Those additions must keep the provider-owned artifact +and reference-only Project boundary. diff --git a/docs/project-lifecycle.md b/docs/project-lifecycle.md new file mode 100644 index 0000000..1c1d71a --- /dev/null +++ b/docs/project-lifecycle.md @@ -0,0 +1,94 @@ +# Project Lifecycle + +Blacknode guides a robot application from the first installation through a +running deployment. Projects keep the workflow, paired robot, configuration, +deployment state, and generated artifacts together. + +## 1. Start the editor + +Install Blacknode on the computer where workflows will be built: + +```powershell +git clone https://github.com/temiroff/Blacknode.git +cd Blacknode +.\start.bat +``` + +On macOS or Linux, run `./start.sh` from the cloned repository. The launcher +prepares the Python and editor dependencies, starts the backend and editor, and +opens Blacknode in the browser. + +Create a Project from the **Projects** panel. The standard robot-deployment +setup is the default. Robot-learning projects can select the learning starter +when they also need collection, training, and simulation stages. + +## 2. Add a device, then its robots + +Select **Set up robot** from the Project's Next step and open **Devices**. Start +with the Jetson, Raspberry Pi, or Ubuntu computer that will run Blacknode +workflows. + +For automatic setup, choose **Add device → Automatic SSH** and enter the +computer's IP address, username, and password. Blacknode first displays the +SSH host-key fingerprint for confirmation. After confirmation it installs the +runtime service, configures runtime port `8766` in UFW when UFW is active, and +pairs the runtime with the editor. The SSH password remains in memory for that +setup request and is not saved. + +For manual setup, install `blacknode-runtime` on the device, run +`./service.sh pairing`, and enter the printed runtime URL and token under +**Add device → Pair manually**. + +Open the new square device card and choose **Add robot** for each hardware +service connected to that computer. Enter the robot URL and token printed by +`blacknode-hardware/pair.sh`. One device may contain several robots; each robot +remains a distinct deployment target and uses its parent device's shared +runtime. Pairing and discovery keep physical motion disarmed. + +When a robot is added while a Project is active, Blacknode links that robot to +the Project automatically. + +## 3. Choose the workflow + +Return to the Project and select **Choose workflow**. Start with a tested +template or link a saved workflow. Templates declare their required Blacknode +packages, and deployment synchronizes those package requirements to the target +device. + +The workflow remains portable. Robot-specific topics, device paths, providers, +and calibration belong to its selected robot profile and capability adapters. + +## 4. Configure the robot + +The Project's Configure stage opens the linked robot workflow. Select the robot +profile and the saved calibration for the physical hardware identity. Read-only +workflows can proceed when calibration is optional. Motion workflows require +the matching calibration, feedback capabilities, and safe limits. + +## 5. Check and deploy + +Open **Deploy**, select the linked device, and choose **Check setup**. Preflight +verifies: + +- authenticated hardware and runtime services; +- required packages and capabilities; +- robot profile and physical hardware identity; +- selected and active calibration; +- disarmed state and deployment ownership; and +- the validated workflow revision. + +After preflight succeeds, **Send to device** stages the workflow. +**Send & run** stages and explicitly starts it. Neither action bypasses the +hardware service's arming, freshness, limit, or shutdown checks. + +## 6. Operate and iterate + +The Project shows the running deployment and its owning device. Use +**Deployments** to inspect logs, stop, update, or roll back a revision. Stops +remain explicit because stopping a hardware workflow may release actuator +torque. + +Update the graph, check setup again, and send a new revision to iterate. Project +artifacts retain evidence from datasets, training runs, policies, evaluations, +and simulations while their extension packages continue to own the underlying +files. diff --git a/docs/projects.md b/docs/projects.md index bd037a6..dea0693 100644 --- a/docs/projects.md +++ b/docs/projects.md @@ -17,12 +17,17 @@ Projects v1 provides: - restoration of linked workflows as editor tabs; - a project/workflow breadcrumb while a linked workflow is active; - lifecycle guidance based on evidence in linked workflows and devices; and +- typed links to provider-owned dataset, training, policy, and simulation + artifacts; and +- an optional robot-learning starter that prepares the next predefined + workflow; and - a view of deployments reported by linked devices. Project data is local editor state. It is stored in `.blacknode/projects.json`, is excluded from Git, and must not contain pairing -tokens, calibration data, workflow graph copies, run logs, or deployment -artifacts. +tokens, calibration data, workflow graph copies, run logs, or artifact +contents. Typed references are indexed separately in +`.blacknode/artifacts.json`; see [Project Artifacts v1](project-artifacts.md). ## Ownership @@ -35,7 +40,7 @@ A project stores references, not copies: | Robot profile and calibration selection | workflow metadata | discovered from the linked workflow | | Calibration record | hardware service | never copied into the project | | Deployment revision and process state | target runtime | project ID and workflow slug | -| Dataset and training artifacts | their package or artifact store | future typed references | +| Dataset, training, policy, and simulation artifacts | their extension package | typed artifact ID | This keeps credentials and physical-hardware calibration bound to their existing secure and hardware-aware owners. @@ -60,6 +65,10 @@ The local project registry uses schema version 1: "alex-desktop-usb-...31481...", "alex-desktop-usb-...31741..." ], + "artifact_ids": [ + "dataset-2bd193725faf064e5d7a" + ], + "starter_kit": "robot_learning", "active_workflow_slug": "so-arm101-leader-deploy", "created_at": "2026-07-24T20:00:00+00:00", "updated_at": "2026-07-24T20:00:00+00:00" @@ -86,6 +95,9 @@ The editor server exposes: - `GET /projects/{project_id}` - `PATCH /projects/{project_id}` - `DELETE /projects/{project_id}` +- `POST /projects/{project_id}/artifacts/import` +- `POST /projects/{project_id}/artifacts/inspect` +- `POST /projects/{project_id}/starter-workflows/{stage}` Create and update requests use the persisted fields above. Read responses also hydrate workflow and device references with their current names, availability, @@ -127,27 +139,35 @@ The project overview uses evidence, not a manually advanced wizard: | Stage | Evidence shown in v1 | |---|---| -| Build | One or more saved workflows are linked | | Connect | One or more paired devices are linked | -| Calibrate | A linked workflow selects a robot calibration | -| Collect | A linked workflow contains dataset recording nodes | -| Train | A linked workflow contains policy-training nodes | -| Simulate | A linked workflow contains simulation nodes | +| Build | One or more saved workflows are linked | +| Configure | A linked workflow selects a robot calibration when required | +| Collect | A linked dataset contains at least one saved episode | +| Train | A linked policy artifact exists | +| Simulate | A linked simulation run or evaluation completed | | Deploy | The target runtime reports a staged or active deployment owned by the project | | Operate | The target runtime reports a running deployment owned by the project | Collect, Train, and Simulate are optional paths. Their absence does not block a project that deploys an already available policy or a deterministic controller. -The overview calls them available or not configured rather than complete when -artifact-level proof is not yet available. +A corresponding workflow makes the stage available; an empty dataset, running +job, checkpoint, or running simulation shows progress; only the artifact +evidence above marks it complete. The next action is selected from the first actionable gap: -1. link a saved workflow; -2. link a paired device; +1. install the device runtime, pair the robot, and link it; +2. choose or link a saved workflow; 3. select calibration when a robot workflow requires it; -4. open the linked workflows; -5. deploy or monitor a running deployment. +4. record the first episode when collection is configured; +5. train or monitor a policy when recorded data is ready; +6. evaluate a ready policy when simulation is configured; +7. deploy or monitor a running deployment. + +When `starter_kit` is `robot_learning`, Next can create, link, and open the +predefined collection, ACT training, or Isaac evaluation workflow needed for +the current gap. A custom linked workflow for that stage takes priority. See +[Guided Projects](guided-projects.md). ## Editor behavior @@ -164,11 +184,10 @@ The next action is selected from the first actionable gap: ## Future extensions -The v1 schema is intentionally small. Later versions can add typed references -for datasets, training runs, models, simulations, deployment IDs, dashboards, -telemetry views, project members, and cloud synchronization. The deployment -ownership fields provide the stable join for exact deployment history when a -history index is added. +Later versions can add artifact lineage, evaluation gates, deployment-to-policy +links, dashboards, telemetry views, project members, and cloud +synchronization. The deployment ownership fields provide the stable join for +exact deployment history when a history index is added. Those additions should preserve the core boundary: applications and workflows depend on capabilities and stable resource identities, while providers retain diff --git a/editor-server/artifact_store.py b/editor-server/artifact_store.py new file mode 100644 index 0000000..ea8a524 --- /dev/null +++ b/editor-server/artifact_store.py @@ -0,0 +1,487 @@ +"""Typed references to artifacts owned by Blacknode extension packages.""" + +from __future__ import annotations + +import hashlib +import json +import os +import re +import threading +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Iterable + + +_SECRET_RE = re.compile( + r"(api[_-]?key|token|secret|password|credential|authorization)", + re.IGNORECASE, +) +_MAX_IMPORT_DEPTH = 8 +_MAX_IMPORT_ITEMS = 500 + + +class ArtifactStoreError(RuntimeError): + """An artifact reference could not be inspected or persisted.""" + + +def _iso_now() -> str: + return datetime.now(timezone.utc).isoformat() + + +def _clean_text(value: Any, *, maximum: int = 500) -> str: + clean = str(value or "").strip() + if any(ord(char) < 32 for char in clean): + clean = "".join(char for char in clean if ord(char) >= 32) + return clean[:maximum] + + +def _safe_metadata(values: dict[str, Any], allowed: Iterable[str]) -> dict[str, Any]: + result: dict[str, Any] = {} + for key in allowed: + if _SECRET_RE.search(key) or key not in values: + continue + value = values[key] + if value is None or isinstance(value, (str, int, float, bool)): + result[key] = _clean_text(value) if isinstance(value, str) else value + elif isinstance(value, list): + result[key] = [ + _clean_text(item, maximum=120) + for item in value[:100] + if isinstance(item, (str, int, float, bool)) + ] + elif isinstance(value, dict): + nested: dict[str, Any] = {} + for nested_key, nested_value in list(value.items())[:100]: + if _SECRET_RE.search(str(nested_key)): + continue + if nested_value is None or isinstance( + nested_value, (str, int, float, bool) + ): + nested[str(nested_key)[:120]] = ( + _clean_text(nested_value, maximum=120) + if isinstance(nested_value, str) + else nested_value + ) + result[key] = nested + return result + + +def _path_locator(value: Any) -> str: + clean = _clean_text(value, maximum=2000) + if not clean: + return "" + return str(Path(clean).expanduser().resolve()) + + +def _record_id(provider: str, artifact_type: str, locator: str) -> str: + digest = hashlib.sha256( + f"{provider}\0{artifact_type}\0{locator}".encode("utf-8") + ).hexdigest()[:20] + return f"{artifact_type}-{digest}" + + +def _status_for_job(payload: dict[str, Any]) -> str: + phase = _clean_text(payload.get("phase"), maximum=80).lower() + if payload.get("running"): + return "running" + if phase in {"complete", "completed", "succeeded", "success", "stopped"}: + return "completed" + if phase in {"failed", "error"} or payload.get("error"): + return "failed" + return "available" + + +def _artifact_candidate( + payload: dict[str, Any], + *, + node_type: str = "", +) -> dict[str, Any] | None: + kind = _clean_text(payload.get("kind"), maximum=160) + now = _iso_now() + artifact_type = "" + provider = "" + locator = "" + name = "" + status = "available" + metadata: dict[str, Any] = {} + + if kind == "blacknode.episode-dataset": + artifact_type = "dataset" + provider = "blacknode-dataset" + locator = _path_locator(payload.get("path")) + dataset_id = _clean_text(payload.get("dataset_id"), maximum=160) + name = dataset_id or (Path(locator).name if locator else "Dataset") + episodes = int(payload.get("episode_count") or 0) + status = "completed" if episodes > 0 else "available" + metadata = _safe_metadata( + payload, + ( + "dataset_id", + "task", + "fps", + "episode_count", + "total_frames", + "duration_seconds", + "robot_type", + "joint_names", + "cameras", + ), + ) + elif kind in {"blacknode.training-job", "blacknode.training-run"}: + artifact_type = "training_run" + provider = "blacknode-training" + run_id = _clean_text(payload.get("run_id"), maximum=160) + locator = _path_locator(payload.get("output_dir")) + if not locator: + locator = f"blacknode://training/runs/{run_id or 'unknown'}" + name = run_id or (Path(locator).name if locator else "Training run") + status = ( + _status_for_job(payload) + if kind == "blacknode.training-job" + else "available" + ) + metadata = _safe_metadata( + payload, + ( + "run_id", + "phase", + "running", + "step", + "steps", + "progress", + "train_loss", + "validation_loss", + "best_validation_loss", + "device", + "started_at", + "ended_at", + "error", + ), + ) + elif kind == "blacknode.action-chunking-checkpoint": + artifact_type = "checkpoint" + provider = "blacknode-training" + locator = _path_locator(payload.get("path")) + name = Path(locator).name if locator else "Training checkpoint" + status = "completed" + metadata = _safe_metadata(payload, ("run_id", "step", "created_at")) + elif kind == "blacknode.policy-artifact": + artifact_type = "policy" + provider = "blacknode-training" + locator = _path_locator(payload.get("path")) + name = ( + _clean_text(payload.get("name"), maximum=160) + or (Path(locator).name if locator else "Policy") + ) + status = "completed" + metadata = _safe_metadata( + payload, + ( + "policy_type", + "backend", + "created_at", + "source_checkpoint", + "step", + "action_mode", + "units", + "joint_names", + "camera_names", + "fps", + "state_dim", + "action_dim", + "metrics", + ), + ) + elif kind == "blacknode.policy-replay-metrics": + artifact_type = "evaluation" + provider = "blacknode-training" + policy = _path_locator(payload.get("policy_artifact")) + episode = int(payload.get("episode_index") or 0) + fingerprint = hashlib.sha256( + json.dumps(payload, sort_keys=True, default=str).encode() + ).hexdigest()[:20] + locator = f"blacknode://policy-replay/{fingerprint}" + name = f"Policy replay · episode {episode}" + status = "completed" + metadata = _safe_metadata( + payload, + ( + "episode_index", + "frames", + "mean_absolute_error", + "root_mean_square_error", + "max_absolute_error", + "motion_commanded", + ), + ) + if policy: + metadata["policy_artifact"] = policy + elif kind == "blacknode.policy-runtime" and "isaac" in node_type.lower(): + artifact_type = "simulation_run" + provider = "blacknode-isaac" + run_id = _clean_text(payload.get("run_id"), maximum=160) + locator = _path_locator(payload.get("log_path")) + if not locator: + locator = f"blacknode://isaac/runs/{run_id or 'unknown'}" + name = run_id or "Isaac policy run" + status = _status_for_job(payload) + metadata = _safe_metadata( + payload, + ( + "run_id", + "phase", + "running", + "inference_count", + "command_count", + "blocked_count", + "mean_inference_ms", + "last_error", + "armed", + "emergency_stop", + "human_takeover", + ), + ) + else: + return None + + if not locator: + return None + created_at = _clean_text(payload.get("created_at"), maximum=100) or now + artifact_id = _record_id(provider, artifact_type, locator) + return { + "id": artifact_id, + "artifact_type": artifact_type, + "kind": kind, + "provider": provider, + "name": name[:160], + "locator": locator, + "status": status, + "metadata": metadata, + "created_at": created_at, + "updated_at": now, + } + + +def _walk_dicts(value: Any) -> Iterable[dict[str, Any]]: + stack: list[tuple[Any, int]] = [(value, 0)] + seen = 0 + while stack and seen < _MAX_IMPORT_ITEMS: + current, depth = stack.pop() + seen += 1 + if depth > _MAX_IMPORT_DEPTH: + continue + if isinstance(current, dict): + yield current + stack.extend((item, depth + 1) for item in current.values()) + elif isinstance(current, (list, tuple)): + stack.extend((item, depth + 1) for item in current) + + +class ArtifactStore: + """Persist small, provider-neutral references without copying artifacts.""" + + def __init__(self, path: Path) -> None: + self.path = Path(path) + self._lock = threading.RLock() + + def list(self, artifact_ids: Iterable[str] | None = None) -> list[dict[str, Any]]: + with self._lock: + records = self._load() + selected = ( + [records[item] for item in artifact_ids or [] if item in records] + if artifact_ids is not None + else list(records.values()) + ) + return [self._hydrate(record) for record in selected] + + def import_value( + self, + value: Any, + *, + node_type: str = "", + workflow_slug: str | None = None, + ) -> list[dict[str, Any]]: + candidates: dict[str, dict[str, Any]] = {} + for payload in _walk_dicts(value): + candidate = _artifact_candidate(payload, node_type=node_type) + if candidate is None: + continue + if workflow_slug: + candidate["workflow_slugs"] = [ + _clean_text(workflow_slug, maximum=60) + ] + candidates[candidate["id"]] = candidate + + if payload.get("kind") == "blacknode.training-job": + checkpoint = _path_locator(payload.get("checkpoint")) + if checkpoint: + checkpoint_payload = { + "kind": "blacknode.action-chunking-checkpoint", + "path": checkpoint, + "run_id": payload.get("run_id"), + "step": payload.get("step"), + } + derived = _artifact_candidate(checkpoint_payload) + if derived is not None: + if workflow_slug: + derived["workflow_slugs"] = [ + _clean_text(workflow_slug, maximum=60) + ] + candidates[derived["id"]] = derived + if not candidates: + return [] + with self._lock: + records = self._load() + for artifact_id, candidate in candidates.items(): + prior = records.get(artifact_id) + if prior: + candidate["created_at"] = prior.get( + "created_at", candidate["created_at"] + ) + candidate["workflow_slugs"] = list( + dict.fromkeys([ + *list(prior.get("workflow_slugs") or []), + *list(candidate.get("workflow_slugs") or []), + ]) + ) + records[artifact_id] = candidate + self._save(records) + return [self._hydrate(records[item]) for item in candidates] + + def inspect_path( + self, + raw_path: str, + *, + workflow_slug: str | None = None, + ) -> list[dict[str, Any]]: + clean = _clean_text(raw_path, maximum=2000) + if not clean: + raise ArtifactStoreError("Artifact path is required.") + path = Path(clean).expanduser().resolve() + if not path.exists(): + raise ArtifactStoreError(f"Artifact path does not exist: {path}") + + candidates: list[Path] = [] + if path.is_dir(): + candidates = [ + item + for item in ( + path / "dataset.json", + path / "run.json", + path / "manifest.json", + ) + if item.is_file() + ] + elif path.suffix.lower() == ".pt": + return self.import_value( + { + "kind": "blacknode.action-chunking-checkpoint", + "path": str(path), + }, + workflow_slug=workflow_slug, + ) + elif path.suffix.lower() == ".json": + candidates = [path] + + imported: list[dict[str, Any]] = [] + for candidate in candidates: + try: + payload = json.loads(candidate.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + raise ArtifactStoreError( + f"Could not read artifact manifest {candidate}: {exc}" + ) from exc + if isinstance(payload, dict): + if payload.get("kind") == "blacknode.episode-dataset": + episodes = payload.get("episodes") + if isinstance(episodes, list): + payload.setdefault("episode_count", len(episodes)) + payload.setdefault( + "total_frames", + sum( + int(item.get("frames") or 0) + for item in episodes + if isinstance(item, dict) + ), + ) + if payload.get("kind") == "blacknode.training-run": + config = payload.get("config") + if isinstance(config, dict): + payload.setdefault("run_id", config.get("run_id")) + if payload.get("kind") in { + "blacknode.episode-dataset", + "blacknode.policy-artifact", + }: + payload["path"] = str(candidate.parent) + elif not payload.get("path"): + payload["path"] = str(candidate.parent) + if payload.get("kind") == "blacknode.training-run": + payload["output_dir"] = str(candidate.parent) + imported.extend( + self.import_value(payload, workflow_slug=workflow_slug) + ) + if path.is_dir(): + imported.extend( + self.import_value( + [ + { + "kind": "blacknode.action-chunking-checkpoint", + "path": str(item), + } + for item in sorted(path.glob("checkpoint-*.pt")) + ], + workflow_slug=workflow_slug, + ) + ) + unique = {item["id"]: item for item in imported} + if not unique: + raise ArtifactStoreError( + "No supported Blacknode artifact manifest was found at that path." + ) + return list(unique.values()) + + def _load(self) -> dict[str, dict[str, Any]]: + if not self.path.exists(): + return {} + try: + payload = json.loads(self.path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + raise ArtifactStoreError( + f"Could not read local artifact index at {self.path}: {exc}" + ) from exc + artifacts = payload.get("artifacts", []) if isinstance(payload, dict) else [] + if not isinstance(artifacts, list): + raise ArtifactStoreError("Local artifact index has an invalid format.") + records: dict[str, dict[str, Any]] = {} + for item in artifacts: + if not isinstance(item, dict): + continue + artifact_id = _clean_text(item.get("id"), maximum=100) + if artifact_id: + records[artifact_id] = dict(item) + return records + + def _save(self, records: dict[str, dict[str, Any]]) -> None: + self.path.parent.mkdir(parents=True, exist_ok=True) + temporary = self.path.with_name(f".{self.path.name}.{os.getpid()}.tmp") + payload = { + "schema_version": 1, + "artifacts": list(records.values()), + } + try: + temporary.write_text( + json.dumps(payload, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + os.replace(temporary, self.path) + finally: + if temporary.exists(): + temporary.unlink() + + @staticmethod + def _hydrate(record: dict[str, Any]) -> dict[str, Any]: + locator = str(record.get("locator") or "") + exists = True + if locator and not locator.startswith("blacknode://"): + exists = Path(locator).exists() + return {**record, "exists": exists} diff --git a/editor-server/device_installer.py b/editor-server/device_installer.py new file mode 100644 index 0000000..4a6f0b0 --- /dev/null +++ b/editor-server/device_installer.py @@ -0,0 +1,1301 @@ +"""Fingerprint-pinned SSH installation for a Blacknode compute device.""" + +from __future__ import annotations + +import base64 +import hashlib +import json +import re +import secrets +import socket +import time +from dataclasses import dataclass +from typing import Any, Callable + + +class DeviceInstallError(RuntimeError): + """A remote device probe or installation could not be completed.""" + + +_INSTANCE_RE = re.compile(r"^[a-z0-9][a-z0-9-]{0,31}$") +_INSPECTION_MARKER = "__BLACKNODE_RUNTIME_INSPECTION__=" +_INSPECTION_SCRIPT = r"""python3 - <<'PY' +import glob +import json +import os +import platform +import re +import shutil +import subprocess +import urllib.request +from pathlib import Path + +home = Path.home() + +def command(args, timeout=8.0): + try: + return subprocess.run( + args, + capture_output=True, + text=True, + check=False, + timeout=timeout, + ) + except (OSError, subprocess.TimeoutExpired) as exc: + return subprocess.CompletedProcess( + args=args, + returncode=124, + stdout="", + stderr=str(exc), + ) + +def unit_text(name): + result = command(["systemctl", "cat", name]) + return result.returncode == 0, result.stdout + +def unit_port(text, fallback): + match = re.search(r"--port(?:=|\s+)[\"']?(\d+)", text) + return int(match.group(1)) if match else fallback + +def listening_ports(): + ports = set() + for proc_path in ("/proc/net/tcp", "/proc/net/tcp6"): + try: + lines = Path(proc_path).read_text(encoding="utf-8").splitlines()[1:] + except OSError: + continue + for line in lines: + fields = line.split() + if len(fields) < 4 or fields[3] != "0A": + continue + try: + ports.add(int(fields[1].rsplit(":", 1)[1], 16)) + except (IndexError, ValueError): + pass + result = command(["ss", "-H", "-ltn"]) + if result.returncode == 0: + for line in result.stdout.splitlines(): + fields = line.split() + if len(fields) < 4: + continue + match = re.search(r":(\d+)$", fields[3]) + if match: + ports.add(int(match.group(1))) + return ports + +def host_environment(): + release = {} + try: + for line in Path("/etc/os-release").read_text(encoding="utf-8").splitlines(): + if "=" in line: + key, value = line.split("=", 1) + release[key] = value.strip().strip("\"'") + except OSError: + pass + + nvidia_smi = shutil.which("nvidia-smi") or "" + gpu_names = [] + driver_version = "" + driver_cuda_version = "" + if nvidia_smi: + result = command([ + nvidia_smi, + "--query-gpu=name,driver_version", + "--format=csv,noheader,nounits", + ]) + if result.returncode == 0: + for line in result.stdout.splitlines(): + name, separator, driver = line.partition(",") + if name.strip(): + gpu_names.append(name.strip()) + if separator and not driver_version: + driver_version = driver.strip() + result = command([nvidia_smi]) + match = re.search(r"CUDA Version:\s*([0-9.]+)", result.stdout + result.stderr) + if match: + driver_cuda_version = match.group(1) + + nvcc = shutil.which("nvcc") or "" + cuda_toolkit_version = "" + if nvcc: + result = command([nvcc, "--version"]) + match = re.search(r"\brelease\s+([0-9.]+)", result.stdout + result.stderr) + if match: + cuda_toolkit_version = match.group(1) + if not cuda_toolkit_version: + version_json = Path("/usr/local/cuda/version.json") + try: + cuda_payload = json.loads(version_json.read_text(encoding="utf-8")) + cuda_toolkit_version = str( + (cuda_payload.get("cuda") or {}).get("version") + or cuda_payload.get("version") + or "" + ) + except (OSError, ValueError, AttributeError): + pass + + ros_distros = sorted({ + path.parent.name + for path in Path("/opt/ros").glob("*/setup.bash") + if path.is_file() + }) + ros2_path = shutil.which("ros2") or "" + selected_ros = str(os.environ.get("ROS_DISTRO") or "") + if not selected_ros and "jazzy" in ros_distros: + selected_ros = "jazzy" + if not selected_ros and ros_distros: + selected_ros = ros_distros[-1] + + docker_path = shutil.which("docker") or "" + docker_client_version = "" + docker_server_version = "" + docker_daemon_running = False + if docker_path: + result = command([docker_path, "--version"]) + match = re.search(r"\bversion\s+([^,\s]+)", result.stdout + result.stderr, re.IGNORECASE) + if match: + docker_client_version = match.group(1) + result = command([docker_path, "info", "--format", "{{.ServerVersion}}"]) + if result.returncode == 0: + docker_daemon_running = True + docker_server_version = result.stdout.strip() + docker_service_active = ( + command(["systemctl", "is-active", "--quiet", "docker.service"]).returncode == 0 + ) + docker_service_enabled = ( + command(["systemctl", "is-enabled", "--quiet", "docker.service"]).returncode == 0 + ) + + return { + "policy": "preserve", + "os": { + "name": release.get("PRETTY_NAME") or platform.system(), + "version": release.get("VERSION_ID") or platform.release(), + "architecture": platform.machine(), + }, + "python": { + "version": platform.python_version(), + "executable": shutil.which("python3") or "python3", + }, + "nvidia": { + "available": bool(nvidia_smi or nvcc or Path("/usr/local/cuda").exists()), + "gpus": gpu_names, + "driver_version": driver_version, + "driver_cuda_version": driver_cuda_version, + "cuda_toolkit_version": cuda_toolkit_version, + "nvidia_smi": bool(nvidia_smi), + "nvcc": bool(nvcc), + "preserved": True, + }, + "ros2": { + "available": bool(ros_distros or ros2_path), + "distributions": ros_distros, + "selected_distribution": selected_ros, + "ros2_on_path": bool(ros2_path), + "preserved": True, + }, + "docker": { + "available": bool(docker_path), + "client_version": docker_client_version, + "server_version": docker_server_version, + "daemon_running": docker_daemon_running or docker_service_active, + "service_enabled": docker_service_enabled, + "preserved": True, + }, + "runtime_setup_packages": ["git", "python3-pip", "python3-venv"], + } + +ids = {"default"} +side_root = home / "blacknode-runtimes" +if side_root.is_dir(): + ids.update( + path.name + for path in side_root.iterdir() + if path.is_dir() and re.fullmatch(r"[a-z0-9][a-z0-9-]{0,31}", path.name) + ) +for unit_path in glob.glob("/etc/systemd/system/blacknode-runtime-*.service"): + name = Path(unit_path).name + instance = name[len("blacknode-runtime-"):-len(".service")] + if re.fullmatch(r"[a-z0-9][a-z0-9-]{0,31}", instance): + ids.add(instance) + +instances = [] +for instance in sorted(ids, key=lambda value: (value != "default", value)): + if instance == "default": + repo = home / "blacknode-runtime" + unit = "blacknode-runtime.service" + fallback_port = 8766 + fallback_token = home / ".blacknode" / "runtime.auth.token" + else: + repo = side_root / instance + unit = f"blacknode-runtime-{instance}.service" + fallback_port = 0 + fallback_token = home / ".blacknode" / "runtimes" / f"{instance}.auth.token" + config_path = repo / ".blacknode-runtime" / "runtime.json" + config = {} + if config_path.is_file(): + try: + config = json.loads(config_path.read_text(encoding="utf-8")) + except (OSError, ValueError): + config = {} + token_path = Path(str(config.get("auth_token_file") or fallback_token)).expanduser() + service_installed, service_text = unit_text(unit) + if not repo.exists() and not service_installed and not config_path.exists() and not token_path.exists(): + continue + port = unit_port(service_text, fallback_port) + running = command(["systemctl", "is-active", "--quiet", unit]).returncode == 0 + token_available = token_path.is_file() + healthy = False + version = "" + device_id = str(config.get("device_id") or "") + error = "" + if token_available: + try: + token = token_path.read_text(encoding="utf-8").strip() + request = urllib.request.Request( + f"http://127.0.0.1:{port}/manifest", + headers={"Authorization": f"Bearer {token}"}, + ) + with urllib.request.urlopen(request, timeout=2.0) as response: + manifest = json.loads(response.read()) + healthy = ( + manifest.get("service") == "blacknode-runtime" + and manifest.get("protocol_version") == 1 + ) + version = str(manifest.get("runtime_version") or "") + device_id = str(manifest.get("device_id") or device_id) + if not healthy: + error = "The service responded with an incompatible manifest." + except Exception as exc: + error = str(exc)[:240] + elif running: + error = "The service is running, but its pairing token is unavailable." + else: + error = "The runtime is not running." + instances.append({ + "instance_id": instance, + "runtime_dir": str(repo), + "service_name": unit, + "port": port, + "repository": (repo / ".git").is_dir(), + "configured": config_path.is_file(), + "service_installed": service_installed, + "running": running, + "healthy": healthy, + "token_available": token_available, + "runtime_version": version, + "device_id": device_id, + "error": error, + "_token_file": str(token_path), + }) + +used = listening_ports() +suggested_port = next((port for port in range(8766, 8866) if port not in used), 0) +used_ids = {item["instance_id"] for item in instances} +counter = 2 +while f"instance-{counter}" in used_ids: + counter += 1 +print("__BLACKNODE_RUNTIME_INSPECTION__=" + json.dumps({ + "instances": instances, + "environment": host_environment(), + "used_ports": sorted(used), + "suggested_port": suggested_port, + "suggested_instance_id": f"instance-{counter}", +}, separators=(",", ":"))) +PY""" + + +def _load_paramiko(): + try: + import paramiko # type: ignore + except ImportError as exc: + raise DeviceInstallError( + "Automatic SSH setup requires Paramiko. Reinstall the editor-server " + "requirements, then restart Blacknode." + ) from exc + return paramiko + + +def _fingerprint(key: Any) -> str: + digest = hashlib.sha256(key.asbytes()).digest() + return "SHA256:" + base64.b64encode(digest).decode("ascii").rstrip("=") + + +def _clean_host(host: str, port: int) -> tuple[str, int]: + clean_host = str(host or "").strip() + if not clean_host: + raise DeviceInstallError("Device IP address or hostname is required.") + if any(character.isspace() for character in clean_host): + raise DeviceInstallError("Device address must not contain spaces.") + clean_port = int(port) + if clean_port < 1 or clean_port > 65535: + raise DeviceInstallError("SSH port must be between 1 and 65535.") + return clean_host, clean_port + + +def _clean_target(host: str, port: int, username: str, password: str) -> tuple[str, int, str, str]: + clean_host, clean_port = _clean_host(host, port) + clean_user = str(username or "").strip() + clean_password = str(password or "") + if not clean_user: + raise DeviceInstallError("SSH username is required.") + if not clean_password: + raise DeviceInstallError("SSH password is required.") + if "\n" in clean_password or "\r" in clean_password: + raise DeviceInstallError("SSH passwords containing line breaks are not supported.") + return clean_host, clean_port, clean_user, clean_password + + +def _sudo_input(password: str, *, attempts: int = 32) -> str: + """Supply nested non-interactive sudo calls without a PTY or password echo.""" + return (str(password) + "\n") * max(1, int(attempts)) + + +@dataclass +class _Connection: + client: Any + fingerprint: str + + def close(self) -> None: + self.client.close() + + +def _connect( + host: str, + port: int, + username: str, + password: str, + *, + expected_fingerprint: str | None = None, + timeout: float = 10.0, +) -> _Connection: + paramiko = _load_paramiko() + clean_host, clean_port, clean_user, clean_password = _clean_target( + host, port, username, password + ) + expected = str(expected_fingerprint or "").strip() + if not expected: + raise DeviceInstallError("The confirmed SSH host fingerprint is required.") + + class _PinnedHostKeyPolicy(paramiko.MissingHostKeyPolicy): + def missing_host_key(self, client, hostname, key): + del client, hostname + actual = _fingerprint(key) + if not secrets.compare_digest(expected, actual): + raise paramiko.SSHException( + "The device SSH host key changed. Stop and verify the device." + ) + + client = paramiko.SSHClient() + client.set_missing_host_key_policy(_PinnedHostKeyPolicy()) + try: + client.connect( + clean_host, + port=clean_port, + username=clean_user, + password=clean_password, + look_for_keys=False, + allow_agent=False, + timeout=timeout, + banner_timeout=timeout, + auth_timeout=timeout, + ) + transport = client.get_transport() + if transport is None or not transport.is_active(): + raise DeviceInstallError("SSH connection did not become active.") + fingerprint = _fingerprint(transport.get_remote_server_key()) + if not secrets.compare_digest(expected, fingerprint): + raise DeviceInstallError( + "The device SSH host key changed. Stop and verify the device before " + "trying again." + ) + return _Connection(client=client, fingerprint=fingerprint) + except DeviceInstallError: + client.close() + raise + except paramiko.AuthenticationException as exc: + client.close() + raise DeviceInstallError( + f"SSH login was rejected for user '{clean_user}'. Re-enter the SSH " + "username and password. No inspection or installation commands ran." + ) from exc + except (paramiko.SSHException, socket.timeout, OSError) as exc: + client.close() + raise DeviceInstallError(f"Could not connect over SSH: {exc}") from exc + + +def _run( + connection: _Connection, + command: str, + *, + stdin_text: str = "", + timeout: float = 30.0, + on_output: Callable[[str], None] | None = None, +) -> str: + try: + stdin, stdout, stderr = connection.client.exec_command( + command, + get_pty=False, + ) + if stdin_text: + stdin.write(stdin_text) + stdin.flush() + stdin.channel.shutdown_write() + channel = stdout.channel + deadline = time.monotonic() + timeout + output = bytearray() + pending_line = bytearray() + + def append_output(chunk: bytes) -> None: + output.extend(chunk) + if on_output is None: + return + pending_line.extend(chunk) + while b"\n" in pending_line: + raw_line, _, remainder = pending_line.partition(b"\n") + pending_line[:] = remainder + try: + on_output(raw_line.decode("utf-8", errors="replace").rstrip("\r")) + except Exception: + # UI progress reporting must never interrupt the remote command. + pass + + while True: + read_any = False + while channel.recv_ready(): + append_output(channel.recv(32768)) + read_any = True + while channel.recv_stderr_ready(): + append_output(channel.recv_stderr(32768)) + read_any = True + if len(output) > 512 * 1024: + del output[: len(output) - 512 * 1024] + if channel.exit_status_ready() and not channel.recv_ready() and not channel.recv_stderr_ready(): + break + if time.monotonic() >= deadline: + channel.close() + raise DeviceInstallError( + f"Remote command did not finish within {int(timeout)} seconds." + ) + if not read_any: + time.sleep(0.05) + exit_code = channel.recv_exit_status() + if on_output is not None and pending_line: + try: + on_output(pending_line.decode("utf-8", errors="replace").rstrip("\r")) + except Exception: + pass + output_text = bytes(output).decode("utf-8", errors="replace") + except Exception as exc: + if isinstance(exc, DeviceInstallError): + raise + raise DeviceInstallError(f"Remote command failed: {exc}") from exc + if exit_code != 0: + tail = "\n".join(output_text.strip().splitlines()[-12:]) + raise DeviceInstallError( + f"Remote command exited with code {exit_code}." + + (f"\n{tail}" if tail else "") + ) + return output_text + + +def probe_device( + *, + host: str, + port: int, +) -> dict[str, Any]: + paramiko = _load_paramiko() + clean_host, clean_port = _clean_host(host, port) + sock = None + transport = None + try: + sock = socket.create_connection((clean_host, clean_port), timeout=10.0) + transport = paramiko.Transport(sock) + transport.start_client(timeout=10.0) + key = transport.get_remote_server_key() + return { + "ok": True, + "host_fingerprint": _fingerprint(key), + "os": "", + "architecture": "", + "hostname": clean_host, + } + except (paramiko.SSHException, socket.timeout, OSError) as exc: + raise DeviceInstallError(f"Could not read the SSH host key: {exc}") from exc + finally: + if transport is not None: + transport.close() + elif sock is not None: + sock.close() + + +def _parse_inspection(output: str) -> dict[str, Any]: + payload_line = next( + ( + line[len(_INSPECTION_MARKER):] + for line in reversed(output.splitlines()) + if line.startswith(_INSPECTION_MARKER) + ), + "", + ) + if not payload_line: + raise DeviceInstallError("The device did not return a runtime inspection report.") + try: + payload = json.loads(payload_line) + except json.JSONDecodeError as exc: + raise DeviceInstallError("The device returned an invalid runtime inspection report.") from exc + if not isinstance(payload, dict) or not isinstance(payload.get("instances"), list): + raise DeviceInstallError("The device returned an incomplete runtime inspection report.") + return payload + + +def _inspect_connection(connection: _Connection) -> dict[str, Any]: + return _parse_inspection(_run(connection, _INSPECTION_SCRIPT, timeout=45.0)) + + +def _public_inspection(payload: dict[str, Any]) -> dict[str, Any]: + instances = [] + for item in payload.get("instances", []): + if not isinstance(item, dict): + continue + instances.append({ + key: value + for key, value in item.items() + if not str(key).startswith("_") + }) + return { + "ok": True, + "instances": instances, + "environment": ( + payload.get("environment") + if isinstance(payload.get("environment"), dict) + else {} + ), + "suggested_port": int(payload.get("suggested_port") or 0), + "suggested_instance_id": str(payload.get("suggested_instance_id") or "instance-2"), + } + + +def _clean_instance_id(value: str, *, allow_default: bool = True) -> str: + clean = str(value or "").strip().lower() + if allow_default and clean == "default": + return clean + if not _INSTANCE_RE.fullmatch(clean) or clean == "default": + raise DeviceInstallError( + "Runtime instance must contain lowercase letters, numbers, or hyphens." + ) + return clean + + +def _find_instance(payload: dict[str, Any], instance_id: str) -> dict[str, Any]: + return next( + ( + item + for item in payload.get("instances", []) + if isinstance(item, dict) and item.get("instance_id") == instance_id + ), + {}, + ) + + +def _read_remote_token(connection: _Connection, instance: dict[str, Any]) -> str: + token_path = str(instance.get("_token_file") or "") + if not token_path.startswith("/"): + raise DeviceInstallError("The existing runtime token path is invalid.") + try: + sftp = connection.client.open_sftp() + try: + with sftp.file(token_path, "r") as token_file: + token = token_file.read(4096) + finally: + sftp.close() + except Exception as exc: + raise DeviceInstallError("Could not read the existing runtime pairing token.") from exc + if isinstance(token, bytes): + token = token.decode("utf-8", errors="strict") + clean = str(token).strip() + if len(clean) < 24 or any(character.isspace() for character in clean): + raise DeviceInstallError("The existing runtime pairing token is invalid.") + return clean + + +def inspect_runtime( + *, + host: str, + port: int, + username: str, + password: str, + host_fingerprint: str, +) -> dict[str, Any]: + expected = str(host_fingerprint or "").strip() + if not expected: + raise DeviceInstallError( + "Check the SSH connection and confirm its host fingerprint first." + ) + connection = _connect( + host, + port, + username, + password, + expected_fingerprint=expected, + timeout=15.0, + ) + try: + report = _public_inspection(_inspect_connection(connection)) + report["host_fingerprint"] = connection.fingerprint + return report + finally: + connection.close() + + +def install_runtime( + *, + host: str, + port: int, + username: str, + password: str, + host_fingerprint: str, + action: str = "install", + instance_id: str = "", + progress: Callable[[dict[str, Any]], None] | None = None, +) -> dict[str, Any]: + def report(percent: int, message: str) -> None: + if progress is not None: + progress({ + "progress": max(0, min(100, int(percent))), + "message": str(message), + }) + + report(2, "Connecting securely") + expected = str(host_fingerprint or "").strip() + if not expected: + raise DeviceInstallError( + "Check the SSH connection and confirm its host fingerprint first." + ) + connection = _connect( + host, + port, + username, + password, + expected_fingerprint=expected, + timeout=15.0, + ) + clean_action = str(action or "").strip().lower() + if clean_action not in {"install", "reuse", "replace", "side_by_side"}: + connection.close() + raise DeviceInstallError("Choose install, reuse, replace, or side_by_side.") + started = time.monotonic() + token = "" + remote_token_path = "" + remote_script_path = "" + try: + report(6, "Inspecting existing runtimes") + inspection = _inspect_connection(connection) + instances = [ + item for item in inspection.get("instances", []) + if isinstance(item, dict) + ] + remove_old_port = False + if clean_action == "install": + if instances: + raise DeviceInstallError( + "An existing runtime was found. Inspect the device and choose " + "reuse, replace, or side-by-side installation." + ) + selected_instance = "default" + runtime_port = int(inspection.get("suggested_port") or 0) + elif clean_action in {"reuse", "replace"}: + selected_instance = _clean_instance_id(instance_id or "default") + existing = _find_instance(inspection, selected_instance) + if not existing: + raise DeviceInstallError( + f"Runtime instance '{selected_instance}' is no longer present. " + "Inspect the device again." + ) + runtime_port = int(existing.get("port") or 0) + if clean_action == "replace" and runtime_port < 1: + runtime_port = int(inspection.get("suggested_port") or 0) + if clean_action == "reuse": + if not existing.get("healthy") or not existing.get("token_available"): + raise DeviceInstallError( + "The selected runtime is not healthy enough to reuse. " + "Choose reinstall or a separate installation." + ) + token = _read_remote_token(connection, existing) + report(100, "Existing runtime paired") + return { + "ok": True, + "runtime_token": token, + "host_fingerprint": connection.fingerprint, + "elapsed_seconds": round(time.monotonic() - started, 1), + "action": clean_action, + "instance_id": selected_instance, + "runtime_port": runtime_port, + "service_name": str(existing.get("service_name") or ""), + "runtime_dir": str(existing.get("runtime_dir") or ""), + } + remove_old_port = bool(existing.get("service_installed")) + else: + selected_instance = _clean_instance_id( + instance_id or str(inspection.get("suggested_instance_id") or "instance-2"), + allow_default=False, + ) + if _find_instance(inspection, selected_instance): + raise DeviceInstallError( + f"Runtime instance '{selected_instance}' already exists. " + "Inspect the device again." + ) + runtime_port = int(inspection.get("suggested_port") or 0) + if runtime_port < 1: + raise DeviceInstallError( + "No available runtime port was found between 8766 and 8865." + ) + + token = secrets.token_urlsafe(32) + remote_token_path = f"/tmp/blacknode-runtime-token-{secrets.token_hex(8)}" + remote_script_path = f"/tmp/blacknode-runtime-install-{secrets.token_hex(8)}.sh" + report(10, "Preparing runtime installation") + script = """#!/usr/bin/env bash +set -euo pipefail +progress() { + echo "__BLACKNODE_INSTALL_PROGRESS__=$1|$2" +} +sudo() { + command sudo -S -p '' "$@" +} +export -f sudo +action="$1" +instance="$2" +runtime_port="$3" +token_source="$4" +remove_old_port="$5" +if [[ "$instance" == "default" ]]; then + runtime_dir="$HOME/blacknode-runtime" + token_file="$HOME/.blacknode/runtime.auth.token" + service_name="blacknode-runtime.service" + service_instance="" +else + [[ "$instance" =~ ^[a-z0-9][a-z0-9-]{0,31}$ ]] || { + echo "Invalid Blacknode runtime instance." + exit 2 + } + runtime_dir="$HOME/blacknode-runtimes/$instance" + token_file="$HOME/.blacknode/runtimes/$instance.auth.token" + service_name="blacknode-runtime-$instance.service" + service_instance="$instance" +fi +active_sibling_services=() +if [[ "$action" == "side_by_side" ]] && command -v systemctl >/dev/null 2>&1; then + mapfile -t active_sibling_services < <( + systemctl list-units --type=service --state=active --no-legend \ + 'blacknode-runtime*.service' 'blacknode-hardware*.service' 2>/dev/null \ + | awk '{print $1}' + ) +fi +install_complete=false +cleanup_install=false +port_guard="" +port_file="" +cleanup_failed_install() { + exit_code=$? + if [[ -n "$port_guard" ]]; then + kill "$port_guard" >/dev/null 2>&1 || true + wait "$port_guard" >/dev/null 2>&1 || true + fi + [[ -z "$port_file" ]] || rm -f -- "$port_file" + if [[ "$exit_code" -ne 0 && "$install_complete" != true && "$cleanup_install" == true ]]; then + progress 0 "Cleaning the incomplete runtime" + sudo systemctl stop "$service_name" >/dev/null 2>&1 || true + sudo systemctl disable "$service_name" >/dev/null 2>&1 || true + sudo rm -f -- "/etc/systemd/system/$service_name" >/dev/null 2>&1 || true + sudo systemctl daemon-reload >/dev/null 2>&1 || true + rm -rf -- "$runtime_dir" + rm -f -- "$token_file" + fi + exit "$exit_code" +} +trap cleanup_failed_install EXIT +case "$runtime_dir" in + "$HOME/blacknode-runtime"|"$HOME/blacknode-runtimes/"*) ;; + *) echo "Unsafe runtime directory."; exit 2 ;; +esac +token_dir="$(dirname -- "$token_file")" +progress 14 "Preparing the selected runtime" +if [[ "$action" == "replace" ]]; then + cleanup_install=true + sudo systemctl stop "$service_name" >/dev/null 2>&1 || true + sudo systemctl disable "$service_name" >/dev/null 2>&1 || true + sudo rm -f -- "/etc/systemd/system/$service_name" + sudo systemctl daemon-reload + if [[ "$remove_old_port" == "1" ]] \ + && command -v ufw >/dev/null 2>&1 \ + && sudo ufw status 2>/dev/null | grep -qi '^Status: active'; then + sudo ufw --force delete allow "$runtime_port/tcp" >/dev/null 2>&1 || true + fi + rm -rf -- "$runtime_dir" + rm -f -- "$token_file" +elif [[ -e "$runtime_dir" ]]; then + echo "The selected runtime directory already exists. Inspect the device again." + exit 3 +else + cleanup_install=true +fi +progress 20 "Checking the runtime port" +port_file="$(mktemp)" +python3 - "$runtime_port" "$port_file" <<'PY' & +import socket +import sys +import time +from pathlib import Path + +port = int(sys.argv[1]) +port_file = Path(sys.argv[2]) +candidates = [port] + [ + candidate + for candidate in range(8766, 8866) + if candidate != port +] +for candidate in candidates: + sock = socket.socket() + try: + sock.bind(("0.0.0.0", candidate)) + except OSError: + sock.close() + continue + port_file.write_text(str(candidate), encoding="utf-8") + while True: + time.sleep(60) +raise SystemExit("No available runtime port was found between 8766 and 8865.") +PY +port_guard=$! +for _attempt in {1..100}; do + [[ -s "$port_file" ]] && break + if ! kill -0 "$port_guard" >/dev/null 2>&1; then + wait "$port_guard" + fi + sleep 0.05 +done +if [[ ! -s "$port_file" ]]; then + echo "Could not reserve an available runtime port." + exit 5 +fi +runtime_port="$(cat "$port_file")" +echo "__BLACKNODE_RUNTIME_PORT__=$runtime_port" +progress 28 "Updating package indexes" +sudo apt-get update +progress 36 "Checking required system tools" +sudo apt-get install -y git +progress 42 "Creating isolated runtime files" +mkdir -p "$token_dir" +install -m 0600 "$token_source" "$token_file" +mkdir -p "$(dirname -- "$runtime_dir")" +progress 48 "Downloading Blacknode Runtime" +git clone https://github.com/temiroff/blacknode-runtime.git "$runtime_dir" +if [[ -n "$service_instance" ]] \ + && ! grep -q 'BLACKNODE_RUNTIME_INSTANCE' "$runtime_dir/install-service.sh"; then + progress 54 "Enabling independent runtime support" + python3 - "$runtime_dir" <<'PY' +import sys +from pathlib import Path + +root = Path(sys.argv[1]) +replacement = '''instance="${BLACKNODE_RUNTIME_INSTANCE:-}" +if [[ -n "$instance" && ! "$instance" =~ ^[a-z0-9][a-z0-9-]{0,31}$ ]]; then + echo "BLACKNODE_RUNTIME_INSTANCE must contain lowercase letters, numbers, or hyphens." + exit 2 +fi +unit_name="blacknode-runtime${instance:+-$instance}.service"''' +for name in ("install-service.sh", "service.sh"): + path = root / name + text = path.read_text(encoding="utf-8") + old = 'unit_name="blacknode-runtime.service"' + if old not in text: + raise SystemExit(f"{name} has an unsupported service layout") + path.write_text(text.replace(old, replacement, 1), encoding="utf-8") +PY + if ! grep -q 'BLACKNODE_RUNTIME_INSTANCE' "$runtime_dir/install-service.sh" \ + || ! grep -q 'BLACKNODE_RUNTIME_INSTANCE' "$runtime_dir/service.sh"; then + echo "Could not enable independent runtime support in the downloaded release." + rm -rf -- "$runtime_dir" + rm -f -- "$token_file" + exit 4 + fi +fi +cd "$runtime_dir" +progress 58 "Creating the Python environment" +BLACKNODE_AUTH_TOKEN_FILE="$token_file" \ +BLACKNODE_RUNTIME_PORT="$runtime_port" \ +BLACKNODE_RUNTIME_INSTANCE="$service_instance" \ +./setup_ubuntu.sh +if [[ -n "$service_instance" ]]; then + progress 78 "Configuring the independent instance" + BLACKNODE_RUNTIME_INSTANCE="$service_instance" ./configure.sh \ + --device-id "$(hostname)-$service_instance" +fi +progress 88 "Installing the runtime service" +kill "$port_guard" >/dev/null 2>&1 || true +wait "$port_guard" >/dev/null 2>&1 || true +port_guard="" +rm -f -- "$port_file" +port_file="" +BLACKNODE_RUNTIME_PORT="$runtime_port" \ +BLACKNODE_RUNTIME_INSTANCE="$service_instance" \ +./install-service.sh +if [[ "${#active_sibling_services[@]}" -gt 0 ]]; then + progress 92 "Verifying existing runtimes and robot services" + for sibling_service in "${active_sibling_services[@]}"; do + if [[ "$sibling_service" != "$service_name" ]] \ + && ! sudo systemctl is-active --quiet "$sibling_service"; then + sudo systemctl start "$sibling_service" + fi + done +fi +if command -v ufw >/dev/null 2>&1 \ + && sudo ufw status 2>/dev/null | grep -qi '^Status: active' \ + && sudo systemctl is-active --quiet blacknode-runtime.service; then + default_port="$( + sudo systemctl cat blacknode-runtime.service 2>/dev/null \ + | sed -nE 's/.*--port[= ]"?([0-9]+).*/\\1/p' \ + | tail -n 1 + )" + if [[ "$default_port" =~ ^[0-9]+$ ]]; then + sudo ufw allow "$default_port/tcp" comment "Blacknode runtime" >/dev/null + fi +fi +install_complete=true +progress 96 "Verifying the runtime service" +""" + sftp = connection.client.open_sftp() + try: + with sftp.file(remote_token_path, "w") as remote_token: + remote_token.write(token + "\n") + sftp.chmod(remote_token_path, 0o600) + with sftp.file(remote_script_path, "w") as remote_script: + remote_script.write(script) + sftp.chmod(remote_script_path, 0o700) + finally: + sftp.close() + + def remote_output(line: str) -> None: + nonlocal runtime_port + port_match = re.match( + r"^__BLACKNODE_RUNTIME_PORT__=(\d{1,5})$", + line.strip(), + ) + if port_match: + runtime_port = int(port_match.group(1)) + report(22, f"Reserved runtime port {runtime_port}") + return + match = re.match( + r"^__BLACKNODE_INSTALL_PROGRESS__=(\d{1,3})\|(.*)$", + line.strip(), + ) + if match: + report(int(match.group(1)), match.group(2).strip()) + + _run( + connection, + ( + f"bash {remote_script_path} " + f"{clean_action} {selected_instance} {runtime_port} {remote_token_path} " + f"{1 if remove_old_port else 0}" + ), + stdin_text=_sudo_input(password), + timeout=900.0, + on_output=remote_output, + ) + service_name = ( + "blacknode-runtime.service" + if selected_instance == "default" + else f"blacknode-runtime-{selected_instance}.service" + ) + report(96, "Runtime service started") + return { + "ok": True, + "runtime_token": token, + "host_fingerprint": connection.fingerprint, + "elapsed_seconds": round(time.monotonic() - started, 1), + "action": clean_action, + "instance_id": selected_instance, + "runtime_port": runtime_port, + "service_name": service_name, + "runtime_dir": ( + "~/blacknode-runtime" + if selected_instance == "default" + else f"~/blacknode-runtimes/{selected_instance}" + ), + } + finally: + if remote_script_path or remote_token_path: + try: + _run( + connection, + f"rm -f -- {remote_script_path} {remote_token_path}", + timeout=10.0, + ) + except DeviceInstallError: + pass + connection.close() + + +def control_runtime( + *, + host: str, + port: int, + username: str, + password: str, + host_fingerprint: str, + instance_id: str, + action: str, +) -> dict[str, Any]: + selected_instance = _clean_instance_id(instance_id or "default") + clean_action = str(action or "").strip().lower() + if clean_action not in {"pause", "resume"}: + raise DeviceInstallError("Runtime action must be pause or resume.") + service_name = ( + "blacknode-runtime.service" + if selected_instance == "default" + else f"blacknode-runtime-{selected_instance}.service" + ) + systemd_action = "stop" if clean_action == "pause" else "start" + connection = _connect( + host, + port, + username, + password, + expected_fingerprint=host_fingerprint, + timeout=15.0, + ) + try: + output = _run( + connection, + ( + "sudo -S -p '' -v" + f" && sudo systemctl {systemd_action} {service_name}" + f' && state="$(sudo systemctl is-active {service_name} || true)"' + ' && printf "%s\\n" "$state"' + f' && [ "$state" = "{"inactive" if clean_action == "pause" else "active"}" ]' + ), + stdin_text=password + "\n", + timeout=60.0, + ) + state = output.strip().splitlines()[-1] if output.strip() else "" + if clean_action == "pause" and state != "inactive": + raise DeviceInstallError( + f"{service_name} did not enter the inactive state." + ) + if clean_action == "resume" and state != "active": + raise DeviceInstallError( + f"{service_name} did not enter the active state." + ) + return { + "ok": True, + "action": clean_action, + "instance_id": selected_instance, + "service_name": service_name, + "state": state, + } + finally: + connection.close() + + +def restart_hardware_service( + *, + host: str, + port: int, + username: str, + password: str, + host_fingerprint: str, + hardware_port: int, +) -> dict[str, Any]: + """Restart exactly one Blacknode hardware unit resolved by its HTTP port.""" + selected_port = int(hardware_port) + if selected_port < 1 or selected_port > 65535: + raise DeviceInstallError("The robot hardware port is invalid.") + connection = _connect( + host, + port, + username, + password, + expected_fingerprint=host_fingerprint, + timeout=15.0, + ) + remote_script_path = f"/tmp/blacknode-hardware-restart-{secrets.token_hex(8)}.sh" + script = r"""#!/usr/bin/env bash +set -euo pipefail +hardware_port="$1" +[[ "$hardware_port" =~ ^[0-9]{1,5}$ ]] || { + echo "Invalid robot hardware port." + exit 2 +} +mapfile -t candidate_units < <( + systemctl list-unit-files 'blacknode-hardware*.service' --no-legend 2>/dev/null \ + | awk '{print $1}' +) +matches=() +for unit in "${candidate_units[@]}"; do + [[ "$unit" =~ ^blacknode-hardware([-.][A-Za-z0-9_.@-]+)?\.service$ ]] || continue + exec_start="$(systemctl show "$unit" --property=ExecStart --value 2>/dev/null || true)" + normalized="${exec_start//\"/}" + if [[ "$normalized" =~ --port(=|[[:space:]])${hardware_port}([^0-9]|$) ]]; then + matches+=("$unit") + fi +done +if [[ "${#matches[@]}" -eq 0 ]]; then + echo "No Blacknode hardware service owns port $hardware_port." + exit 3 +fi +if [[ "${#matches[@]}" -ne 1 ]]; then + echo "Multiple Blacknode hardware services claim port $hardware_port." + exit 4 +fi +service_name="${matches[0]}" +sudo -S -p '' systemctl restart "$service_name" +state="" +for _attempt in {1..40}; do + state="$(systemctl is-active "$service_name" 2>/dev/null || true)" + [[ "$state" == "active" ]] && break + sleep 0.25 +done +echo "__BLACKNODE_HARDWARE_SERVICE__=$service_name" +echo "__BLACKNODE_HARDWARE_STATE__=$state" +[[ "$state" == "active" ]] +""" + try: + sftp = connection.client.open_sftp() + try: + with sftp.file(remote_script_path, "w") as remote_script: + remote_script.write(script) + sftp.chmod(remote_script_path, 0o700) + finally: + sftp.close() + output = _run( + connection, + f"bash {remote_script_path} {selected_port}", + stdin_text=_sudo_input(password), + timeout=60.0, + ) + service_match = re.search( + r"^__BLACKNODE_HARDWARE_SERVICE__=([A-Za-z0-9_.@-]+\.service)$", + output, + re.MULTILINE, + ) + state_match = re.search( + r"^__BLACKNODE_HARDWARE_STATE__=([a-z-]+)$", + output, + re.MULTILINE, + ) + if not service_match or not state_match: + raise DeviceInstallError( + "The device did not confirm which robot service was restarted." + ) + state = state_match.group(1) + if state != "active": + raise DeviceInstallError( + f"{service_match.group(1)} did not return to the active state." + ) + return { + "ok": True, + "hardware_port": selected_port, + "service_name": service_match.group(1), + "state": state, + } + finally: + try: + _run(connection, f"rm -f -- {remote_script_path}", timeout=10.0) + except DeviceInstallError: + pass + connection.close() + + +def uninstall_runtime( + *, + host: str, + port: int, + username: str, + password: str, + host_fingerprint: str, + instance_id: str, + runtime_port: int, +) -> dict[str, Any]: + selected_instance = _clean_instance_id(instance_id or "default") + selected_port = int(runtime_port) + if selected_port < 1 or selected_port > 65535: + raise DeviceInstallError("The managed runtime port is invalid.") + connection = _connect( + host, + port, + username, + password, + expected_fingerprint=host_fingerprint, + timeout=15.0, + ) + remote_script_path = f"/tmp/blacknode-runtime-uninstall-{secrets.token_hex(8)}.sh" + script = """#!/usr/bin/env bash +set -euo pipefail +instance="$1" +runtime_port="$2" +if [[ "$instance" == "default" ]]; then + runtime_dir="$HOME/blacknode-runtime" + token_file="$HOME/.blacknode/runtime.auth.token" + service_name="blacknode-runtime.service" +else + [[ "$instance" =~ ^[a-z0-9][a-z0-9-]{0,31}$ ]] || exit 2 + runtime_dir="$HOME/blacknode-runtimes/$instance" + token_file="$HOME/.blacknode/runtimes/$instance.auth.token" + service_name="blacknode-runtime-$instance.service" +fi +case "$runtime_dir" in + "$HOME/blacknode-runtime"|"$HOME/blacknode-runtimes/"*) ;; + *) echo "Unsafe runtime directory."; exit 2 ;; +esac +sudo systemctl stop "$service_name" >/dev/null 2>&1 || true +sudo systemctl disable "$service_name" >/dev/null 2>&1 || true +sudo rm -f -- "/etc/systemd/system/$service_name" +sudo systemctl daemon-reload +if command -v ufw >/dev/null 2>&1 \ + && sudo ufw status 2>/dev/null | grep -qi '^Status: active'; then + sudo ufw --force delete allow "$runtime_port/tcp" >/dev/null 2>&1 || true +fi +rm -rf -- "$runtime_dir" +rm -f -- "$token_file" +""" + try: + inspection = _inspect_connection(connection) + existing = _find_instance(inspection, selected_instance) + if existing: + discovered_port = int(existing.get("port") or 0) + if discovered_port and discovered_port != selected_port: + raise DeviceInstallError( + "The runtime port changed since this device was installed. " + "Inspect and pair it again before uninstalling." + ) + sftp = connection.client.open_sftp() + try: + with sftp.file(remote_script_path, "w") as remote_script: + remote_script.write(script) + sftp.chmod(remote_script_path, 0o700) + finally: + sftp.close() + _run( + connection, + ( + f"sudo -S -p '' -v && bash {remote_script_path} " + f"{selected_instance} {selected_port}" + ), + stdin_text=password + "\n", + timeout=120.0, + ) + return { + "ok": True, + "host_fingerprint": connection.fingerprint, + "instance_id": selected_instance, + "runtime_port": selected_port, + "already_absent": not bool(existing), + } + finally: + try: + _run(connection, f"rm -f -- {remote_script_path}", timeout=10.0) + except DeviceInstallError: + pass + connection.close() diff --git a/editor-server/device_registry.py b/editor-server/device_registry.py index 49d00aa..e28ed48 100644 --- a/editor-server/device_registry.py +++ b/editor-server/device_registry.py @@ -65,6 +65,13 @@ def _slug(value: str) -> str: return _ID_RE.sub("-", value.strip().lower()).strip("-")[:48] or "device" +def _host_id(runtime_url: str) -> str: + parsed = urllib.parse.urlsplit(normalize_base_url(runtime_url)) + label = _slug(str(parsed.hostname or "computer")) + digest = hashlib.sha256(runtime_url.encode("utf-8")).hexdigest()[:8] + return f"{label}-{digest}" + + class HardwareDeviceClient: """Talk to one hardware service while keeping its bearer token server-side.""" @@ -285,7 +292,7 @@ def __init__(self, path: Path) -> None: def list(self) -> list[dict[str, Any]]: with self._lock: - records = self._load() + _hosts, records = self._load_payload() return [ self._public(record) for record in sorted( @@ -296,25 +303,244 @@ def list(self) -> list[dict[str, Any]]: def get_public(self, device_id: str) -> dict[str, Any] | None: with self._lock: - record = self._load().get(device_id) + _hosts, records = self._load_payload() + record = records.get(device_id) return self._public(record) if record is not None else None def client(self, device_id: str) -> HardwareDeviceClient: with self._lock: - record = self._load().get(device_id) + _hosts, records = self._load_payload() + record = records.get(device_id) if record is None: raise KeyError(device_id) return HardwareDeviceClient(record["base_url"], record["token"]) def runtime_client(self, device_id: str) -> RuntimeDeviceClient: with self._lock: - record = self._load().get(device_id) + hosts, records = self._load_payload() + record = records.get(device_id) if record is None: raise KeyError(device_id) - runtime_url = record.get("runtime_url") or default_runtime_url(record["base_url"]) - runtime_token = record.get("runtime_token") or record["token"] + host = hosts.get(str(record.get("host_id") or "")) + runtime_url = ( + (host or {}).get("runtime_url") + or record.get("runtime_url") + or default_runtime_url(record["base_url"]) + ) + runtime_token = ( + (host or {}).get("runtime_token") + or record.get("runtime_token") + or record["token"] + ) return RuntimeDeviceClient(runtime_url, runtime_token) + def list_hosts(self) -> list[dict[str, Any]]: + with self._lock: + hosts, records = self._load_payload() + hosts, changed = self._materialize_hosts(hosts, records) + if changed: + self._save_payload(hosts, records) + robots_by_host: dict[str, list[dict[str, Any]]] = {} + for record in records.values(): + host_id = str( + record.get("host_id") + or _host_id( + str( + record.get("runtime_url") + or default_runtime_url(record["base_url"]) + ) + ) + ) + robots_by_host.setdefault(host_id, []).append(self._public(record)) + result = [] + for host in sorted( + hosts.values(), + key=lambda item: (str(item.get("name", "")).lower(), item["id"]), + ): + public = self._public_host(host) + public["robots"] = sorted( + robots_by_host.get(host["id"], []), + key=lambda item: (str(item.get("name", "")).lower(), item["id"]), + ) + result.append(public) + return result + + def get_host_public(self, host_id: str) -> dict[str, Any] | None: + with self._lock: + hosts, records = self._load_payload() + hosts, changed = self._materialize_hosts(hosts, records) + if changed: + self._save_payload(hosts, records) + host = hosts.get(host_id) + if host is None: + return None + public = self._public_host(host) + public["robots"] = [ + self._public(record) + for record in records.values() + if str(record.get("host_id") or "") == host_id + ] + return public + + def host_client(self, host_id: str) -> RuntimeDeviceClient: + with self._lock: + hosts, records = self._load_payload() + hosts, changed = self._materialize_hosts(hosts, records) + if changed: + self._save_payload(hosts, records) + host = hosts.get(host_id) + if host is None: + raise KeyError(host_id) + return RuntimeDeviceClient(host["runtime_url"], host["runtime_token"]) + + def pair_host( + self, + *, + name: str, + runtime_url: str, + runtime_token: str, + manifest: dict[str, Any], + managed_runtime: dict[str, Any] | None = None, + ) -> dict[str, Any]: + clean_url = normalize_base_url(runtime_url) + clean_token = str(runtime_token or "").strip() + if not clean_token: + raise DeviceRegistryError("Runtime pairing token is required.") + if ( + manifest.get("service") != "blacknode-runtime" + or manifest.get("protocol_version") != 1 + ): + raise DeviceRegistryError( + "The URL responded, but it is not a compatible Blacknode runtime." + ) + remote_id = str(manifest.get("device_id") or "").strip() + with self._lock: + hosts, records = self._load_payload() + hosts, _changed = self._materialize_hosts(hosts, records) + existing = next( + (item for item in hosts.values() if item.get("runtime_url") == clean_url), + None, + ) + now = _iso_now() + host_id = existing["id"] if existing else _host_id(clean_url) + created_at = existing.get("created_at") if existing else now + management = ( + { + key: value + for key, value in managed_runtime.items() + if key in { + "ssh_host", + "ssh_port", + "ssh_username", + "host_fingerprint", + "instance_id", + "runtime_port", + "service_name", + "runtime_dir", + } + } + if managed_runtime + else dict((existing or {}).get("managed_runtime") or {}) + ) + host = { + "id": host_id, + "name": str(name or "").strip() or remote_id or str( + urllib.parse.urlsplit(clean_url).hostname or "Computer" + ), + "runtime_url": clean_url, + "runtime_token": clean_token, + "runtime_token_fingerprint": token_fingerprint(clean_token), + "remote_device_id": remote_id, + "paused": False, + "created_at": created_at or now, + "updated_at": now, + } + if management: + host["managed_runtime"] = management + hosts[host_id] = host + for record in records.values(): + record_runtime_url = str( + record.get("runtime_url") + or default_runtime_url(record["base_url"]) + ) + if record_runtime_url == clean_url: + record["host_id"] = host_id + record["runtime_url"] = clean_url + record["runtime_token"] = clean_token + record["runtime_token_fingerprint"] = token_fingerprint(clean_token) + record["runtime_token_explicit"] = True + record["updated_at"] = now + self._save_payload(hosts, records) + public = self._public_host(host) + public["robots"] = [ + self._public(record) + for record in records.values() + if record.get("host_id") == host_id + ] + return public + + def set_host_paused(self, host_id: str, paused: bool) -> dict[str, Any]: + with self._lock: + hosts, records = self._load_payload() + hosts, _changed = self._materialize_hosts(hosts, records) + host = hosts.get(host_id) + if host is None: + raise KeyError(host_id) + host["paused"] = bool(paused) + host["updated_at"] = _iso_now() + self._save_payload(hosts, records) + public = self._public_host(host) + public["robots"] = [ + self._public(record) + for record in records.values() + if str(record.get("host_id") or "") == host_id + ] + return public + + def rename_host(self, host_id: str, name: str) -> dict[str, Any]: + clean_name = str(name or "").strip() + if not clean_name: + raise ValueError("Device name is required.") + with self._lock: + hosts, records = self._load_payload() + hosts, _changed = self._materialize_hosts(hosts, records) + host = hosts.get(host_id) + if host is None: + raise KeyError(host_id) + host["name"] = clean_name + host["updated_at"] = _iso_now() + self._save_payload(hosts, records) + return self._public_host(host) + + def delete_host(self, host_id: str, *, cascade: bool = False) -> bool: + with self._lock: + hosts, records = self._load_payload() + hosts, _changed = self._materialize_hosts(hosts, records) + if host_id not in hosts: + return False + attached = [ + record + for record in records.values() + if str(record.get("host_id") or "") == host_id + ] + if attached and not cascade: + raise DeviceRegistryError( + "Remove this device's robots before removing the device." + ) + if cascade: + attached_ids = { + str(record.get("id") or "") + for record in attached + } + records = { + record_id: record + for record_id, record in records.items() + if record_id not in attached_ids + } + del hosts[host_id] + self._save_payload(hosts, records) + return True + def pair( self, *, @@ -322,6 +548,8 @@ def pair( base_url: str, token: str, runtime_token: str | None = None, + runtime_url: str | None = None, + host_id: str | None = None, status: dict[str, Any], ) -> dict[str, Any]: clean_name = str(name or "").strip() @@ -333,7 +561,11 @@ def pair( if not remote_device_id: raise DeviceRegistryError("The device status has no device_id.") with self._lock: - records = self._load() + hosts, records = self._load_payload() + hosts, _changed = self._materialize_hosts(hosts, records) + selected_host = hosts.get(str(host_id or "")) + if host_id and selected_host is None: + raise DeviceRegistryError("Compute device was not found.") existing = next( (item for item in records.values() if item.get("base_url") == clean_url), None, @@ -350,13 +582,49 @@ def pair( device_id = f"{base_id}-{suffix}" suffix += 1 created_at = now - runtime_url = ( - existing.get("runtime_url") - if existing and existing.get("runtime_url") - else default_runtime_url(clean_url) + clean_runtime_url = normalize_base_url( + str( + (selected_host or {}).get("runtime_url") + or runtime_url + or ( + existing.get("runtime_url") + if existing and existing.get("runtime_url") + else default_runtime_url(clean_url) + ) + ) + ) + resolved_host_id = str( + (selected_host or {}).get("id") + or host_id + or ( + existing.get("host_id") + if existing and existing.get("host_id") + else _host_id(clean_runtime_url) + ) + ) + if resolved_host_id not in hosts: + now_for_host = _iso_now() + hosts[resolved_host_id] = { + "id": resolved_host_id, + "name": str( + urllib.parse.urlsplit(clean_runtime_url).hostname or "Computer" + ), + "runtime_url": clean_runtime_url, + "runtime_token": "", + "runtime_token_fingerprint": "", + "remote_device_id": "", + "created_at": now_for_host, + "updated_at": now_for_host, + } + clean_runtime_token = str( + (selected_host or {}).get("runtime_token") + or runtime_token + or "" + ).strip() + runtime_token_explicit = bool( + (selected_host or {}).get("runtime_token") + or clean_runtime_token ) - clean_runtime_token = str(runtime_token or "").strip() - runtime_token_explicit = bool(clean_runtime_token) if not clean_runtime_token and existing and existing.get("runtime_token_explicit"): clean_runtime_token = ( str(existing.get("runtime_token") or "").strip() @@ -370,7 +638,7 @@ def pair( if ( item.get("runtime_url") or default_runtime_url(item["base_url"]) - ) == runtime_url + ) == clean_runtime_url and item.get("runtime_token_explicit") and str(item.get("runtime_token") or "").strip() ), @@ -380,17 +648,24 @@ def pair( clean_runtime_token = str(runtime_peer["runtime_token"]).strip() runtime_token_explicit = True clean_runtime_token = clean_runtime_token or clean_token + host = hosts[resolved_host_id] + if not str(host.get("runtime_token") or "").strip() or runtime_token_explicit: + host["runtime_token"] = clean_runtime_token + host["runtime_token_fingerprint"] = token_fingerprint(clean_runtime_token) + host["updated_at"] = now record = { "id": device_id, "name": clean_name or remote_device_id, "base_url": clean_url, - "runtime_url": runtime_url, + "host_id": resolved_host_id, + "runtime_url": clean_runtime_url, "token": clean_token, "token_fingerprint": token_fingerprint(clean_token), "runtime_token": clean_runtime_token, "runtime_token_fingerprint": token_fingerprint(clean_runtime_token), "runtime_token_explicit": runtime_token_explicit, "remote_device_id": remote_device_id, + "paused": False, "created_at": created_at, "updated_at": now, } @@ -404,22 +679,23 @@ def pair( or default_runtime_url(other["base_url"]) ) == record["runtime_url"] ): + other["host_id"] = resolved_host_id other["runtime_token"] = clean_runtime_token other["runtime_token_fingerprint"] = token_fingerprint( clean_runtime_token ) other["runtime_token_explicit"] = True other["updated_at"] = now - self._save(records) + self._save_payload(hosts, records) return self._public(record) def delete(self, device_id: str) -> bool: with self._lock: - records = self._load() + hosts, records = self._load_payload() if device_id not in records: return False del records[device_id] - self._save(records) + self._save_payload(hosts, records) return True def rename(self, device_id: str, name: str) -> dict[str, Any]: @@ -427,19 +703,32 @@ def rename(self, device_id: str, name: str) -> dict[str, Any]: if not clean_name: raise ValueError("Device name is required.") with self._lock: - records = self._load() + hosts, records = self._load_payload() record = records.get(device_id) if record is None: raise KeyError(device_id) record["name"] = clean_name record["updated_at"] = _iso_now() records[device_id] = record - self._save(records) + self._save_payload(hosts, records) return self._public(record) - def _load(self) -> dict[str, dict[str, Any]]: + def set_device_paused(self, device_id: str, paused: bool) -> dict[str, Any]: + with self._lock: + hosts, records = self._load_payload() + record = records.get(device_id) + if record is None: + raise KeyError(device_id) + record["paused"] = bool(paused) + record["updated_at"] = _iso_now() + self._save_payload(hosts, records) + return self._public(record) + + def _load_payload( + self, + ) -> tuple[dict[str, dict[str, Any]], dict[str, dict[str, Any]]]: if not self.path.exists(): - return {} + return {}, {} try: payload = json.loads(self.path.read_text(encoding="utf-8")) except (OSError, json.JSONDecodeError) as exc: @@ -447,18 +736,31 @@ def _load(self) -> dict[str, dict[str, Any]]: f"Could not read local device registry at {self.path}: {exc}" ) from exc devices = payload.get("devices", {}) if isinstance(payload, dict) else {} - if not isinstance(devices, dict): + hosts = payload.get("hosts", {}) if isinstance(payload, dict) else {} + if not isinstance(devices, dict) or not isinstance(hosts, dict): raise DeviceRegistryError("Local device registry has an invalid format.") - return { + return ({ + str(host_id): dict(record) + for host_id, record in hosts.items() + if isinstance(record, dict) + }, { str(device_id): dict(record) for device_id, record in devices.items() if isinstance(record, dict) - } + }) - def _save(self, records: dict[str, dict[str, Any]]) -> None: + def _load(self) -> dict[str, dict[str, Any]]: + _hosts, devices = self._load_payload() + return devices + + def _save_payload( + self, + hosts: dict[str, dict[str, Any]], + records: dict[str, dict[str, Any]], + ) -> None: self.path.parent.mkdir(parents=True, exist_ok=True) temporary = self.path.with_name(f".{self.path.name}.{os.getpid()}.tmp") - payload = {"schema_version": 1, "devices": records} + payload = {"schema_version": 2, "hosts": hosts, "devices": records} try: temporary.write_text( json.dumps(payload, indent=2, sort_keys=True) + "\n", @@ -477,6 +779,54 @@ def _save(self, records: dict[str, dict[str, Any]]) -> None: if temporary.exists(): temporary.unlink() + def _save(self, records: dict[str, dict[str, Any]]) -> None: + hosts, _existing = self._load_payload() + self._save_payload(hosts, records) + + @staticmethod + def _materialize_hosts( + hosts: dict[str, dict[str, Any]], + records: dict[str, dict[str, Any]], + ) -> tuple[dict[str, dict[str, Any]], bool]: + changed = False + for record in records.values(): + runtime_url = normalize_base_url( + str( + record.get("runtime_url") + or default_runtime_url(record["base_url"]) + ) + ) + host_id = str(record.get("host_id") or _host_id(runtime_url)) + host = hosts.get(host_id) + if host is None: + now = str(record.get("created_at") or _iso_now()) + hosts[host_id] = { + "id": host_id, + "name": str( + urllib.parse.urlsplit(runtime_url).hostname or "Computer" + ), + "runtime_url": runtime_url, + "runtime_token": str( + record.get("runtime_token") or record.get("token") or "" + ), + "runtime_token_fingerprint": str( + record.get("runtime_token_fingerprint") + or record.get("token_fingerprint") + or "" + ), + "remote_device_id": "", + "created_at": now, + "updated_at": str(record.get("updated_at") or now), + } + changed = True + if record.get("host_id") != host_id: + record["host_id"] = host_id + changed = True + if record.get("runtime_url") != runtime_url: + record["runtime_url"] = runtime_url + changed = True + return hosts, changed + @staticmethod def _public(record: dict[str, Any]) -> dict[str, Any]: public = { @@ -493,3 +843,11 @@ def _public(record: dict[str, Any]) -> dict[str, Any]: record.get("runtime_token_explicit") ) return public + + @staticmethod + def _public_host(record: dict[str, Any]) -> dict[str, Any]: + return { + key: value + for key, value in record.items() + if key != "runtime_token" + } diff --git a/editor-server/project_store.py b/editor-server/project_store.py index de00486..d074d85 100644 --- a/editor-server/project_store.py +++ b/editor-server/project_store.py @@ -77,12 +77,16 @@ def create( description: str = "", workflow_slugs: list[str] | None = None, device_ids: list[str] | None = None, + artifact_ids: list[str] | None = None, + starter_kit: str | None = None, active_workflow_slug: str | None = None, ) -> dict[str, Any]: clean_name = self._clean_name(name) clean_description = self._clean_description(description) clean_workflows = _unique_strings(workflow_slugs, field="workflow_slugs") clean_devices = _unique_strings(device_ids, field="device_ids") + clean_artifacts = _unique_strings(artifact_ids, field="artifact_ids") + clean_starter = self._clean_starter_kit(starter_kit) clean_active = str(active_workflow_slug or "").strip() or None if clean_active and clean_active not in clean_workflows: raise ProjectStoreError( @@ -103,6 +107,8 @@ def create( "description": clean_description, "workflow_slugs": clean_workflows, "device_ids": clean_devices, + "artifact_ids": clean_artifacts, + "starter_kit": clean_starter, "active_workflow_slug": ( clean_active or (clean_workflows[0] if clean_workflows else None) @@ -122,8 +128,11 @@ def update( description: str | None = None, workflow_slugs: list[str] | None = None, device_ids: list[str] | None = None, + artifact_ids: list[str] | None = None, + starter_kit: str | None = None, active_workflow_slug: str | None = None, update_active_workflow: bool = False, + update_starter_kit: bool = False, ) -> dict[str, Any]: with self._lock: records = self._load() @@ -144,6 +153,13 @@ def update( device_ids, field="device_ids", ) + if artifact_ids is not None: + record["artifact_ids"] = _unique_strings( + artifact_ids, + field="artifact_ids", + ) + if update_starter_kit: + record["starter_kit"] = self._clean_starter_kit(starter_kit) workflows = list(record.get("workflow_slugs") or []) if update_active_workflow: clean_active = str(active_workflow_slug or "").strip() or None @@ -168,6 +184,57 @@ def delete(self, project_id: str) -> bool: self._save(records) return True + def link_artifact_ids( + self, + project_id: str, + artifact_ids: list[str], + ) -> dict[str, Any]: + """Atomically add artifact references captured by concurrent node runs.""" + clean_ids = _unique_strings(artifact_ids, field="artifact_ids") + with self._lock: + records = self._load() + record = records.get(project_id) + if record is None: + raise KeyError(project_id) + linked = list(record.get("artifact_ids") or []) + changed = False + for artifact_id in clean_ids: + if artifact_id not in linked: + linked.append(artifact_id) + changed = True + if changed: + record["artifact_ids"] = linked + record["updated_at"] = _iso_now() + records[project_id] = record + self._save(records) + return dict(record) + + def link_workflow_slug( + self, + project_id: str, + workflow_slug: str, + ) -> dict[str, Any]: + """Atomically link a generated starter workflow to its Project.""" + clean_slug = _unique_strings( + [workflow_slug], + field="workflow_slugs", + )[0] + with self._lock: + records = self._load() + record = records.get(project_id) + if record is None: + raise KeyError(project_id) + workflows = list(record.get("workflow_slugs") or []) + if clean_slug not in workflows: + workflows.append(clean_slug) + record["workflow_slugs"] = workflows + if not record.get("active_workflow_slug"): + record["active_workflow_slug"] = clean_slug + record["updated_at"] = _iso_now() + records[project_id] = record + self._save(records) + return dict(record) + def replace_workflow_slug(self, previous_slug: str, next_slug: str) -> None: """Keep project links intact when a saved workflow is renamed.""" if previous_slug == next_slug: @@ -213,7 +280,10 @@ def _load(self) -> dict[str, dict[str, Any]]: continue project_id = str(item.get("id") or "").strip() if project_id: - records[project_id] = dict(item) + record = dict(item) + record.setdefault("artifact_ids", []) + record.setdefault("starter_kit", None) + records[project_id] = record return records def _save(self, records: dict[str, dict[str, Any]]) -> None: @@ -250,3 +320,10 @@ def _clean_description(value: str) -> str: "Project description must be 2,000 characters or fewer." ) return clean + + @staticmethod + def _clean_starter_kit(value: str | None) -> str | None: + clean = str(value or "").strip() or None + if clean not in {None, "robot_learning"}: + raise ProjectStoreError(f"Unknown Project starter kit '{clean}'.") + return clean diff --git a/editor-server/requirements.txt b/editor-server/requirements.txt index d845bd0..dbec63d 100644 --- a/editor-server/requirements.txt +++ b/editor-server/requirements.txt @@ -4,3 +4,4 @@ uvicorn[standard]>=0.29 anthropic>=0.25 openai>=1.0 mcp>=1.0 +paramiko>=3.4 diff --git a/editor-server/server.py b/editor-server/server.py index 27d932f..ec8578f 100644 --- a/editor-server/server.py +++ b/editor-server/server.py @@ -1,10 +1,10 @@ """Blacknode editor backend — FastAPI server the React editor talks to.""" from __future__ import annotations import uuid, os, sys, json, threading, re, queue, io, contextlib, time, subprocess, importlib, signal, shlex, hashlib -import urllib.error, urllib.request +import urllib.error, urllib.parse, urllib.request from datetime import datetime from pathlib import Path -from typing import Any +from typing import Any, Callable from fastapi import FastAPI, HTTPException, WebSocket, WebSocketDisconnect from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import FileResponse, StreamingResponse @@ -53,7 +53,22 @@ from blacknode.workflow import validate_graph as validate_bn_graph from blacknode.workflow import validate_workflow as validate_bn_workflow -from device_registry import DeviceRegistry, DeviceRegistryError, HardwareDeviceClient +from device_installer import ( + control_runtime, + DeviceInstallError, + inspect_runtime, + install_runtime, + probe_device, + restart_hardware_service, + uninstall_runtime, +) +from device_registry import ( + DeviceRegistry, + DeviceRegistryError, + HardwareDeviceClient, + RuntimeDeviceClient, +) +from artifact_store import ArtifactStore, ArtifactStoreError from project_store import ProjectStore, ProjectStoreError from run_store import RunStore @@ -115,6 +130,8 @@ def _reap_orphaned_stream_servers() -> None: _device_registry = DeviceRegistry(_DEVICES_PATH) _PROJECTS_PATH = Path(__file__).resolve().parents[1] / ".blacknode" / "projects.json" _project_store = ProjectStore(_PROJECTS_PATH) +_ARTIFACTS_PATH = Path(__file__).resolve().parents[1] / ".blacknode" / "artifacts.json" +_artifact_store = ArtifactStore(_ARTIFACTS_PATH) _save_timer: threading.Timer | None = None _SUBGRAPH_NODE_TYPES = {"Subnet", "SubnetAsTool", "VisualAgentLoop"} _TOOLBOX_NODE_TYPES = {"ToolBox"} @@ -316,6 +333,8 @@ class CreateProjectReq(BaseModel): description: str = "" workflow_slugs: list[str] = [] device_ids: list[str] = [] + artifact_ids: list[str] = [] + starter_kit: str | None = None active_workflow_slug: str | None = None class UpdateProjectReq(BaseModel): @@ -323,8 +342,19 @@ class UpdateProjectReq(BaseModel): description: str | None = None workflow_slugs: list[str] | None = None device_ids: list[str] | None = None + artifact_ids: list[str] | None = None + starter_kit: str | None = None active_workflow_slug: str | None = None +class ImportProjectArtifactsReq(BaseModel): + workflow_slug: str | None = None + node_type: str = "" + value: Any = None + +class InspectProjectArtifactReq(BaseModel): + path: str + workflow_slug: str | None = None + class NewWorkflowTabReq(BaseModel): name: str = "Untitled" @@ -380,6 +410,43 @@ class PairDeviceReq(BaseModel): token: str runtime_token: str | None = None +class PairDeviceHostReq(BaseModel): + name: str = "" + runtime_url: str + runtime_token: str + +class PairHostRobotReq(BaseModel): + name: str = "" + base_url: str + token: str + +class SshProbeReq(BaseModel): + host: str + port: int = 22 + +class SshDeviceReq(SshProbeReq): + username: str + password: str + +class InspectDeviceHostReq(SshDeviceReq): + host_fingerprint: str + +class InstallDeviceHostReq(InspectDeviceHostReq): + name: str = "" + action: str = "install" + instance_id: str = "" + +class UninstallDeviceHostReq(BaseModel): + password: str + +class RuntimeLifecycleReq(BaseModel): + action: str + password: str + +class RobotLifecycleReq(BaseModel): + action: str + password: str = "" + class RenameDeviceReq(BaseModel): name: str @@ -2829,6 +2896,647 @@ def _paired_device_client(device_id: str) -> HardwareDeviceClient: raise HTTPException(500, str(exc)) from exc +def _device_host_runtime_status(host_id: str) -> dict[str, Any]: + try: + host = _device_registry.get_host_public(host_id) + except DeviceRegistryError as exc: + raise HTTPException(500, str(exc)) from exc + if host is None: + raise HTTPException(404, "Device not found") + if host.get("paused"): + return { + "ok": False, + "paused": True, + "runtime_url": host["runtime_url"], + "error": "Runtime is paused.", + } + try: + manifest = _device_registry.host_client(host_id).manifest() + if ( + manifest.get("service") != "blacknode-runtime" + or manifest.get("protocol_version") != 1 + ): + raise DeviceRegistryError( + "Runtime service identity or protocol is incompatible." + ) + except (DeviceRegistryError, KeyError) as exc: + return { + "ok": False, + "runtime_url": host["runtime_url"], + "error": str(exc), + } + return { + "ok": True, + "runtime_url": host["runtime_url"], + "manifest": manifest, + } + + +@app.get("/device-hosts") +def list_device_hosts(): + try: + return {"devices": _device_registry.list_hosts()} + except DeviceRegistryError as exc: + raise HTTPException(500, str(exc)) from exc + + +@app.post("/device-hosts") +def pair_device_host(req: PairDeviceHostReq): + try: + client = RuntimeDeviceClient(req.runtime_url, req.runtime_token) + manifest = client.manifest() + host = _device_registry.pair_host( + name=req.name, + runtime_url=client.base_url, + runtime_token=req.runtime_token, + manifest=manifest, + ) + except DeviceRegistryError as exc: + raise HTTPException(400, str(exc)) from exc + return { + "device": host, + "runtime": _device_host_runtime_status(host["id"]), + } + + +@app.post("/device-hosts/ssh-probe") +def probe_device_host_ssh(req: SshProbeReq): + try: + return probe_device( + host=req.host, + port=req.port, + ) + except DeviceInstallError as exc: + raise HTTPException(400, str(exc)) from exc + + +@app.post("/device-hosts/inspect") +def inspect_device_host(req: InspectDeviceHostReq): + try: + return inspect_runtime( + host=req.host, + port=req.port, + username=req.username, + password=req.password, + host_fingerprint=req.host_fingerprint, + ) + except DeviceInstallError as exc: + raise HTTPException(400, str(exc)) from exc + + +def _install_device_host_payload( + req: InstallDeviceHostReq, + progress: Callable[[dict[str, Any]], None] | None = None, +) -> dict[str, Any]: + installed = install_runtime( + host=req.host, + port=req.port, + username=req.username, + password=req.password, + host_fingerprint=req.host_fingerprint, + action=req.action, + instance_id=req.instance_id, + progress=progress, + ) + host_name = str(req.host or "").strip() + runtime_host = f"[{host_name}]" if ":" in host_name else host_name + runtime_port = int(installed["runtime_port"]) + runtime_url = f"http://{runtime_host}:{runtime_port}" + runtime_token = str(installed["runtime_token"]) + if progress is not None: + progress({"progress": 98, "message": "Pairing the installed runtime"}) + client = RuntimeDeviceClient(runtime_url, runtime_token) + manifest = client.manifest() + host = _device_registry.pair_host( + name=req.name, + runtime_url=runtime_url, + runtime_token=runtime_token, + manifest=manifest, + managed_runtime={ + "ssh_host": host_name, + "ssh_port": req.port, + "ssh_username": req.username, + "host_fingerprint": installed["host_fingerprint"], + "instance_id": installed["instance_id"], + "runtime_port": runtime_port, + "service_name": installed["service_name"], + "runtime_dir": installed["runtime_dir"], + }, + ) + if progress is not None: + progress({"progress": 100, "message": "Device is ready"}) + return { + "device": host, + "runtime": { + "ok": True, + "runtime_url": runtime_url, + "manifest": manifest, + }, + "install": { + key: value + for key, value in installed.items() + if key != "runtime_token" + }, + } + + +@app.post("/device-hosts/install") +def install_device_host(req: InstallDeviceHostReq): + try: + return _install_device_host_payload(req) + except (DeviceInstallError, DeviceRegistryError) as exc: + raise HTTPException(400, str(exc)) from exc + + +@app.post("/device-hosts/install-stream") +def install_device_host_stream(req: InstallDeviceHostReq): + def event_stream(): + events: queue.Queue[dict[str, Any]] = queue.Queue() + + def worker() -> None: + try: + result = _install_device_host_payload(req, progress=events.put) + events.put({"type": "done", "result": result}) + except (DeviceInstallError, DeviceRegistryError) as exc: + events.put({"type": "error", "error": str(exc)}) + except Exception as exc: + events.put({ + "type": "error", + "error": f"Device installation failed: {exc}", + }) + + threading.Thread( + target=worker, + name="blacknode-device-install", + daemon=True, + ).start() + while True: + event = events.get() + if "type" not in event: + event = {"type": "progress", **event} + yield json.dumps(event, separators=(",", ":")) + "\n" + if event.get("type") in {"done", "error"}: + break + + return StreamingResponse(event_stream(), media_type="application/x-ndjson") + + +@app.get("/device-hosts/{host_id}/runtime-status") +def get_device_host_runtime_status(host_id: str): + return _device_host_runtime_status(host_id) + + +def _raise_rpc_error(result: dict[str, Any], *, action: str) -> None: + error = result.get("error") if isinstance(result, dict) else None + if not error: + return + message = ( + str(error.get("message") or error) + if isinstance(error, dict) + else str(error) + ) + raise DeviceRegistryError(f"Could not {action} robot: {message}") + + +@app.post("/device-hosts/{host_id}/lifecycle") +def control_device_host_lifecycle(host_id: str, req: RuntimeLifecycleReq): + try: + return _control_device_host_lifecycle_payload(host_id, req) + except DeviceInstallError as exc: + raise HTTPException(400, str(exc)) from exc + except (DeviceRegistryError, KeyError) as exc: + raise HTTPException(502, str(exc)) from exc + + +def _control_device_host_lifecycle_payload( + host_id: str, + req: RuntimeLifecycleReq, + progress: Callable[[dict[str, Any]], None] | None = None, +) -> dict[str, Any]: + def report(percent: int, message: str) -> None: + if progress is not None: + progress({ + "progress": max(0, min(100, int(percent))), + "message": str(message), + }) + + action = str(req.action or "").strip().lower() + if action not in {"pause", "resume"}: + raise HTTPException(400, "Device action must be pause or resume.") + report(5, "Checking the managed device") + try: + host = _device_registry.get_host_public(host_id) + except DeviceRegistryError as exc: + raise DeviceRegistryError(str(exc)) from exc + if host is None: + raise HTTPException(404, "Device not found") + managed = host.get("managed_runtime") + if not isinstance(managed, dict): + raise HTTPException( + 409, + "This runtime was paired manually. Pause or resume its service on the " + "device with ./service.sh stop or ./service.sh start.", + ) + + stopped_deployments: list[str] = [] + controlled_robots: list[str] = [] + warnings: list[str] = [] + if action == "pause": + report(15, f"Stopping deployments through {host['runtime_url']}") + try: + deployments = _device_registry.host_client(host_id).list_deployments() + for deployment in deployments.get("deployments") or []: + if ( + isinstance(deployment, dict) + and str(deployment.get("state") or "") == "running" + ): + deployment_id = str(deployment.get("id") or "") + if deployment_id: + _device_registry.host_client(host_id).stop_deployment( + deployment_id + ) + stopped_deployments.append(deployment_id) + except (DeviceRegistryError, KeyError) as exc: + warnings.append( + f"Deployment runtime {host['runtime_url']} could not be reached: {exc}" + ) + robots = list(host.get("robots") or []) + for index, robot in enumerate(robots): + robot_id = str(robot.get("id") or "") + if not robot_id: + continue + report( + 30 + int(30 * (index + 1) / max(1, len(robots))), + f"Stopping and disarming {robot.get('name') or robot_id}", + ) + try: + result = _paired_device_client(robot_id).rpc({ + "jsonrpc": "2.0", + "id": f"device-pause-{robot_id}", + "method": "stop", + "params": {}, + }) + _raise_rpc_error(result, action="pause") + _device_registry.set_device_paused(robot_id, True) + controlled_robots.append(robot_id) + except (DeviceRegistryError, HTTPException, KeyError) as exc: + detail = exc.detail if isinstance(exc, HTTPException) else str(exc) + warnings.append( + f"Robot {robot.get('name') or robot_id} could not be stopped: {detail}" + ) + + report(70, f"{'Stopping' if action == 'pause' else 'Starting'} the runtime service") + runtime = control_runtime( + host=str(managed.get("ssh_host") or ""), + port=int(managed.get("ssh_port") or 22), + username=str(managed.get("ssh_username") or ""), + password=req.password, + host_fingerprint=str(managed.get("host_fingerprint") or ""), + instance_id=str(managed.get("instance_id") or "default"), + action=action, + ) + + if action == "resume": + robots = list(host.get("robots") or []) + for index, robot in enumerate(robots): + robot_id = str(robot.get("id") or "") + if not robot_id: + continue + report( + 78 + int(17 * (index + 1) / max(1, len(robots))), + f"Reconnecting {robot.get('name') or robot_id} in a disarmed state", + ) + try: + result = _paired_device_client(robot_id).rpc({ + "jsonrpc": "2.0", + "id": f"device-resume-{robot_id}", + "method": "resume", + "params": {}, + }) + _raise_rpc_error(result, action="resume") + _device_registry.set_device_paused(robot_id, False) + controlled_robots.append(robot_id) + except (DeviceRegistryError, HTTPException, KeyError) as exc: + detail = exc.detail if isinstance(exc, HTTPException) else str(exc) + warnings.append( + f"Robot {robot.get('name') or robot_id} did not reconnect: {detail}" + ) + + device = _device_registry.set_host_paused(host_id, action == "pause") + summary = ( + "Device paused; its runtime is stopped and reachable robot endpoints accepted the stop request." + if action == "pause" + else "Device resumed; its runtime and robot monitoring were restored. Robots remain disarmed." + ) + if warnings: + summary += ( + f" Completed with {len(warnings)} warning{'s' if len(warnings) != 1 else ''}; " + "verify the listed robot and physical torque state." + ) + report(100, summary) + return { + "ok": True, + "action": action, + "runtime": runtime, + "device": device, + "stopped_deployments": stopped_deployments, + "controlled_robots": controlled_robots, + "warnings": warnings, + "summary": summary, + } + + +def _lifecycle_stream(worker: Callable[[Callable[[dict[str, Any]], None]], dict[str, Any]]): + def event_stream(): + events: queue.Queue[dict[str, Any]] = queue.Queue() + + def run() -> None: + try: + events.put({"progress": 1, "message": "Starting lifecycle action"}) + events.put({"type": "done", "result": worker(events.put)}) + except HTTPException as exc: + events.put({"type": "error", "error": str(exc.detail)}) + except (DeviceInstallError, DeviceRegistryError, KeyError) as exc: + events.put({"type": "error", "error": str(exc)}) + except Exception as exc: + events.put({"type": "error", "error": f"Lifecycle action failed: {exc}"}) + + threading.Thread( + target=run, + name="blacknode-device-lifecycle", + daemon=True, + ).start() + while True: + event = events.get() + if "type" not in event: + event = {"type": "progress", **event} + yield json.dumps(event, separators=(",", ":")) + "\n" + if event.get("type") in {"done", "error"}: + break + + return StreamingResponse(event_stream(), media_type="application/x-ndjson") + + +@app.post("/device-hosts/{host_id}/lifecycle-stream") +def stream_device_host_lifecycle(host_id: str, req: RuntimeLifecycleReq): + return _lifecycle_stream( + lambda progress: _control_device_host_lifecycle_payload(host_id, req, progress) + ) + + +def _control_robot_lifecycle_payload( + device_id: str, + req: RobotLifecycleReq, + progress: Callable[[dict[str, Any]], None] | None = None, +) -> dict[str, Any]: + def report(percent: int, message: str) -> None: + if progress is not None: + progress({ + "progress": max(0, min(100, int(percent))), + "message": str(message), + }) + + action = str(req.action or "").strip().lower() + if action not in {"pause", "resume", "restart"}: + raise HTTPException(400, "Robot action must be pause, resume, or restart.") + report(5, "Checking the robot") + saved_device = _device_registry.get_public(device_id) + if saved_device is None: + raise HTTPException(404, "Device not found") + + warnings: list[str] = [] + if action == "restart": + if not str(req.password or ""): + raise HTTPException(400, "Enter the SSH password to restart this robot service.") + status = _deployment_aware_device_status(device_id) + if status.get("deployment_lease") or status.get("leased_to_deployment"): + raise HTTPException( + 409, + "Stop the robot's active deployment before restarting its hardware service.", + ) + if status.get("armed"): + raise HTTPException( + 409, + "Pause and disarm the robot before restarting its hardware service.", + ) + host_id = str(saved_device.get("host_id") or "") + host = _device_registry.get_host_public(host_id) if host_id else None + managed = host.get("managed_runtime") if isinstance(host, dict) else None + if not isinstance(managed, dict): + raise HTTPException( + 409, + "This robot's compute device was paired manually. Restart its " + "blacknode-hardware service on the device.", + ) + try: + hardware_port = urllib.parse.urlsplit( + str(saved_device.get("base_url") or "") + ).port + except ValueError as exc: + raise HTTPException(409, "The saved robot hardware URL has an invalid port.") from exc + if not hardware_port: + raise HTTPException(409, "The saved robot hardware URL has no service port.") + + report(35, f"Resolving the hardware service on port {hardware_port}") + service = restart_hardware_service( + host=str(managed.get("ssh_host") or ""), + port=int(managed.get("ssh_port") or 22), + username=str(managed.get("ssh_username") or ""), + password=req.password, + host_fingerprint=str(managed.get("host_fingerprint") or ""), + hardware_port=hardware_port, + ) + report(75, "Verifying the authenticated robot after restart") + verified_status: dict[str, Any] | None = None + last_error = "" + for _attempt in range(30): + try: + candidate = _paired_device_client(device_id).status() + expected_id = str(saved_device.get("remote_device_id") or "") + actual_id = str(candidate.get("device_id") or "") + if expected_id and actual_id != expected_id: + raise DeviceRegistryError( + f"port {hardware_port} returned robot '{actual_id}', expected '{expected_id}'" + ) + verified_status = candidate + break + except (DeviceRegistryError, HTTPException) as exc: + last_error = exc.detail if isinstance(exc, HTTPException) else str(exc) + time.sleep(0.25) + if verified_status is None: + raise DeviceRegistryError( + f"{service['service_name']} restarted, but the robot did not return: {last_error}" + ) + if verified_status.get("armed"): + result = _paired_device_client(device_id).rpc({ + "jsonrpc": "2.0", + "id": f"robot-restart-stop-{device_id}", + "method": "stop", + "params": {}, + }) + _raise_rpc_error(result, action="disarm restarted") + verified_status = _paired_device_client(device_id).status() + if verified_status.get("armed"): + raise DeviceRegistryError( + "The restarted robot service did not return to a disarmed state." + ) + if not verified_status.get("connected"): + warnings.append( + "The service restarted, but the hardware provider reports disconnected." + ) + _device_registry.set_device_paused(device_id, False) + verified_status["paused"] = False + summary = ( + f"Restarted {service['service_name']} for hardware port {hardware_port}; " + "robot monitoring is online and motion remains disarmed." + ) + if warnings: + summary += f" Completed with {len(warnings)} warning." + report(100, summary) + return { + "ok": True, + "action": action, + "status": verified_status, + "service": service, + "warnings": warnings, + "summary": summary, + } + + if action == "pause": + report(20, "Checking for an active deployment") + try: + status = _deployment_aware_device_status(device_id) + lease = status.get("deployment_lease") + if isinstance(lease, dict) and lease.get("id"): + _runtime_client_or_404(device_id).stop_deployment(str(lease["id"])) + except (DeviceRegistryError, HTTPException, KeyError) as exc: + detail = exc.detail if isinstance(exc, HTTPException) else str(exc) + warnings.append( + "The deployment runtime at " + f"{saved_device.get('runtime_url') or 'the saved runtime URL'} could not be reached: " + f"{detail}. Blacknode continued with the robot hardware stop request, " + "but physical torque cannot be verified through this status endpoint." + ) + method = "stop" + report(60, "Stopping and disarming the robot directly") + else: + method = "resume" + report(60, "Reconnecting robot monitoring in a disarmed state") + + result = _paired_device_client(device_id).rpc({ + "jsonrpc": "2.0", + "id": f"robot-{action}-{device_id}", + "method": method, + "params": {}, + }) + _raise_rpc_error(result, action=action) + _device_registry.set_device_paused(device_id, action == "pause") + report(85, "Verifying robot status") + status = _paired_device_client(device_id).status() + status["paused"] = action == "pause" + summary = ( + "Robot paused; Blacknode motion is disarmed." + if action == "pause" + else "Robot monitoring resumed; motion remains disarmed." + ) + if warnings: + summary += ( + f" Completed with {len(warnings)} warning{'s' if len(warnings) != 1 else ''}; " + "verify physical torque before handling the robot." + ) + report(100, summary) + return { + "ok": True, + "action": action, + "status": status, + "warnings": warnings, + "summary": summary, + } + + +@app.post("/device-hosts/{host_id}/uninstall") +def uninstall_device_host(host_id: str, req: UninstallDeviceHostReq): + try: + host = _device_registry.get_host_public(host_id) + except DeviceRegistryError as exc: + raise HTTPException(500, str(exc)) from exc + if host is None: + raise HTTPException(404, "Device not found") + managed = host.get("managed_runtime") + if not isinstance(managed, dict): + raise HTTPException( + 409, + "This runtime was paired manually, so Blacknode does not have its SSH " + "installation identity. Remove it from the editor or uninstall it on the device.", + ) + try: + result = uninstall_runtime( + host=str(managed.get("ssh_host") or ""), + port=int(managed.get("ssh_port") or 22), + username=str(managed.get("ssh_username") or ""), + password=req.password, + host_fingerprint=str(managed.get("host_fingerprint") or ""), + instance_id=str(managed.get("instance_id") or "default"), + runtime_port=int(managed.get("runtime_port") or 0), + ) + _device_registry.delete_host(host_id, cascade=True) + except DeviceInstallError as exc: + raise HTTPException(400, str(exc)) from exc + except DeviceRegistryError as exc: + raise HTTPException(409, str(exc)) from exc + return { + "ok": True, + "id": host_id, + "uninstall": result, + } + + +@app.post("/device-hosts/{host_id}/robots") +def pair_host_robot(host_id: str, req: PairHostRobotReq): + try: + if _device_registry.get_host_public(host_id) is None: + raise HTTPException(404, "Device not found") + client = HardwareDeviceClient(req.base_url, req.token) + status = client.validate_pairing() + device = _device_registry.pair( + name=req.name, + base_url=client.base_url, + token=req.token, + host_id=host_id, + status=status, + ) + except DeviceRegistryError as exc: + raise HTTPException(400, str(exc)) from exc + return { + "robot": device, + "status": status, + "runtime": _device_host_runtime_status(host_id), + } + + +@app.patch("/device-hosts/{host_id}") +def rename_device_host(host_id: str, req: RenameDeviceReq): + try: + return {"device": _device_registry.rename_host(host_id, req.name)} + except KeyError as exc: + raise HTTPException(404, "Device not found") from exc + except ValueError as exc: + raise HTTPException(400, str(exc)) from exc + except DeviceRegistryError as exc: + raise HTTPException(500, str(exc)) from exc + + +@app.delete("/device-hosts/{host_id}") +def delete_device_host(host_id: str): + try: + deleted = _device_registry.delete_host(host_id) + except DeviceRegistryError as exc: + raise HTTPException(409, str(exc)) from exc + if not deleted: + raise HTTPException(404, "Device not found") + return {"ok": True, "id": host_id} + + @app.get("/devices") def list_devices(): try: @@ -2918,6 +3626,21 @@ def get_device_status(device_id: str): raise HTTPException(502, str(exc)) from exc +@app.post("/devices/{device_id}/lifecycle") +def control_robot_lifecycle(device_id: str, req: RobotLifecycleReq): + try: + return _control_robot_lifecycle_payload(device_id, req) + except DeviceRegistryError as exc: + raise HTTPException(502, str(exc)) from exc + + +@app.post("/devices/{device_id}/lifecycle-stream") +def stream_robot_lifecycle(device_id: str, req: RobotLifecycleReq): + return _lifecycle_stream( + lambda progress: _control_robot_lifecycle_payload(device_id, req, progress) + ) + + @app.get("/devices/{device_id}/runtime-status") def get_device_runtime_status(device_id: str): return _device_runtime_status(device_id) @@ -4137,6 +4860,14 @@ def _deployment_aware_device_status(device_id: str) -> dict[str, Any]: """Recover an orphaned hardware lease, or explain the active owner.""" client = _paired_device_client(device_id) status = client.status() + saved_device = _device_registry.get_public(device_id) + if saved_device and saved_device.get("paused"): + result = dict(status) + result["paused"] = True + result["notice"] = ( + "Robot is paused and disarmed. Resume it before starting a workflow." + ) + return result error = str(status.get("error") or "") folded_error = error.casefold() if "leased" not in folded_error or "deployment" not in folded_error: @@ -6138,6 +6869,23 @@ def _insert_workflow(node_meta: dict, edges: list): "IsaacPolicyBridge", "IsaacPolicyRuntime", } +_PROJECT_STARTER_KITS = { + "robot_learning": { + "collect": { + "template_slug": "teleoperation-episode-recording", + "name": "Collect demonstrations", + }, + "train": { + "template_slug": "act-training", + "name": "Train ACT policy", + }, + "simulate": { + "template_slug": "isaac-act-policy-deployment", + "name": "Evaluate policy in Isaac", + }, + }, +} +_PROJECT_STARTER_LOCK = threading.RLock() _PROJECT_ROBOT_NODE_MARKERS = ( "robot", "servo", @@ -6219,6 +6967,21 @@ def _project_workflow_reference(slug: str) -> dict[str, Any]: "stages": stages, "requires_calibration": requires_calibration, "calibration": calibration, + "starter_kit": ( + str(metadata.get("starter_kit")) + if metadata.get("starter_kit") + else None + ), + "starter_stage": ( + str(metadata.get("starter_stage")) + if metadata.get("starter_stage") + else None + ), + "source_template": ( + str(metadata.get("source_template")) + if metadata.get("source_template") + else None + ), } @@ -6239,6 +7002,7 @@ def _project_payload(record: dict[str, Any]) -> dict[str, Any]: **record, "workflows": workflows, "devices": devices, + "artifacts": _artifact_store.list(record.get("artifact_ids", [])), } @@ -6262,6 +7026,8 @@ def create_project(req: CreateProjectReq): description=req.description, workflow_slugs=req.workflow_slugs, device_ids=req.device_ids, + artifact_ids=req.artifact_ids, + starter_kit=req.starter_kit, active_workflow_slug=req.active_workflow_slug, )) except ProjectStoreError as exc: @@ -6293,8 +7059,11 @@ def update_project(project_id: str, req: UpdateProjectReq): description=req.description, workflow_slugs=req.workflow_slugs, device_ids=req.device_ids, + artifact_ids=req.artifact_ids, + starter_kit=req.starter_kit, active_workflow_slug=req.active_workflow_slug, update_active_workflow="active_workflow_slug" in fields_set, + update_starter_kit="starter_kit" in fields_set, )) except KeyError as exc: raise HTTPException(404, f"Project '{project_id}' not found") from exc @@ -6313,6 +7082,277 @@ def delete_project(project_id: str): return {"ok": True} +def _project_artifact_error( + exc: ProjectStoreError | ArtifactStoreError, +) -> HTTPException: + return HTTPException(400, str(exc)) + + +def _require_project(project_id: str) -> dict[str, Any]: + record = _project_store.get(project_id) + if record is None: + raise HTTPException(404, f"Project '{project_id}' not found") + return record + + +def _set_starter_node_defaults( + data: dict[str, Any], + node_id: str, + values: dict[str, Any], +) -> None: + node_meta = data.get("node_meta") + node = node_meta.get(node_id) if isinstance(node_meta, dict) else None + if not isinstance(node, dict): + return + params = node.setdefault("params", {}) + if isinstance(params, dict): + params.update(values) + defaults = node.get("input_defaults") + if isinstance(defaults, dict): + defaults.update({ + key: value + for key, value in values.items() + if key in defaults + }) + + +def _configure_project_starter_workflow( + data: dict[str, Any], + stage: str, + project: dict[str, Any], +) -> None: + """Prefill safe Project context while keeping all actions disarmed.""" + project_id = str(project["id"]) + if stage == "collect": + _set_starter_node_defaults(data, "rosbridge", {"transport": "auto"}) + _set_starter_node_defaults(data, "follow", { + "transport": "auto", + "run_id": f"{project_id}-teleoperation", + "armed": False, + }) + _set_starter_node_defaults(data, "dataset", { + "dataset_id": f"{project_id}-demonstrations"[:120], + "task": str(project.get("description") or project.get("name") or ""), + "metadata": {"project_id": project_id}, + }) + _set_starter_node_defaults(data, "recorder", { + "action": "status", + "run_id": f"{project_id}-episode", + }) + return + + artifacts = _artifact_store.list(project.get("artifact_ids", [])) + if stage == "train": + dataset = next(( + artifact + for artifact in reversed(artifacts) + if artifact.get("artifact_type") == "dataset" + and artifact.get("exists") + and int((artifact.get("metadata") or {}).get("episode_count") or 0) > 0 + ), None) + if dataset is not None: + locator = str(dataset["locator"]) + metadata = dict(dataset.get("metadata") or {}) + descriptor = { + "kind": str(dataset.get("kind") or "blacknode.episode-dataset"), + "schema_version": 1, + "dataset_id": str(metadata.get("dataset_id") or dataset["name"]), + "path": locator, + "fps": int(metadata.get("fps") or 0), + "task": str(metadata.get("task") or ""), + "episode_count": int(metadata.get("episode_count") or 0), + } + _set_starter_node_defaults(data, "dataset_browser", { + "dataset": descriptor, + "root": str(Path(locator).parent), + "dataset_id": descriptor["dataset_id"], + }) + _set_starter_node_defaults(data, "training", { + "action": "start", + "run_id": f"{project_id}-act", + }) + _set_starter_node_defaults(data, "policy_stream", { + "run_id": f"{project_id}-policy-replay", + }) + return + + if stage == "simulate": + policy = next(( + artifact + for artifact in reversed(artifacts) + if artifact.get("artifact_type") == "policy" + and artifact.get("exists") + ), None) + if policy is not None: + _set_starter_node_defaults(data, "artifact_path", { + "value": str(policy["locator"]), + }) + _set_starter_node_defaults(data, "bridge", { + "action": "status", + "run_id": f"{project_id}-isaac-bridge", + }) + _set_starter_node_defaults(data, "runtime", { + "action": "status", + "run_id": f"{project_id}-isaac-policy", + }) + + +@app.post("/projects/{project_id}/starter-workflows/{stage}") +def create_project_starter_workflow(project_id: str, stage: str): + with _PROJECT_STARTER_LOCK: + return _materialize_project_starter_workflow(project_id, stage) + + +def _materialize_project_starter_workflow(project_id: str, stage: str): + try: + project = _require_project(project_id) + starter_kit = str(project.get("starter_kit") or "") + kit = _PROJECT_STARTER_KITS.get(starter_kit) + if kit is None: + raise HTTPException( + 409, + "Enable the Robot learning starter for this Project first.", + ) + starter = kit.get(stage) + if starter is None: + raise HTTPException(404, f"Starter stage '{stage}' was not found.") + + for workflow_slug in project.get("workflow_slugs", []): + reference = _project_workflow_reference(str(workflow_slug)) + if ( + reference.get("exists") + and reference.get("starter_kit") == starter_kit + and reference.get("starter_stage") == stage + ): + return { + "created": False, + "workflow": reference, + "project": _project_payload(project), + } + + template_slug = str(starter["template_slug"]) + template_path = _template_path(template_slug) + if not os.path.exists(template_path): + raise HTTPException( + 409, + f"The {starter['name']} template is unavailable. Install and " + "enable its Blacknode package, then try again.", + ) + data = _read_workflow_file(template_path) + dependencies = _workflow_dependency_report(data) + if not dependencies["ok"]: + raise HTTPException(409, dependencies) + report = validate_bn_workflow(data) + if not report.ok: + raise HTTPException(400, report.to_dict()) + _configure_project_starter_workflow(data, stage, project) + + workflow_name = f"{project['name']} · {starter['name']}" + workflow_slug = _unique_workflow_slug(_slug(workflow_name)) + metadata = ( + dict(data.get("metadata")) + if isinstance(data.get("metadata"), dict) + else {} + ) + metadata.pop("template", None) + metadata.update({ + "starter_kit": starter_kit, + "starter_stage": stage, + "source_template": template_slug, + "project_id": project_id, + }) + data["name"] = workflow_name + data["saved_at"] = datetime.now().isoformat(timespec="seconds") + data["metadata"] = metadata + os.makedirs(_WORKFLOWS_DIR, exist_ok=True) + with open(_workflow_path(workflow_slug), "w", encoding="utf-8") as file: + json.dump(data, file, indent=2) + try: + project = _project_store.link_workflow_slug( + project_id, + workflow_slug, + ) + except KeyError as exc: + os.remove(_workflow_path(workflow_slug)) + raise HTTPException( + 404, + f"Project '{project_id}' not found", + ) from exc + return { + "created": True, + "workflow": _project_workflow_reference(workflow_slug), + "project": _project_payload(project), + } + except ProjectStoreError as exc: + raise _project_error(exc) from exc + + +def _link_project_artifacts( + project_id: str, + artifacts: list[dict[str, Any]], +) -> dict[str, Any]: + _require_project(project_id) + artifact_ids = [ + str(artifact.get("id") or "") + for artifact in artifacts + if artifact.get("id") + ] + try: + record = _project_store.link_artifact_ids(project_id, artifact_ids) + except KeyError as exc: + raise HTTPException(404, f"Project '{project_id}' not found") from exc + return _project_payload(record) + + +@app.post("/projects/{project_id}/artifacts/import") +def import_project_artifacts(project_id: str, req: ImportProjectArtifactsReq): + try: + project = _require_project(project_id) + if req.workflow_slug and req.workflow_slug not in project.get( + "workflow_slugs", [] + ): + raise HTTPException( + 400, + "workflow_slug must be linked to the project before its artifacts " + "can be captured.", + ) + artifacts = _artifact_store.import_value( + req.value, + node_type=req.node_type, + workflow_slug=req.workflow_slug, + ) + return { + "artifacts": artifacts, + "project": _link_project_artifacts(project_id, artifacts), + } + except (ProjectStoreError, ArtifactStoreError) as exc: + raise _project_artifact_error(exc) from exc + + +@app.post("/projects/{project_id}/artifacts/inspect") +def inspect_project_artifact(project_id: str, req: InspectProjectArtifactReq): + try: + project = _require_project(project_id) + if req.workflow_slug and req.workflow_slug not in project.get( + "workflow_slugs", [] + ): + raise HTTPException( + 400, + "workflow_slug must be linked to the project before its artifacts " + "can be added.", + ) + artifacts = _artifact_store.inspect_path( + req.path, + workflow_slug=req.workflow_slug, + ) + return { + "artifacts": artifacts, + "project": _link_project_artifacts(project_id, artifacts), + } + except (ProjectStoreError, ArtifactStoreError) as exc: + raise _project_artifact_error(exc) from exc + + @app.get("/workflows") def list_workflows(): os.makedirs(_WORKFLOWS_DIR, exist_ok=True) diff --git a/editor/src/App.tsx b/editor/src/App.tsx index 1ad8426..c4d13bc 100644 --- a/editor/src/App.tsx +++ b/editor/src/App.tsx @@ -35,7 +35,7 @@ const NODE_TYPES = { subnetoutput: SubgraphOutputNode, } -const TAB_H = 36 // workflow tab bar height +const TAB_H = 52 // workflow tab bar height const DEFAULT_FRAMEWORK_EXPORT_TARGETS: FrameworkExportTarget[] = [ { id: 'python', label: 'Plain Python', description: 'Readable Blacknode Graph script.', extension: '.py' }, @@ -159,7 +159,6 @@ export default function App() { const suppressPaneClick = useRef(false) const lastBackendNotice = useRef(null) const activeTab = tabs.find(tab => tab.id === activeTabId) - const needsSave = Boolean(activeTab && (activeTab.dirty || !activeTab.slug)) const menuTab = tabMenu ? tabs.find(tab => tab.id === tabMenu.tabId) : null const pendingCloseTab = pendingClose ? tabs.find(tab => tab.id === pendingClose.tabId) : null @@ -1026,7 +1025,8 @@ export default function App() { setTabMenu({ x: e.clientX, y: e.clientY, tabId }) }, []) - const topbarH = 44 + const topbarH = 52 + const leftRailW = 78 const canvasPad = topbarH + TAB_H const liveStreamCount = nodes.filter(n => ( LIVE_STREAM_NODE_TYPES.has(n.data.type) && @@ -1062,25 +1062,26 @@ export default function App() { {/* ── top bar ── */}
-
+
BLACKNODE
-
- - right-click to add - +
setLoop(event.target.checked)} /> Loop - - + + @@ -311,7 +311,7 @@ export default function DatasetBrowserPanel({ id, data }: {
- + {jointNames.map(name => diff --git a/editor/src/components/DeploymentsPanel.tsx b/editor/src/components/DeploymentsPanel.tsx index 3c125ff..1fca97b 100644 --- a/editor/src/components/DeploymentsPanel.tsx +++ b/editor/src/components/DeploymentsPanel.tsx @@ -1281,7 +1281,7 @@ function formatCalibrationTime(value: string): string { } const miniButton: CSSProperties = { - background: 'transparent', + background: 'var(--lift)', border: '1px solid var(--line2)', color: 'var(--tx2)', padding: '2px 8px', @@ -1293,8 +1293,10 @@ const miniButton: CSSProperties = { const primaryButton: CSSProperties = { ...miniButton, - borderColor: 'var(--ok)', - color: 'var(--ok)', + background: 'var(--action)', + borderColor: 'var(--action)', + color: 'var(--action-ink)', + fontWeight: 700, } const logStyle: CSSProperties = { @@ -1302,7 +1304,7 @@ const logStyle: CSSProperties = { padding: 8, maxHeight: 220, overflow: 'auto', - background: 'var(--bg2)', + background: 'var(--panel)', border: '1px solid var(--line)', borderRadius: 4, color: 'var(--tx2)', diff --git a/editor/src/components/DevicesPanel.tsx b/editor/src/components/DevicesPanel.tsx index 84bf0c1..32e8a5d 100644 --- a/editor/src/components/DevicesPanel.tsx +++ b/editor/src/components/DevicesPanel.tsx @@ -1,53 +1,59 @@ import { useEffect, useState, type CSSProperties, type FormEvent } from 'react' import { api, + type ComputeDevice, + type DeviceActionProgress, + type DeviceInstallProgress, type DeviceRuntimeStatus, type HardwareDevice, type HardwareDeviceStatus, + type SshDeviceProbe, + type SshRuntimeInspection, } from '../api' +import { useStore } from '../store' -type DeviceState = { +type RobotState = { status?: HardwareDeviceStatus - runtime?: DeviceRuntimeStatus error?: string loading?: boolean checkedAt?: number } -const DEFAULT_HARDWARE_URL = 'http://192.168.1.87:8765' +type DeviceState = { + runtime?: DeviceRuntimeStatus + loading?: boolean + checkedAt?: number +} + +type RuntimeInstallAction = 'install' | 'reuse' | 'replace' | 'side_by_side' + +const DEFAULT_RUNTIME_URL = 'http://192.168.1.87:8766' +const DEFAULT_SSH_HOST = '192.168.1.87' const FIRST_HARDWARE_PORT = 8765 const RUNTIME_PORT = 8766 -function suggestedHardwareUrl(devices: HardwareDevice[]): string { - const latest = devices[devices.length - 1] - if (!latest) return DEFAULT_HARDWARE_URL +function suggestedHardwareUrl(device: ComputeDevice): string { try { - const latestUrl = new URL(latest.base_url) - const usedPorts = new Set( - devices.flatMap(device => { - try { - const parsed = new URL(device.base_url) - if (parsed.protocol !== latestUrl.protocol || parsed.hostname !== latestUrl.hostname) { - return [] - } - return parsed.port ? [Number(parsed.port)] : [] - } catch { - return [] - } - }), - ) + const runtime = new URL(device.runtime_url) + const runtimePort = Number(runtime.port || (runtime.protocol === 'https:' ? 443 : 80)) + const usedPorts = new Set(device.robots.flatMap(robot => { + try { + const parsed = new URL(robot.base_url) + return parsed.port ? [Number(parsed.port)] : [] + } catch { + return [] + } + })) let port = FIRST_HARDWARE_PORT - while (port === RUNTIME_PORT || usedPorts.has(port)) port += 1 - const host = latestUrl.hostname.includes(':') - ? `[${latestUrl.hostname}]` - : latestUrl.hostname - return `${latestUrl.protocol}//${host}:${port}` + while (port === runtimePort || usedPorts.has(port)) port += 1 + const host = runtime.hostname.includes(':') ? `[${runtime.hostname}]` : runtime.hostname + return `${runtime.protocol}//${host}:${port}` } catch { - return DEFAULT_HARDWARE_URL + return 'http://192.168.1.87:8765' } } -function hardwareUrlError(value: string): string | null { +function hardwareUrlError(value: string, runtimeUrl = DEFAULT_RUNTIME_URL): string | null { let parsed: URL try { parsed = new URL(value.trim()) @@ -60,95 +66,259 @@ function hardwareUrlError(value: string): string | null { if (!parsed.port) { return 'Include the hardware service port printed by pair.sh.' } - if (Number(parsed.port) === RUNTIME_PORT) { - return 'Port 8766 is the shared Blacknode runtime. Use the robot hardware port printed by pair.sh, such as 8765 or 8767.' - } - return null -} - -function runtimeUrlForHardware(value: string): string { + let runtimePort = RUNTIME_PORT try { - const parsed = new URL(value.trim()) - parsed.port = String(RUNTIME_PORT) - parsed.pathname = '' - parsed.search = '' - parsed.hash = '' - return parsed.toString().replace(/\/$/, '') + const runtime = new URL(runtimeUrl) + runtimePort = Number(runtime.port || (runtime.protocol === 'https:' ? 443 : 80)) } catch { - return '' + // Keep the default runtime port in the validation message. + } + if (Number(parsed.port) === runtimePort) { + return `Port ${runtimePort} is this device runtime. Choose a different robot hardware port.` } + return null } export default function DevicesPanel() { - const [devices, setDevices] = useState([]) - const [states, setStates] = useState>({}) - const [showForm, setShowForm] = useState(false) - const [name, setName] = useState('') - const [baseUrl, setBaseUrl] = useState(DEFAULT_HARDWARE_URL) - const [token, setToken] = useState('') + const activeProject = useStore(state => state.activeProject) + const setActiveProject = useStore(state => state.setActiveProject) + const [devices, setDevices] = useState([]) + const [deviceStates, setDeviceStates] = useState>({}) + const [robotStates, setRobotStates] = useState>({}) + const [selectedDeviceId, setSelectedDeviceId] = useState(null) + const [showDeviceForm, setShowDeviceForm] = useState(false) + const [setupMode, setSetupMode] = useState<'automatic' | 'manual'>('automatic') + const [deviceName, setDeviceName] = useState('') + const [runtimeUrl, setRuntimeUrl] = useState(DEFAULT_RUNTIME_URL) const [runtimeToken, setRuntimeToken] = useState('') - const [changeRuntimeToken, setChangeRuntimeToken] = useState(false) + const [sshHost, setSshHost] = useState(DEFAULT_SSH_HOST) + const [sshPort, setSshPort] = useState(22) + const [sshUsername, setSshUsername] = useState('') + const [sshPassword, setSshPassword] = useState('') + const [sshProbe, setSshProbe] = useState(null) + const [sshInspection, setSshInspection] = useState(null) + const [installAction, setInstallAction] = useState('install') + const [installInstanceId, setInstallInstanceId] = useState('default') + const [installProgress, setInstallProgress] = useState(null) + const [actionProgress, setActionProgress] = useState>({}) + const [showRuntimeControl, setShowRuntimeControl] = useState(false) + const [runtimeControlPassword, setRuntimeControlPassword] = useState('') + const [showUninstallForm, setShowUninstallForm] = useState(false) + const [uninstallPassword, setUninstallPassword] = useState('') + const [showRobotForm, setShowRobotForm] = useState(false) + const [robotName, setRobotName] = useState('') + const [robotUrl, setRobotUrl] = useState('') + const [robotToken, setRobotToken] = useState('') const [busy, setBusy] = useState(false) const [error, setError] = useState(null) + const [linkedProjectName, setLinkedProjectName] = useState('') - const refreshStatus = async (device: HardwareDevice) => { - setStates(prev => ({ ...prev, [device.id]: { ...prev[device.id], loading: true } })) - const [hardwareResult, runtimeResult] = await Promise.allSettled([ - api.deviceStatus(device.id), - api.deviceRuntimeStatus(device.id), - ]) - setStates(prev => ({ - ...prev, - [device.id]: { - status: hardwareResult.status === 'fulfilled' ? hardwareResult.value : undefined, - runtime: runtimeResult.status === 'fulfilled' - ? runtimeResult.value - : { - ok: false, - runtime_url: device.runtime_url, - error: runtimeResult.reason instanceof Error - ? runtimeResult.reason.message - : String(runtimeResult.reason), - }, - error: hardwareResult.status === 'rejected' - ? ( - hardwareResult.reason instanceof Error - ? hardwareResult.reason.message - : String(hardwareResult.reason) - ) - : undefined, - loading: false, - checkedAt: Date.now(), - }, + const selectedDevice = devices.find(device => device.id === selectedDeviceId) ?? null + const selectedDeviceState = selectedDevice + ? deviceStates[selectedDevice.id] + : undefined + + const refreshRobot = async (robot: HardwareDevice) => { + setRobotStates(previous => ({ + ...previous, + [robot.id]: { ...previous[robot.id], loading: true }, })) + try { + const status = await api.deviceStatus(robot.id) + setRobotStates(previous => ({ + ...previous, + [robot.id]: { status, loading: false, checkedAt: Date.now() }, + })) + } catch (reason) { + setRobotStates(previous => ({ + ...previous, + [robot.id]: { + loading: false, + checkedAt: Date.now(), + error: reason instanceof Error ? reason.message : String(reason), + }, + })) + } + } + + const refreshDevice = async (device: ComputeDevice) => { + setDeviceStates(previous => ({ + ...previous, + [device.id]: { ...previous[device.id], loading: true }, + })) + try { + const runtime = await api.computeDeviceRuntimeStatus(device.id) + setDeviceStates(previous => ({ + ...previous, + [device.id]: { runtime, loading: false, checkedAt: Date.now() }, + })) + } catch (reason) { + setDeviceStates(previous => ({ + ...previous, + [device.id]: { + runtime: { + ok: false, + runtime_url: device.runtime_url, + error: reason instanceof Error ? reason.message : String(reason), + }, + loading: false, + checkedAt: Date.now(), + }, + })) + } } const refresh = async () => { setError(null) try { - const result = await api.listDevices() + const result = await api.listComputeDevices() setDevices(result.devices) - await Promise.all(result.devices.map(refreshStatus)) - } catch (err) { - setError(err instanceof Error ? err.message : String(err)) + setSelectedDeviceId(current => ( + current && result.devices.some(device => device.id === current) + ? current + : null + )) + await Promise.all([ + ...result.devices.map(refreshDevice), + ...result.devices.flatMap(device => device.robots.map(refreshRobot)), + ]) + } catch (reason) { + setError(reason instanceof Error ? reason.message : String(reason)) } } - useEffect(() => { refresh() }, []) + useEffect(() => { + void refresh() + }, []) - const openPairForm = (device?: HardwareDevice) => { - setName(device?.name ?? '') - setBaseUrl(device?.base_url ?? suggestedHardwareUrl(devices)) - setToken('') + const resetDeviceForm = () => { + setShowDeviceForm(false) + setDeviceName('') setRuntimeToken('') - setChangeRuntimeToken(false) + setSshPassword('') + setSshProbe(null) + setSshInspection(null) + setInstallAction('install') + setInstallInstanceId('default') + setInstallProgress(null) + } + + const manualPair = async (event: FormEvent) => { + event.preventDefault() + setBusy(true) + setError(null) + try { + const result = await api.pairComputeDevice( + deviceName.trim(), + runtimeUrl.trim(), + runtimeToken.trim(), + ) + resetDeviceForm() + setSelectedDeviceId(result.device.id) + await refresh() + } catch (reason) { + setError(reason instanceof Error ? reason.message : String(reason)) + } finally { + setBusy(false) + } + } + + const probeSsh = async () => { + setBusy(true) setError(null) - setShowForm(true) + setSshProbe(null) + setSshInspection(null) + try { + setSshProbe(await api.probeComputeDeviceSsh( + sshHost.trim(), + sshPort, + )) + } catch (reason) { + setError(reason instanceof Error ? reason.message : String(reason)) + } finally { + setBusy(false) + } } - const pair = async (event: FormEvent) => { + const automaticInstall = async (event: FormEvent) => { event.preventDefault() - const urlError = hardwareUrlError(baseUrl) + if (!sshProbe) { + await probeSsh() + return + } + setBusy(true) + setError(null) + try { + if (!sshInspection) { + setInstallProgress(null) + const inspection = await api.inspectComputeDeviceSsh( + sshHost.trim(), + sshPort, + sshUsername.trim(), + sshPassword, + sshProbe.host_fingerprint, + ) + setSshInspection(inspection) + const reusable = inspection.instances.find(instance => instance.healthy) + if (reusable) { + setInstallAction('reuse') + setInstallInstanceId(reusable.instance_id) + } else if (inspection.instances.length) { + setInstallAction('replace') + setInstallInstanceId(inspection.instances[0].instance_id) + } else { + setInstallAction('install') + setInstallInstanceId('default') + } + return + } + setInstallProgress({ progress: 1, message: 'Starting secure installation' }) + const result = await api.installComputeDevice( + deviceName.trim(), + sshHost.trim(), + sshPort, + sshUsername.trim(), + sshPassword, + sshProbe.host_fingerprint, + installAction, + installInstanceId, + setInstallProgress, + ) + resetDeviceForm() + setSelectedDeviceId(result.device.id) + await refresh() + } catch (reason) { + const message = reason instanceof Error ? reason.message : String(reason) + if ( + message.toLowerCase().includes('authentication failed') + || message.toLowerCase().includes('ssh login was rejected') + ) { + setSshInspection(null) + setInstallProgress(null) + setError( + `${message} The confirmed host key is still valid; correct the login and press Confirm and inspect again.`, + ) + } else { + setError(message) + } + } finally { + setBusy(false) + } + } + + const openRobotForm = (device: ComputeDevice) => { + setSelectedDeviceId(device.id) + setRobotName('') + setRobotUrl(suggestedHardwareUrl(device)) + setRobotToken('') + setError(null) + setShowRobotForm(true) + } + + const addRobot = async (event: FormEvent) => { + event.preventDefault() + if (!selectedDevice) return + const urlError = hardwareUrlError(robotUrl, selectedDevice.runtime_url) if (urlError) { setError(urlError) return @@ -156,94 +326,262 @@ export default function DevicesPanel() { setBusy(true) setError(null) try { - const result = await api.pairDevice( - name.trim(), - baseUrl.trim(), - token.trim(), - runtimeToken.trim(), + const result = await api.pairRobot( + selectedDevice.id, + robotName.trim(), + robotUrl.trim(), + robotToken.trim(), ) - setShowForm(false) - setToken('') - setRuntimeToken('') - setChangeRuntimeToken(false) - const listed = await api.listDevices() - setDevices(listed.devices) - setStates(prev => ({ - ...prev, - [result.device.id]: { + setShowRobotForm(false) + setRobotToken('') + setRobotStates(previous => ({ + ...previous, + [result.robot.id]: { status: result.status, - runtime: result.runtime, loading: false, checkedAt: Date.now(), }, })) - } catch (err) { - setError(err instanceof Error ? err.message : String(err)) + await refresh() + if (activeProject && !activeProject.deviceIds.includes(result.robot.id)) { + try { + const project = await api.updateProject(activeProject.id, { + device_ids: [...activeProject.deviceIds, result.robot.id], + }) + setActiveProject({ + id: project.id, + name: project.name, + workflowSlugs: project.workflow_slugs, + deviceIds: project.device_ids, + }) + setLinkedProjectName(project.name) + } catch (projectError) { + setError( + `The robot was added, but it could not be linked to “${activeProject.name}”: ${ + projectError instanceof Error ? projectError.message : String(projectError) + }`, + ) + } + } + } catch (reason) { + setError(reason instanceof Error ? reason.message : String(reason)) } finally { setBusy(false) } } - const remove = async (device: HardwareDevice) => { - if (!window.confirm(`Remove "${device.name}" from this Blacknode editor?`)) return + const renameDevice = async (device: ComputeDevice) => { + const nextName = window.prompt('Name this device', device.name) + if (nextName === null || nextName.trim() === device.name) return setBusy(true) setError(null) try { - await api.deleteDevice(device.id) - setDevices(prev => prev.filter(item => item.id !== device.id)) - setStates(prev => { - const next = { ...prev } - delete next[device.id] - return next - }) - } catch (err) { - setError(err instanceof Error ? err.message : String(err)) + await api.renameComputeDevice(device.id, nextName.trim()) + await refresh() + } catch (reason) { + setError(reason instanceof Error ? reason.message : String(reason)) } finally { setBusy(false) } } - const rename = async (device: HardwareDevice) => { - const name = window.prompt('Name this robot', device.name) - if (name === null || name.trim() === device.name) return + const removeDevice = async (device: ComputeDevice) => { + if (device.robots.length) { + setError('Remove the robots from this device first. Saved deployment targets stay intact until you choose to remove them.') + return + } + if (!window.confirm(`Remove "${device.name}" from this Blacknode editor?`)) return setBusy(true) setError(null) try { - const result = await api.renameDevice(device.id, name.trim()) - setDevices(prev => prev.map(item => ( - item.id === device.id ? result.device : item - ))) - } catch (err) { - setError(err instanceof Error ? err.message : String(err)) + await api.deleteComputeDevice(device.id) + await refresh() + } catch (reason) { + setError(reason instanceof Error ? reason.message : String(reason)) } finally { setBusy(false) } } - const stopDeployment = async (device: HardwareDevice, deploymentId: string) => { + const uninstallDevice = async (device: ComputeDevice) => { + if (!uninstallPassword) { + setError('Enter the SSH password to uninstall this managed runtime.') + return + } if (!window.confirm( - `Stop the deployment using "${device.name}" and return control to Devices?`, + `Uninstall "${device.name}" from the remote computer? This stops its runtime, removes its service, files, token, firewall rule, attached robot registrations, and this device card.`, )) return setBusy(true) setError(null) try { - await api.stopRemoteDeployment(device.id, deploymentId) - await refreshStatus(device) - } catch (err) { - setError(err instanceof Error ? err.message : String(err)) + await api.uninstallComputeDevice(device.id, uninstallPassword) + setShowUninstallForm(false) + setUninstallPassword('') + setSelectedDeviceId(null) + await refresh() + } catch (reason) { + setError(reason instanceof Error ? reason.message : String(reason)) } finally { setBusy(false) } } - const pairUrlError = showForm ? hardwareUrlError(baseUrl) : null - const pairRuntimeUrl = runtimeUrlForHardware(baseUrl) - const reusableRuntimeDevice = devices.find(device => ( - device.runtime_url === pairRuntimeUrl - && device.runtime_token_configured - && states[device.id]?.runtime?.ok - )) - const reuseRuntimeToken = Boolean(reusableRuntimeDevice) && !changeRuntimeToken + const controlDevice = async (device: ComputeDevice, action: 'pause' | 'resume') => { + if (!runtimeControlPassword) { + setError(`Enter the SSH password to ${action} this managed device.`) + return + } + if (action === 'pause' && !window.confirm( + `Pause "${device.name}"? Running deployments will stop, attached robots will be stopped and disarmed, and the runtime service will stop.`, + )) return + setBusy(true) + setError(null) + setActionProgress(previous => ({ + ...previous, + [device.id]: { + progress: 1, + message: action === 'pause' ? 'Starting device pause' : 'Starting device restore', + }, + })) + try { + const result = await api.controlComputeDevice( + device.id, + action, + runtimeControlPassword, + progress => setActionProgress(previous => ({ + ...previous, + [device.id]: progress, + })), + ) + setActionProgress(previous => ({ + ...previous, + [device.id]: { + progress: 100, + message: result.warnings.length + ? `${result.summary} ${result.warnings.join(' ')}` + : result.summary, + }, + })) + setShowRuntimeControl(false) + setRuntimeControlPassword('') + await refresh() + } catch (reason) { + const message = reason instanceof Error ? reason.message : String(reason) + setActionProgress(previous => ({ + ...previous, + [device.id]: { progress: 0, message: `Device action failed: ${message}` }, + })) + setError(message) + } finally { + setBusy(false) + } + } + + const controlRobot = async ( + robot: HardwareDevice, + action: 'pause' | 'resume' | 'restart', + password = '', + ): Promise => { + if (action === 'pause' && !window.confirm( + `Pause "${robot.name}"? Its active deployment will stop and the robot will be stopped and disarmed.`, + )) return false + setBusy(true) + setError(null) + setActionProgress(previous => ({ + ...previous, + [robot.id]: { + progress: 1, + message: action === 'pause' + ? 'Starting robot pause' + : action === 'resume' + ? 'Starting robot restore' + : 'Starting robot service restart', + }, + })) + try { + const result = await api.controlRobot( + robot.id, + action, + progress => setActionProgress(previous => ({ + ...previous, + [robot.id]: progress, + })), + password, + ) + setActionProgress(previous => ({ + ...previous, + [robot.id]: { + progress: 100, + message: result.warnings.length + ? `${result.summary} ${result.warnings.join(' ')}` + : result.summary, + }, + })) + setRobotStates(previous => ({ + ...previous, + [robot.id]: { + status: result.status, + loading: false, + checkedAt: Date.now(), + }, + })) + await refresh() + return true + } catch (reason) { + const message = reason instanceof Error ? reason.message : String(reason) + setActionProgress(previous => ({ + ...previous, + [robot.id]: { progress: 0, message: `Robot action failed: ${message}` }, + })) + setError(message) + return false + } finally { + setBusy(false) + } + } + + const renameRobot = async (robot: HardwareDevice) => { + const nextName = window.prompt('Name this robot', robot.name) + if (nextName === null || nextName.trim() === robot.name) return + setBusy(true) + setError(null) + try { + await api.renameDevice(robot.id, nextName.trim()) + await refresh() + } catch (reason) { + setError(reason instanceof Error ? reason.message : String(reason)) + } finally { + setBusy(false) + } + } + + const removeRobot = async (robot: HardwareDevice) => { + if (!window.confirm(`Remove robot "${robot.name}" from this device?`)) return + setBusy(true) + setError(null) + try { + await api.deleteDevice(robot.id) + await refresh() + } catch (reason) { + setError(reason instanceof Error ? reason.message : String(reason)) + } finally { + setBusy(false) + } + } + + const stopDeployment = async (robot: HardwareDevice, deploymentId: string) => { + if (!window.confirm(`Stop the deployment using "${robot.name}"?`)) return + setBusy(true) + setError(null) + try { + await api.stopRemoteDeployment(robot.id, deploymentId) + await refreshRobot(robot) + } catch (reason) { + setError(reason instanceof Error ? reason.message : String(reason)) + } finally { + setBusy(false) + } + } return (
@@ -251,306 +589,1081 @@ export default function DevicesPanel() {
Devices
- {devices.length} paired {devices.length === 1 ? 'device' : 'devices'} + {devices.length} {devices.length === 1 ? 'computer' : 'computers'} ·{' '} + {devices.reduce((count, device) => count + device.robots.length, 0)} robots
- +
- {showForm && ( -
-
Pair hardware service
-
- On the hardware computer, run ./pair.sh --all --show, then copy - one robot’s name, complete hardware URL, and token. The computer runtime - on port 8766 is connected once and reused for its other robots. + {showDeviceForm && ( + +
Add a compute device
+

+ A device runs Blacknode Runtime. Add its robot hardware after the runtime is ready. +

+
+ +
- - {pairUrlError && ( -
- {pairUrlError} -
+ + {setupMode === 'automatic' ? ( + <> +
+ 1 +
+ Connect over SSH +

+ Blacknode verifies the computer, checks existing runtimes and occupied + ports, then lets you reuse, replace, or install an independent instance. +

+
+
+
+ + + + +
+
+ The SSH password is used for this setup only. It is not saved by Blacknode. +
+ {sshProbe && !sshInspection && ( +
+ Confirm this device + {sshProbe.hostname} · SSH host key + {sshProbe.host_fingerprint} +

+ Confirm this fingerprint belongs to your device before inspecting it. + A changed key is blocked automatically. +

+
+ )} + {sshInspection && ( +
+
+ Runtime setup + + {sshInspection.instances.length + ? `${sshInspection.instances.length} existing ${sshInspection.instances.length === 1 ? 'installation' : 'installations'} found` + : `No existing runtime found · port ${sshInspection.suggested_port} is available`} + +
+ {sshInspection.environment && ( +
+
+
+ Host environment + Read-only inspection · existing system versions are preserved +
+ Preserved +
+
+
+
+
+
+
+
+
+
+
+

+ Runtime setup may install missing {sshInspection.environment.runtime_setup_packages.join(', ')}. + It does not replace NVIDIA drivers, CUDA, ROS 2, or Docker. +

+
+ )} +
+ {sshInspection.instances.flatMap(instance => [ + , + , + ])} + {sshInspection.instances.length === 0 && ( + + )} + {sshInspection.instances.length > 0 && ( + + )} +
+
+ )} + + ) : ( + <> +
+ 1 +
+ Install the runtime on the device +

+ Run the Blacknode runtime installer on Ubuntu, Jetson, or Raspberry Pi, + then print its pairing details with ./service.sh pairing. +

+
+
+ + + )} - - {reuseRuntimeToken ? ( -
+ + {installProgress && setupMode === 'automatic' && ( +
- Computer runtime already connected - - {reusableRuntimeDevice?.runtime_url} will be reused automatically. - Only this robot’s hardware token is needed. - + {installProgress.message} + {installProgress.progress}%
+
+ )} + +
+ + {setupMode === 'automatic' && !sshProbe ? ( + + ) : ( + + )} +
+ + )} + + {error &&
{error}
} + {linkedProjectName && ( +
+
+ Robot connected + It was linked to “{linkedProjectName}” and is ready for deployment setup. +
+ +
+ )} + + {!selectedDevice && ( + devices.length === 0 && !showDeviceForm && !error ? ( +
+ 1 + Add the computer that will run your robots +

+ Install or pair Blacknode Runtime first. Then attach one or more robots + and deploy a workflow to the robot you choose. +

+ +
+ ) : ( +
+ {devices.map(device => ( + { + setSelectedDeviceId(device.id) + setShowDeviceForm(false) + setShowRobotForm(false) + }} + /> + ))} +
+ ) + )} + + {selectedDevice && ( +
+
+
+ Compute device + {selectedDevice.name} + {selectedDevice.runtime_url}
- ) : ( - <> +
+ + + {selectedDevice.managed_runtime && ( + <> + + + + )} + +
+
+ + {showRuntimeControl && selectedDevice.managed_runtime && ( +
{ + event.preventDefault() + void controlDevice( + selectedDevice, + selectedDevice.paused ? 'resume' : 'pause', + ) + }} + > +
+ + {selectedDevice.paused ? 'Resume this device' : 'Pause this device safely'} + + + {selectedDevice.paused + ? 'Starts this runtime and reconnects attached robot monitoring. Robots remain disarmed; previous deployments stay stopped until you start them again.' + : 'Stops deployments, stops and disarms attached robots, then stops the runtime service.'} + +
-
- Enter this once for port 8766. Future robots on this computer reuse - it automatically. Leave it empty only when runtime intentionally - shares the hardware token. +
+ +
- + )} -
+ + {actionProgress[selectedDevice.id] && ( + + )} + + {showUninstallForm && selectedDevice.managed_runtime && ( +
{ + event.preventDefault() + void uninstallDevice(selectedDevice) + }} + > +
+ Uninstall this managed runtime + + Stops {selectedDevice.managed_runtime.service_name}, removes only + {' '}{selectedDevice.managed_runtime.runtime_dir}, its token and port + {' '}{selectedDevice.managed_runtime.runtime_port} firewall rule. Other runtime + instances on this computer stay untouched. + +
+ +
+ + +
+ + )} + +
+
+ +
+
+ Robots + + {selectedDevice.robots.length + ? `${selectedDevice.robots.length} attached to this device` + : 'Add the robot hardware connected to this device'} + +
-
- - )} - {error &&
{error}
} + {showRobotForm && ( +
+
Add robot to {selectedDevice.name}
+ + + {hardwareUrlError(robotUrl) && ( +
+ {hardwareUrlError(robotUrl)} +
+ )} + +
+ This pairs hardware only. Deployments automatically use the runtime on{' '} + {selectedDevice.name}. +
+
+ + +
+ + )} -
- {devices.length === 0 && !showForm && !error && ( -
- No hardware device is paired. Run ./pair.sh on the device, - then add its network address and token here. +
+ {selectedDevice.robots.length === 0 && !showRobotForm && ( +
+ This device is ready. Add a robot to create a deployment target. +
+ )} + {selectedDevice.robots.map(robot => ( + refreshRobot(robot)} + onRename={() => renameRobot(robot)} + onRemove={() => removeRobot(robot)} + onStopDeployment={deploymentId => stopDeployment(robot, deploymentId)} + onControl={action => controlRobot(robot, action)} + onRestartService={password => controlRobot(robot, 'restart', password)} + /> + ))}
- )} - {devices.map(device => ( - refreshStatus(device)} - onRename={() => rename(device)} - onRepair={() => openPairForm(device)} - onRemove={() => remove(device)} - onStopDeployment={deploymentId => stopDeployment(device, deploymentId)} - /> - ))} -
+
+ )}
) } -function DeviceRow({ +function ComputeDeviceCard({ device, state, + selected, + onSelect, +}: { + device: ComputeDevice + state?: DeviceState + selected: boolean + onSelect: () => void +}) { + const paused = Boolean(device.paused || state?.runtime?.paused) + const ready = !paused && state?.runtime?.ok === true + const label = state?.loading ? 'CHECKING' : paused ? 'PAUSED' : ready ? 'READY' : 'ATTENTION' + const color = paused ? 'var(--tx3)' : ready ? 'var(--ok)' : state?.runtime ? 'var(--warn)' : 'var(--tx3)' + return ( + + ) +} + +function RobotRow({ + robot, + state, + runtimeReady, + actionProgress, + canRestartService, + sshUsername, busy, onRefresh, onRename, - onRepair, onRemove, onStopDeployment, + onControl, + onRestartService, }: { - device: HardwareDevice - state?: DeviceState + robot: HardwareDevice + state?: RobotState + runtimeReady: boolean + actionProgress?: DeviceActionProgress + canRestartService: boolean + sshUsername?: string busy: boolean onRefresh: () => void onRename: () => void - onRepair: () => void onRemove: () => void onStopDeployment: (deploymentId: string) => void + onControl: (action: 'pause' | 'resume') => void + onRestartService: (password: string) => Promise }) { + const [expanded, setExpanded] = useState(false) + const [showRestart, setShowRestart] = useState(false) + const [restartPassword, setRestartPassword] = useState('') const status = state?.status - const runtime = state?.runtime - const serviceOnline = Boolean(status) - const connected = Boolean(status?.connected) const deploymentLease = status?.deployment_lease + const paused = Boolean(robot.paused || status?.paused) const leased = Boolean(status?.leased_to_deployment || deploymentLease) - const runtimeReady = runtime?.ok === true - const ready = leased ? runtimeReady : connected && runtimeReady - const color = leased - ? runtimeReady ? 'var(--ok)' : 'var(--warn)' - : ready - ? 'var(--ok)' - : serviceOnline || runtime + const connected = Boolean(status?.connected) + const armed = Boolean(status?.armed) + const ready = !paused && !armed && runtimeReady && (connected || leased) + const color = paused + ? 'var(--tx3)' + : armed ? 'var(--warn)' - : 'var(--err)' + : ready + ? 'var(--ok)' + : status + ? 'var(--warn)' + : 'var(--err)' const label = state?.loading ? 'CHECK' - : leased + : paused + ? 'PAUSED' + : deploymentLease ? 'ACTIVE' + : armed + ? 'ARMED' + : ready + ? 'CONNECTED' + : 'OFF' + const summary = state?.loading + ? 'Checking robot hardware…' + : paused + ? 'Stopped and disarmed' + : deploymentLease + ? `Deployment “${deploymentLease.name}” is running` + : armed + ? 'Robot motion is armed' : ready - ? 'READY' - : serviceOnline - ? 'NEEDS SETUP' - : 'OFF' - const recoveryHint = hardwareRecoveryHint(status?.error) + ? 'Hardware monitoring connected · Blacknode motion disarmed' + : status + ? 'Robot needs attention' + : 'Hardware service unavailable' return ( -
-
-
-
-
- {device.name} +
+ + {expanded && ( +
+
+ Hardware · {robot.base_url} + Deployment runtime · {robot.runtime_url}
-
{device.base_url}
-
runtime {device.runtime_url}
- {device.remote_device_id} - hardware token {device.token_fingerprint} - - runtime token {device.runtime_token_fingerprint || device.token_fingerprint} - + {robot.remote_device_id} + token {robot.token_fingerprint} {status?.joint_names && {status.joint_names.length} joints}
{state?.error && (
{state.error}
)} + {actionProgress && } {deploymentLease && (
Deployment controls this robot - - {status?.notice || ( - `“${deploymentLease.name}” is running. Device checks pause here ` - + 'to prevent two processes from opening the same hardware connection.' - )} - -
- )} - {status?.error && !deploymentLease && ( -
- {status.error} + {status?.notice || `“${deploymentLease.name}” is running.`}
)} - {runtime && !runtime.ok && ( - <> -
- Runtime: {runtime.error || 'Unavailable'} -
-
- On the device, run blacknode-runtime/service.sh pairing, - then choose Re-pair and paste its runtime token. -
- - )} - {recoveryHint &&
{recoveryHint}
} -
-
- {label} - - {state?.loading - ? 'Checking hardware and runtime…' - : deploymentLease - ? `“${deploymentLease.name}” controls this robot` - : ready - ? 'Hardware and runtime ready' - : serviceOnline - ? 'Hardware ready · runtime needs attention' - : 'Unavailable'} - -
-
-
-
- - - - - - -
-
- {deploymentLease?.id && ( +
+ + + + + +
+
+ {deploymentLease?.id && ( + + )} + + + + +
+ {showRestart && canRestartService && ( +
{ + event.preventDefault() + if (!window.confirm( + `Restart the hardware service for "${robot.name}"? The service will return with Blacknode motion disarmed.`, + )) return + void onRestartService(restartPassword).then(restarted => { + if (restarted) { + setShowRestart(false) + setRestartPassword('') + } + }) + }} + > +
+ Restart this robot service + + Blacknode resolves the exact systemd unit from hardware port + {' '}{new URL(robot.base_url).port}, restarts only that unit, and verifies + the same authenticated robot returns. + +
+ +
+ + +
+ )} - - - -
-
+ )}
) } -function hardwareRecoveryHint(error?: string): string | null { - if (!error) return null - const normalized = error.toLowerCase() - if (normalized.includes('no response from servo') || normalized.includes('no position response from servo')) { - return 'The latest check reached the device service, but the servo bus did not answer. Check servo power, bus wiring, the configured serial port, baud rate, and servo IDs. On the device, run ./probe.sh --servos 6.' - } - if (normalized.includes('leased') && normalized.includes('deployment')) { - return 'Device checks pause while a deployment owns this robot. If no deployment is running, restore the deployment runtime connection and refresh the status to recover the stale reservation.' - } - return null +function LifecycleProgress({ + value, + compact = false, +}: { + value: DeviceActionProgress + compact?: boolean +}) { + const failed = value.progress <= 0 && value.message.toLowerCase().includes('failed') + return ( +
+
+ {value.message} + {value.progress}% +
+
+ ) } function formatCheckedAt(checkedAt?: number): string { @@ -567,7 +1680,7 @@ function DeviceFact({ label, value, warn = false }: { label: string; value: stri } const miniButton: CSSProperties = { - background: 'transparent', + background: 'var(--lift)', border: '1px solid var(--line2)', color: 'var(--tx2)', padding: '3px 8px', @@ -579,8 +1692,10 @@ const miniButton: CSSProperties = { const primaryButton: CSSProperties = { ...miniButton, - borderColor: 'var(--ok)', - color: 'var(--ok)', + background: 'var(--action)', + borderColor: 'var(--action)', + color: 'var(--action-ink)', + fontWeight: 700, } const dangerButton: CSSProperties = { diff --git a/editor/src/components/Inspector.tsx b/editor/src/components/Inspector.tsx index e04221d..f11f8cd 100644 --- a/editor/src/components/Inspector.tsx +++ b/editor/src/components/Inspector.tsx @@ -33,7 +33,7 @@ function driverFor(type: string): keyof typeof DRIVER_TOKENS | null { if (type.startsWith('Telegram')) return 'Telegram' return null } -const TOP_BAR_H = 44 +const TOP_BAR_H = 52 const RAIL_W = 78 const PANEL_DEFAULT_W = 260 const PANEL_MIN_W = 200 @@ -451,9 +451,9 @@ export default function Inspector() { return (
- Select a node + Select a node on the canvas to view and edit its properties.
) } @@ -764,6 +764,8 @@ export default function Inspector() { {open && (
= { black: '#111827', blue: '#2563eb', cyan: '#06b6d4', - green: '#22c55e', + green: '#3ddc97', orange: '#f97316', pink: '#ec4899', purple: '#a855f7', diff --git a/editor/src/components/NodePalette.tsx b/editor/src/components/NodePalette.tsx index 9c00f1d..cfef4c2 100644 --- a/editor/src/components/NodePalette.tsx +++ b/editor/src/components/NodePalette.tsx @@ -28,7 +28,7 @@ interface PaletteGroup { name: string; color: string; subgroups: PaletteSubGroup type Tab = 'nodes' | 'projects' | 'templates' | 'workflows' | 'script' | 'runs' | 'runtime' | 'console' | 'deployments' | 'devices' | 'learned' | 'mcp' | 'packages' -const TOP_BAR_H = 44 +const TOP_BAR_H = 52 const RAIL_W = 78 const PANEL_DEFAULT_W = 240 const PANEL_MIN_W = 188 @@ -183,11 +183,7 @@ export default function NodePalette() { return Math.max(PANEL_MIN_W, Math.min(PANEL_MAX_W, viewportMax, width)) } - const selectTab = (tab: Tab) => { - if (activeTab === tab) { - setActiveTab(null) - return - } + const openPanel = (tab: Tab) => { setActiveTab(tab) if (tab === 'templates') { setTemplateSearch('') @@ -206,6 +202,14 @@ export default function NodePalette() { } } + const selectTab = (tab: Tab) => { + if (activeTab === tab) { + setActiveTab(null) + return + } + openPanel(tab) + } + const togglePanelExpanded = () => { if (panelExpanded) { setPanelWidth(clampPanelWidth(compactWidthRef.current)) @@ -231,6 +235,23 @@ export default function NodePalette() { if (activeTab === 'nodes') loadNodeTypes() }, [activeTab, loadNodeTypes]) + useEffect(() => { + document.documentElement.style.setProperty('--bn-palette-panel-width', `${panelWidth}px`) + return () => { + document.documentElement.style.removeProperty('--bn-palette-panel-width') + } + }, [panelWidth]) + + useEffect(() => { + const handleOpenPanel = (event: Event) => { + const tab = (event as CustomEvent<{ tab?: Tab }>).detail?.tab + if (!tab || !TABS.some(item => item.id === tab)) return + openPanel(tab) + } + window.addEventListener('blacknode:open-panel', handleOpenPanel) + return () => window.removeEventListener('blacknode:open-panel', handleOpenPanel) + }) + useEffect(() => { const handleViewportResize = () => { setPanelWidth(width => clampPanelWidth(width)) @@ -567,7 +588,7 @@ export default function NodePalette() { @@ -595,16 +616,16 @@ export default function NodePalette() { flexShrink: 0, }}> BN @@ -681,6 +702,8 @@ export default function NodePalette() { {activeTab && (
) { {liveInput && ( ● LIVE diff --git a/editor/src/components/PackagesPanel.tsx b/editor/src/components/PackagesPanel.tsx index 49684d6..1290119 100644 --- a/editor/src/components/PackagesPanel.tsx +++ b/editor/src/components/PackagesPanel.tsx @@ -16,7 +16,7 @@ const inputStyle: React.CSSProperties = { } const buttonStyle = (busy: boolean): React.CSSProperties => ({ - background: 'transparent', + background: 'var(--lift)', border: '1px solid var(--line2)', borderRadius: 5, color: 'var(--tx2)', @@ -311,7 +311,7 @@ export default function PackagesPanel() { @@ -373,7 +373,7 @@ export default function PackagesPanel() { width: 7, height: 7, borderRadius: '50%', - background: !pkg.ok ? 'var(--err)' : (hasWarnings || hasMissingNodes ? '#e0a000' : 'var(--ok)'), + background: !pkg.ok ? 'var(--err)' : (hasWarnings || hasMissingNodes ? 'var(--warn)' : 'var(--ok)'), flexShrink: 0, }} /> @@ -495,7 +495,7 @@ export default function PackagesPanel() {
)} {hasMissingNodes && ( -
+
missing: {pkg.missing_node_types.join(', ')}
)} @@ -527,7 +527,7 @@ export default function PackagesPanel() { padding: 8, background: 'var(--hover)', borderRadius: 5, - color: '#e0a000', + color: 'var(--warn)', fontSize: 12, fontFamily: 'var(--font-mono)', whiteSpace: 'pre-wrap', @@ -545,7 +545,7 @@ export default function PackagesPanel() { disabled={busy} title={setupTitle} style={hasWarnings - ? { ...buttonStyle(busy), color: '#e0a000', borderColor: '#e0a000' } + ? { ...buttonStyle(busy), color: 'var(--warn)', borderColor: 'var(--warn)' } : prereqsMet ? { ...buttonStyle(busy), color: 'var(--ok)', borderColor: 'var(--ok)' } : buttonStyle(busy)} diff --git a/editor/src/components/ProjectPanel.css b/editor/src/components/ProjectPanel.css index 2458a94..b4de50f 100644 --- a/editor/src/components/ProjectPanel.css +++ b/editor/src/components/ProjectPanel.css @@ -37,15 +37,15 @@ } .bn-project-panel button.primary { - border-color: var(--accent); - background: var(--accent); - color: #fff; + border-color: var(--action); + background: var(--action); + color: var(--action-ink); font-weight: 700; } .bn-project-panel button.danger-text { background: transparent; - color: #c87b78; + color: var(--err); } .bn-project-panel input, @@ -237,6 +237,37 @@ line-height: 1.5; } +.bn-project-onboarding-steps { + display: grid; + width: 100%; + max-width: 310px; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 6px; + margin: 0 0 18px; +} + +.bn-project-onboarding-steps span { + display: flex; + min-width: 0; + flex-direction: column; + align-items: center; + gap: 5px; + color: var(--tx3); + font-size: 11px; +} + +.bn-project-onboarding-steps b { + display: grid; + width: 24px; + height: 24px; + place-items: center; + border: 1px solid color-mix(in srgb, var(--accent) 45%, var(--line)); + border-radius: 50%; + background: color-mix(in srgb, var(--accent) 9%, var(--lift)); + color: var(--accent); + font-size: 11px; +} + .bn-project-link-button, .bn-project-refresh { border-color: transparent !important; @@ -250,10 +281,10 @@ .bn-project-error { margin: 10px 12px; padding: 9px 10px; - border: 1px solid color-mix(in srgb, #d16d6a 55%, var(--line)); + border: 1px solid color-mix(in srgb, var(--err) 55%, var(--line)); border-radius: 7px; - background: color-mix(in srgb, #d16d6a 10%, transparent); - color: #d98a87; + background: color-mix(in srgb, var(--err) 10%, transparent); + color: var(--err); line-height: 1.4; } @@ -327,6 +358,30 @@ line-height: 1.4; } +.bn-project-starter-note { + margin-top: 10px; + padding: 9px 10px; + border: 1px solid color-mix(in srgb, var(--accent) 35%, var(--line)); + border-radius: 7px; + background: color-mix(in srgb, var(--accent) 8%, var(--bg)); + color: var(--tx2); + font-size: 12px; + line-height: 1.45; +} + +.bn-project-create .bn-project-starter-note { + margin-top: 0; +} + +.bn-project-next button { + align-self: flex-start; + margin-top: 5px; + border-color: color-mix(in srgb, var(--accent) 55%, var(--line2)); + background: color-mix(in srgb, var(--accent) 12%, var(--lift)); + color: var(--tx1); + font-weight: 700; +} + .bn-project-section-title { align-items: baseline; margin-bottom: 10px; @@ -350,12 +405,27 @@ .bn-project-stage { display: flex; + width: 100%; min-width: 0; + min-height: 0; + align-items: flex-start; gap: 8px; padding: 8px; border: 1px solid var(--line); border-radius: 7px; background: var(--bg); + color: var(--tx1); + text-align: left; +} + +.bn-project-stage:hover { + background: color-mix(in srgb, var(--accent) 6%, var(--bg)); +} + +.bn-project-stage:focus-visible { + border-color: var(--accent); + outline: 2px solid color-mix(in srgb, var(--accent) 25%, transparent); + outline-offset: 1px; } .bn-project-stage-marker { @@ -382,8 +452,8 @@ } .bn-project-stage.waiting .bn-project-stage-marker { - background: color-mix(in srgb, #d0a14d 16%, var(--lift)); - color: #d0a14d; + background: color-mix(in srgb, var(--warn) 16%, var(--lift)); + color: var(--warn); } .bn-project-stage strong, @@ -476,6 +546,56 @@ color: color-mix(in srgb, var(--accent) 70%, var(--tx2)); } +.bn-project-stage .bn-project-stage-open { + margin-left: auto; + color: var(--tx3); + font-size: 18px; + line-height: 1; +} + +.bn-project-stage:hover .bn-project-stage-open { + color: var(--accent); +} + +.bn-project-resource-main strong .bn-project-artifact-kind { + display: inline-block; + margin: 0 7px 0 0; + padding: 2px 6px; + border-radius: 999px; + background: color-mix(in srgb, var(--accent) 12%, var(--lift)); + color: var(--accent); + font-size: 11px; + font-weight: 800; + text-transform: uppercase; + vertical-align: 1px; +} + +.bn-project-artifact-state { + align-self: center; + padding: 3px 7px; + border-radius: 999px; + background: var(--lift); + color: var(--tx3); + font-size: 11px; + font-weight: 800; + text-transform: uppercase; +} + +.bn-project-artifact-state.completed { + background: color-mix(in srgb, var(--ok) 14%, var(--lift)); + color: var(--ok); +} + +.bn-project-artifact-state.running { + background: color-mix(in srgb, var(--accent) 15%, var(--lift)); + color: var(--accent); +} + +.bn-project-artifact-state.failed { + background: color-mix(in srgb, var(--err) 15%, var(--lift)); + color: var(--err); +} + .bn-project-resource-actions { display: flex; flex-shrink: 0; @@ -514,8 +634,8 @@ } .bn-project-deployment-state.failed { - background: color-mix(in srgb, #d16d6a 15%, var(--lift)); - color: #d98a87; + background: color-mix(in srgb, var(--err) 15%, var(--lift)); + color: var(--err); } .bn-project-empty { diff --git a/editor/src/components/ProjectPanel.tsx b/editor/src/components/ProjectPanel.tsx index e023135..12b4202 100644 --- a/editor/src/components/ProjectPanel.tsx +++ b/editor/src/components/ProjectPanel.tsx @@ -3,6 +3,7 @@ import { api, type HardwareDevice, type Project, + type ProjectArtifact, type RemoteDeployment, } from '../api' import { useStore } from '../store' @@ -28,6 +29,16 @@ interface LifecycleItem { detail: string } +type StarterStage = 'collect' | 'train' | 'simulate' + +interface ProjectNextStep { + detail: string + label: string + workflowSlug: string + starterStage?: StarterStage + panel?: 'devices' | 'deployments' | 'templates' | 'workflows' +} + function errorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error) @@ -46,6 +57,37 @@ function formatUpdated(value: string): string { }) } +const ARTIFACT_LABELS: Record = { + dataset: 'Dataset', + training_run: 'Training run', + checkpoint: 'Checkpoint', + policy: 'Policy', + simulation_run: 'Simulation run', + evaluation: 'Evaluation', +} + +function artifactDetail(artifact: ProjectArtifact): string { + const metadata = artifact.metadata + if (artifact.artifact_type === 'dataset') { + const episodes = Number(metadata.episode_count ?? 0) + return `${episodes} episode${episodes === 1 ? '' : 's'} · ${artifact.provider}` + } + if (artifact.artifact_type === 'training_run') { + const step = Number(metadata.step ?? 0) + const steps = Number(metadata.steps ?? 0) + return steps > 0 + ? `${artifact.status} · step ${step} of ${steps}` + : `${artifact.status} · ${artifact.provider}` + } + if (artifact.artifact_type === 'evaluation') { + const frames = Number(metadata.frames ?? 0) + return frames > 0 + ? `${frames} frame${frames === 1 ? '' : 's'} evaluated` + : `${artifact.status} · ${artifact.provider}` + } + return `${artifact.status} · ${artifact.provider}` +} + export default function ProjectPanel() { const { @@ -53,6 +95,7 @@ export default function ProjectPanel() { activeTabId, activeProject, workflowRevision, + projectRevision, openWorkflowAsTab, setActiveProject, } = useStore() @@ -68,12 +111,14 @@ export default function ProjectPanel() { const [error, setError] = useState('') const [newName, setNewName] = useState('') const [newDescription, setNewDescription] = useState('') + const [newStarterKit, setNewStarterKit] = useState<'robot_learning' | ''>('') const [creating, setCreating] = useState(false) const [editing, setEditing] = useState(false) const [editName, setEditName] = useState('') const [editDescription, setEditDescription] = useState('') const [workflowToAdd, setWorkflowToAdd] = useState('') const [deviceToAdd, setDeviceToAdd] = useState('') + const [artifactPath, setArtifactPath] = useState('') const selected = projects.find(project => project.id === selectedId) ?? null @@ -103,7 +148,7 @@ export default function ProjectPanel() { useEffect(() => { void refresh() - }, [refresh, workflowRevision]) + }, [refresh, workflowRevision, projectRevision]) const deploymentDeviceKey = selected?.device_ids.join('|') ?? '' useEffect(() => { @@ -182,6 +227,7 @@ export default function ProjectPanel() { name, description: newDescription.trim(), workflow_slugs: activeTab?.slug ? [activeTab.slug] : [], + starter_kit: newStarterKit || null, active_workflow_slug: activeTab?.slug ?? null, }) setProjects(current => [project, ...current]) @@ -189,6 +235,7 @@ export default function ProjectPanel() { setCreating(false) setNewName('') setNewDescription('') + setNewStarterKit('') } catch (nextError) { setError(errorMessage(nextError)) } finally { @@ -294,6 +341,45 @@ export default function ProjectPanel() { }, `device:${deviceId}`) } + const enableRobotLearningStarter = async () => { + await updateSelected( + { starter_kit: 'robot_learning' }, + 'starter:enable', + ) + } + + const useCustomProjectSetup = async () => { + await updateSelected( + { starter_kit: null }, + 'starter:disable', + ) + } + + const addExistingArtifact = async () => { + if (!selected || !artifactPath.trim()) return + setBusy('artifact:add') + setError('') + try { + const result = await api.inspectProjectArtifact(selected.id, { + path: artifactPath.trim(), + workflow_slug: selected.active_workflow_slug, + }) + replaceProject(result.project) + setArtifactPath('') + } catch (nextError) { + setError(errorMessage(nextError)) + } finally { + setBusy('') + } + } + + const unlinkArtifact = async (artifactId: string) => { + if (!selected) return + await updateSelected({ + artifact_ids: selected.artifact_ids.filter(value => value !== artifactId), + }, `artifact:${artifactId}`) + } + const availableWorkflows = workflows.filter( workflow => !selected?.workflow_slugs.includes(workflow.slug), ) @@ -305,6 +391,27 @@ export default function ProjectPanel() { item.deployment.state === 'staged' || item.deployment.active_revision )) + const datasetArtifacts = selected?.artifacts.filter( + artifact => artifact.artifact_type === 'dataset' && artifact.exists, + ) ?? [] + const completedDatasets = datasetArtifacts.filter( + artifact => Number(artifact.metadata.episode_count ?? 0) > 0, + ) + const trainingArtifacts = selected?.artifacts.filter( + artifact => ['training_run', 'checkpoint', 'policy'].includes(artifact.artifact_type), + ) ?? [] + const policyArtifacts = trainingArtifacts.filter( + artifact => artifact.artifact_type === 'policy' && artifact.exists, + ) + const runningTraining = trainingArtifacts.filter( + artifact => artifact.status === 'running', + ) + const simulationArtifacts = selected?.artifacts.filter( + artifact => ['simulation_run', 'evaluation'].includes(artifact.artifact_type), + ) ?? [] + const completedSimulations = simulationArtifacts.filter( + artifact => artifact.status === 'completed' && artifact.exists, + ) const lifecycle = useMemo(() => { if (!selected) return [] @@ -315,26 +422,32 @@ export default function ProjectPanel() { const hasStage = (stage: 'collect' | 'train' | 'simulate') => ( existingWorkflows.some(workflow => workflow.stages.includes(stage)) ) - return [ - { - id: 'build', - label: 'Build', - state: existingWorkflows.length ? 'complete' : 'waiting', - detail: existingWorkflows.length - ? `${existingWorkflows.length} saved workflow${existingWorkflows.length === 1 ? '' : 's'} linked` - : 'Link a saved workflow', - }, + const setupStages: LifecycleItem[] = [ { id: 'connect', label: 'Connect', state: existingDevices.length ? 'complete' : 'waiting', detail: existingDevices.length ? `${existingDevices.length} paired device${existingDevices.length === 1 ? '' : 's'} linked` - : 'Link a paired device', + : 'Install the device runtime and pair a robot', + }, + { + id: 'build', + label: 'Build', + state: existingWorkflows.length + ? 'complete' + : selected.starter_kit + ? 'available' + : 'waiting', + detail: existingWorkflows.length + ? `${existingWorkflows.length} saved workflow${existingWorkflows.length === 1 ? '' : 's'} linked` + : selected.starter_kit + ? 'Starter is ready to create the first workflow' + : 'Choose a template or link a saved workflow', }, { id: 'calibrate', - label: 'Calibrate', + label: 'Configure', state: robotWorkflows.length === 0 ? 'optional' : calibratedWorkflows.length === robotWorkflows.length @@ -344,26 +457,79 @@ export default function ProjectPanel() { ? 'No linked robot workflow requires calibration' : calibratedWorkflows.length === robotWorkflows.length ? `Selected in ${calibratedWorkflows.length} robot workflow${calibratedWorkflows.length === 1 ? '' : 's'}` - : `Select calibration in ${robotWorkflows.length - calibratedWorkflows.length} robot workflow${robotWorkflows.length - calibratedWorkflows.length === 1 ? '' : 's'}`, + : `Click to select calibration in ${robotWorkflows.length - calibratedWorkflows.length} robot workflow${robotWorkflows.length - calibratedWorkflows.length === 1 ? '' : 's'}`, }, + ] + const learningConfigured = Boolean( + selected.starter_kit + || hasStage('collect') + || hasStage('train') + || hasStage('simulate') + || datasetArtifacts.length + || trainingArtifacts.length + || simulationArtifacts.length, + ) + const learningStages: LifecycleItem[] = learningConfigured ? [ { id: 'collect', label: 'Collect', - state: hasStage('collect') ? 'available' : 'optional', - detail: hasStage('collect') ? 'Dataset recording workflow available' : 'Not configured for this project', + state: completedDatasets.length + ? 'complete' + : selected.starter_kit || hasStage('collect') || datasetArtifacts.length + ? 'available' + : 'optional', + detail: completedDatasets.length + ? `${completedDatasets.reduce((sum, artifact) => sum + Number(artifact.metadata.episode_count ?? 0), 0)} recorded episode${completedDatasets.reduce((sum, artifact) => sum + Number(artifact.metadata.episode_count ?? 0), 0) === 1 ? '' : 's'}` + : datasetArtifacts.length + ? 'Dataset created; record the first episode' + : hasStage('collect') + ? 'Dataset recording workflow available' + : selected.starter_kit + ? 'Starter recording workflow ready' + : 'Not configured for this project', }, { id: 'train', label: 'Train', - state: hasStage('train') ? 'available' : 'optional', - detail: hasStage('train') ? 'Policy training workflow available' : 'Not configured for this project', + state: policyArtifacts.length + ? 'complete' + : selected.starter_kit || hasStage('train') || trainingArtifacts.length + ? 'available' + : 'optional', + detail: policyArtifacts.length + ? `${policyArtifacts.length} policy artifact${policyArtifacts.length === 1 ? '' : 's'} ready` + : runningTraining.length + ? `${runningTraining.length} training run${runningTraining.length === 1 ? '' : 's'} running` + : trainingArtifacts.length + ? 'Training evidence available; export a policy' + : hasStage('train') + ? 'Policy training workflow available' + : selected.starter_kit + ? 'Starter training workflow ready' + : 'Not configured for this project', }, { id: 'simulate', label: 'Simulate', - state: hasStage('simulate') ? 'available' : 'optional', - detail: hasStage('simulate') ? 'Simulation workflow available' : 'Not configured for this project', + state: completedSimulations.length + ? 'complete' + : selected.starter_kit || hasStage('simulate') || simulationArtifacts.length + ? 'available' + : 'optional', + detail: completedSimulations.length + ? `${completedSimulations.length} completed simulation result${completedSimulations.length === 1 ? '' : 's'}` + : simulationArtifacts.some(artifact => artifact.status === 'running') + ? 'Simulation is running' + : hasStage('simulate') + ? 'Simulation workflow available' + : selected.starter_kit + ? 'Starter simulation workflow ready' + : 'Not configured for this project', }, + ] : [] + return [ + ...setupStages, + ...learningStages, { id: 'deploy', label: 'Deploy', @@ -387,19 +553,308 @@ export default function ProjectPanel() { detail: runningDeployments.length ? 'Monitor the running robot deployment' : 'Waiting for a running deployment', }, ] - }, [selected, runningDeployments.length, stagedDeployments.length]) + }, [ + completedDatasets, + completedSimulations, + datasetArtifacts.length, + policyArtifacts.length, + runningDeployments.length, + runningTraining.length, + selected, + simulationArtifacts, + stagedDeployments.length, + trainingArtifacts.length, + ]) - const nextStep = useMemo(() => { - if (!selected) return '' + const nextStep = useMemo(() => { + if (!selected) return { detail: '', label: '', workflowSlug: '' } + const workflowFor = (stage: StarterStage) => { + const candidates = selected.workflows.filter( + workflow => workflow.exists && workflow.stages.includes(stage), + ) + return ( + candidates.find(workflow => !workflow.starter_stage) + ?? candidates[0] + ) + } const build = lifecycle.find(item => item.id === 'build') const connect = lifecycle.find(item => item.id === 'connect') const calibrate = lifecycle.find(item => item.id === 'calibrate') - if (build?.state === 'waiting') return 'Save a workflow, then link it to this project.' - if (connect?.state === 'waiting') return 'Pair a device in Devices, then link it here.' - if (calibrate?.state === 'waiting') return 'Open the robot workflow and select its matching calibration.' - if (runningDeployments.length) return 'Monitor the running deployment and robot state.' - return 'Open the project workflows, check setup, then deploy to a linked device.' - }, [lifecycle, runningDeployments.length, selected]) + if (connect?.state === 'waiting') { + return { + detail: 'Install Blacknode on the robot computer, then pair and verify it.', + label: 'Set up robot', + workflowSlug: '', + panel: 'devices', + } + } + if (build?.state === 'waiting') { + return { + detail: 'Start from a tested template or link a workflow you already saved.', + label: 'Choose workflow', + workflowSlug: '', + panel: 'templates', + } + } + if ( + selected.starter_kit === 'robot_learning' + && selected.workflows.every(workflow => !workflow.exists) + && completedDatasets.length === 0 + && policyArtifacts.length === 0 + ) { + return { + detail: 'Start with a ready-made workflow for recording robot demonstrations.', + label: 'Create recording workflow', + workflowSlug: '', + starterStage: 'collect', + } + } + if (completedDatasets.length > 0 && policyArtifacts.length === 0) { + const train = workflowFor('train') + if (!train && selected.starter_kit === 'robot_learning') { + return { + detail: 'Your recorded data is ready. Create the predefined ACT training workflow.', + label: 'Create training workflow', + workflowSlug: '', + starterStage: 'train', + } + } + if (train) { + return { + detail: runningTraining.length + ? 'Training is running. Open the workflow to monitor progress.' + : 'Recorded data is ready. Start or resume policy training.', + label: runningTraining.length ? 'Monitor training' : 'Open training workflow', + workflowSlug: train.slug, + } + } + } + if (policyArtifacts.length > 0 && completedSimulations.length === 0) { + const simulate = workflowFor('simulate') + if (!simulate && selected.starter_kit === 'robot_learning') { + return { + detail: 'Your policy is ready. Create the predefined Isaac evaluation workflow.', + label: 'Create simulation workflow', + workflowSlug: '', + starterStage: 'simulate', + } + } + if (simulate) { + return { + detail: 'A policy is ready. Run it in simulation and capture the result.', + label: 'Open simulation workflow', + workflowSlug: simulate.slug, + } + } + } + if (calibrate?.state === 'waiting') { + const workflow = selected.workflows.find(item => item.exists && item.requires_calibration) + return { + detail: 'Open the robot workflow and select its matching calibration.', + label: 'Open calibration workflow', + workflowSlug: workflow?.slug ?? '', + } + } + if (completedDatasets.length === 0 && policyArtifacts.length === 0) { + const collect = workflowFor('collect') + if (!collect && selected.starter_kit === 'robot_learning') { + return { + detail: 'Create the ready-made recording workflow and save the first episode.', + label: 'Create recording workflow', + workflowSlug: '', + starterStage: 'collect', + } + } + if (collect) { + return { + detail: datasetArtifacts.length + ? 'The dataset is ready. Record and save the first episode.' + : 'Create the dataset and record the first episode.', + label: 'Open recording workflow', + workflowSlug: collect.slug, + } + } + } + if (runningDeployments.length) { + return { + detail: 'Monitor the running deployment and robot state.', + label: 'Monitor deployment', + workflowSlug: '', + panel: 'deployments', + } + } + return { + detail: 'Open the project workflows, check setup, then deploy to a linked device.', + label: 'Open project', + workflowSlug: selected.active_workflow_slug ?? '', + } + }, [ + completedDatasets.length, + completedSimulations.length, + datasetArtifacts.length, + lifecycle, + policyArtifacts.length, + runningDeployments.length, + runningTraining.length, + selected, + ]) + + const runProjectStep = async (step: ProjectNextStep) => { + if (!selected) return + if (step.panel) { + setActiveProject({ + id: selected.id, + name: selected.name, + workflowSlugs: selected.workflow_slugs, + deviceIds: selected.device_ids, + }) + window.dispatchEvent(new CustomEvent('blacknode:open-panel', { + detail: { tab: step.panel }, + })) + return + } + setBusy('next') + setError('') + try { + let project = selected + let workflow = step.workflowSlug + ? selected.workflows.find(item => item.slug === step.workflowSlug) + : null + if (step.starterStage) { + const result = await api.createProjectStarterWorkflow( + selected.id, + step.starterStage, + ) + project = result.project + workflow = result.workflow + replaceProject(project) + setWorkflows(current => ( + current.some(item => item.slug === result.workflow.slug) + ? current + : [{ + slug: result.workflow.slug, + name: result.workflow.name, + saved_at: result.workflow.saved_at ?? '', + }, ...current] + )) + } + if (!workflow) return + setActiveProject({ + id: project.id, + name: project.name, + workflowSlugs: project.workflow_slugs, + deviceIds: project.device_ids, + }) + await openWorkflowAsTab(workflow.slug, workflow.name) + } catch (nextError) { + setError(errorMessage(nextError)) + } finally { + setBusy('') + } + } + + const openLifecycleStage = async (stageId: string) => { + if (!selected || busy) return + if (stageId === 'connect') { + setActiveProject({ + id: selected.id, + name: selected.name, + workflowSlugs: selected.workflow_slugs, + deviceIds: selected.device_ids, + }) + window.dispatchEvent(new CustomEvent('blacknode:open-panel', { + detail: { tab: 'devices' }, + })) + return + } + if (stageId === 'deploy' || stageId === 'operate') { + window.dispatchEvent(new CustomEvent('blacknode:open-panel', { + detail: { tab: 'deployments' }, + })) + return + } + if (stageId === 'build') { + if (selected.workflows.some(workflow => workflow.exists)) { + await openProject() + } else if (selected.starter_kit === 'robot_learning') { + await runProjectStep({ + detail: '', + label: '', + workflowSlug: '', + starterStage: 'collect', + }) + } else { + window.dispatchEvent(new CustomEvent('blacknode:open-panel', { + detail: { tab: 'workflows' }, + })) + } + return + } + if (stageId === 'calibrate') { + const robotWorkflows = selected.workflows.filter( + workflow => workflow.exists && workflow.requires_calibration, + ) + const workflow = ( + robotWorkflows.find(item => !item.calibration) + ?? robotWorkflows[0] + ) + if (workflow) { + await runProjectStep({ + detail: '', + label: '', + workflowSlug: workflow.slug, + }) + } else if (selected.starter_kit === 'robot_learning') { + await runProjectStep({ + detail: '', + label: '', + workflowSlug: '', + starterStage: 'collect', + }) + } else { + window.dispatchEvent(new CustomEvent('blacknode:notice', { + detail: { + kind: 'info', + title: 'No calibration needed', + message: 'No linked workflow currently requires robot calibration.', + }, + })) + } + return + } + if (['collect', 'train', 'simulate'].includes(stageId)) { + const stage = stageId as StarterStage + const candidates = selected.workflows.filter( + workflow => workflow.exists && workflow.stages.includes(stage), + ) + const workflow = ( + candidates.find(item => !item.starter_stage) + ?? candidates[0] + ) + if (workflow) { + await runProjectStep({ + detail: '', + label: '', + workflowSlug: workflow.slug, + }) + } else if (selected.starter_kit === 'robot_learning') { + await runProjectStep({ + detail: '', + label: '', + workflowSlug: '', + starterStage: stage, + }) + } else { + window.dispatchEvent(new CustomEvent('blacknode:notice', { + detail: { + kind: 'info', + title: `${stageId[0].toUpperCase()}${stageId.slice(1)} is not configured`, + message: 'Link a saved workflow for this stage or enable the robot learning starter.', + }, + })) + } + } + } if (selected) { return ( @@ -442,10 +897,37 @@ export default function ProjectPanel() {
{selected.description &&

{selected.description}

} + {selected.starter_kit === 'robot_learning' && ( +
+ Robot learning starter · Next creates the appropriate + predefined workflow when you have not linked your own. +
+ )}
+ {!selected.starter_kit && ( + + )} + {selected.starter_kit === 'robot_learning' && ( + + )}
@@ -455,7 +937,15 @@ export default function ProjectPanel() {
Next step - {nextStep} + {nextStep.detail} + {nextStep.label && (nextStep.workflowSlug || nextStep.starterStage || nextStep.panel) && ( + + )}
@@ -465,14 +955,86 @@ export default function ProjectPanel() {
{lifecycle.map((item, index) => ( -
+ + ))} +
+ + +
+
+

Artifacts

+ {selected.artifacts.length} linked +
+
+ Dataset, training, policy, and simulation outputs are captured + automatically when linked workflows run. +
+
+ setArtifactPath(event.target.value)} + onKeyDown={event => event.key === 'Enter' && void addExistingArtifact()} + placeholder="Path to an existing artifact or manifest" + /> + +
+
+ {selected.artifacts.map(artifact => ( +
+
+ + + {ARTIFACT_LABELS[artifact.artifact_type]} + + {artifact.name} + + {artifactDetail(artifact)} + + {artifact.exists ? artifact.locator : `Source unavailable · ${artifact.locator}`} + +
+
+ + {artifact.status} + + +
))} + {selected.artifacts.length === 0 && ( +
+ No artifacts linked yet. Run a linked collect, train, or + simulation workflow, or add an existing artifact path. +
+ )}
@@ -602,7 +1164,7 @@ export default function ProjectPanel() {

Projects

-

Group workflows, robots, and deployments.

+

Group workflows, robots, artifacts, and deployments.

+

Set up your first robot project

+

Blacknode guides you through connecting the robot, choosing a workflow, checking its setup, and deploying it.

+
+ 1 Connect + 2 Build + 3 Configure + 4 Deploy +
+
)} {projects.map(project => ( @@ -665,8 +1259,10 @@ export default function ProjectPanel() {
{project.description &&

{project.description}

}
+ {project.starter_kit === 'robot_learning' && Guided starter} {project.workflow_slugs.length} workflow{project.workflow_slugs.length === 1 ? '' : 's'} {project.device_ids.length} device{project.device_ids.length === 1 ? '' : 's'} + {project.artifact_ids.length} artifact{project.artifact_ids.length === 1 ? '' : 's'}
Updated {formatUpdated(project.updated_at)} diff --git a/editor/src/components/ScriptEditor.tsx b/editor/src/components/ScriptEditor.tsx index aa3f994..3bae65d 100644 --- a/editor/src/components/ScriptEditor.tsx +++ b/editor/src/components/ScriptEditor.tsx @@ -160,7 +160,7 @@ export default function ScriptEditor() { {status && (
) { : isBool ? (typeof value === 'string' ? !['false', '0', ''].includes(value.trim().toLowerCase()) : Boolean(value)) - : isColor ? normalizeHexColor(value) ?? '#22c55e' + : isColor ? normalizeHexColor(value) ?? '#3ddc97' : isList ? (typeof value === 'string' ? value : JSON.stringify(value ?? [], null, 2)) : isDict ? (typeof value === 'string' ? value : JSON.stringify(value ?? {}, null, 2)) : typeof value === 'string' || typeof value === 'number' ? value @@ -341,7 +341,7 @@ function ValueNode({ id, data, selected }: NodeProps) { ) : isColor ? ( e.stopPropagation()} onMouseDown={e => e.stopPropagation()} diff --git a/editor/src/index.css b/editor/src/index.css index 43f0666..c0a663b 100644 --- a/editor/src/index.css +++ b/editor/src/index.css @@ -2,58 +2,71 @@ :root { --font-ui: 'Inter', -apple-system, BlinkMacSystemFont, system-ui, sans-serif; --font-mono: 'JetBrains Mono', 'Fira Code', 'SF Mono', monospace; + --control-radius: 6px; + --action: rgb(61, 220, 151); + --action-ink: #103326; } /* ── Dark theme ────────────────────────────────── */ html[data-theme="dark"] { - --bg: #1a1a1a; + --bg: #181818; --panel: #202020; - --node: #353535; - --lift: #2a2a2a; - --hover: #2e2e2e; - --menu-active: #36363d; - --menu-hover: #323238; - --line: #2a2a2a; - --line2: #383838; + --node: #303030; + --lift: #272727; + --hover: #303030; + --menu-active: #383838; + --menu-hover: #333333; + --line: #2c2c2c; + --line2: #424242; --tx1: #ffffff; - --tx2: #fdfdfd; - --tx3: #b3b3b3; - --accent: #9899a8; + --tx2: #e4e4e4; + --tx3: #a8a8a8; + --accent: #8d8d8d; --learned: #a78bfa; - --save-pending: #75623c; - --ok: #22c55e; + --ok: rgb(61, 220, 151); --warn: #facc15; --err: #ef4444; - --rf-bg: #1a1a1a; - --rf-dot: #3a3a3a; - --rf-edge: #383838; + --ok-soft: #173b2f; + --warn-soft: #3b3218; + --err-soft: #3a1e1e; + --rf-bg: #1c1c1c; + --rf-dot: #686868; + --rf-edge: #4a4a4a; --rf-ctrl: #202020; + --tabbar: #242424; + --tab: #2d2d2d; + --tab-active: #3a3a3a; } /* ── Light theme ───────────────────────────────── */ html[data-theme="light"] { - --bg: #ecedf5; - --panel: #ffffff; + --bg: #ededed; + --panel: #f9f9f9; --node: #ffffff; - --lift: #f5f5fb; - --hover: #ededf6; - --menu-active: #e3e5f3; - --menu-hover: #e8e9f4; - --line: #dcdde9; - --line2: #bbbcce; - --tx1: #11121e; - --tx2: #3a3c60; - --tx3: #8082a8; - --accent: #6b7280; + --lift: #f2f2f2; + --hover: #e5e5e5; + --menu-active: #dddddd; + --menu-hover: #e4e4e4; + --line: #d4d4d4; + --line2: #b7b7b7; + --tx1: #171717; + --tx2: #404040; + --tx3: #737373; + --accent: #666666; --learned: #8b5cf6; - --save-pending: #8a7248; - --ok: #16a34a; + --ok: rgb(61, 220, 151); --warn: #ca8a04; --err: #dc2626; - --rf-bg: #ecedf5; - --rf-dot: #d0d1e0; - --rf-edge: #bbbcce; - --rf-ctrl: #ffffff; + --ok-soft: #e1f8ee; + --warn-soft: #f7f0dc; + --err-soft: #f8e6e6; + --rf-bg: #e2e2e2; + --rf-dot: #858585; + --rf-edge: #a9a9a9; + --rf-ctrl: #f9f9f9; + --tabbar: #f7f7f7; + --tab: #e8e8e8; + --tab-active: #ffffff; } /* ── Reset ─────────────────────────────────────── */ @@ -77,6 +90,42 @@ textarea { line-height: 1.35; } +button { + border-radius: var(--control-radius) !important; + transition: + transform .08s ease, + filter .08s ease, + box-shadow .08s ease, + opacity .12s ease; + -webkit-tap-highlight-color: transparent; +} + +button:active:not(:disabled) { + transform: translateY(1px) scale(.985); + filter: brightness(.84) saturate(.9); + box-shadow: inset 0 2px 4px color-mix(in srgb, var(--bg) 42%, transparent) !important; +} + +button:focus-visible { + outline: 2px solid color-mix(in srgb, var(--accent) 58%, transparent); + outline-offset: 2px; +} + +button:disabled { + cursor: not-allowed !important; + filter: saturate(.65); +} + +@media (prefers-reduced-motion: reduce) { + button { + transition: none; + } + + button:active:not(:disabled) { + transform: none; + } +} + .bn-brand { align-items: center; color: var(--tx2); @@ -90,12 +139,21 @@ textarea { .bn-brand-logo { display: block; + filter: none; height: 36px; object-fit: contain; width: 36px; } +html[data-theme="dark"] .bn-brand-logo { + filter: invert(1); +} + /* ── ReactFlow overrides ───────────────────────── */ +.react-flow { + background: var(--rf-bg); +} + .react-flow__controls { background: var(--rf-ctrl) !important; border: 1px solid var(--line2) !important; @@ -111,6 +169,7 @@ textarea { fill: var(--tx1) !important; width: 28px !important; height: 28px !important; + border-radius: 0 !important; } .react-flow__controls button svg { fill: var(--tx1) !important; color: var(--tx1) !important; } .react-flow__controls button:last-child { border-bottom: none !important; } @@ -122,6 +181,11 @@ textarea { border-radius: 8px !important; } +.react-flow__edge-path, +.react-flow__connection-path { + stroke: var(--rf-edge); +} + .react-flow__edge.selected .react-flow__edge-path { filter: brightness(1.5); stroke-width: 2.5px; } .react-flow__edge:hover .react-flow__edge-path { filter: brightness(1.3); } @@ -142,7 +206,7 @@ textarea { } .bn-top-button { - background: transparent; + background: var(--lift); border: 1px solid var(--line2); border-radius: 6px; color: var(--tx2); @@ -192,17 +256,17 @@ textarea { } .bn-top-run-button { - background: var(--ok); - border-color: var(--ok); - color: #fff; + background: var(--action); + border-color: var(--action); + color: var(--action-ink); font-weight: 700; min-width: 64px; } .bn-top-run-button:hover:not(:disabled) { - background: color-mix(in srgb, var(--ok) 86%, #000); - border-color: var(--ok); - color: #fff; + background: color-mix(in srgb, var(--action) 86%, #000); + border-color: var(--action); + color: var(--action-ink); } .bn-top-run-button:disabled { @@ -597,7 +661,20 @@ textarea { scrollbar-color: var(--line2) transparent; } -.bn-topbar > *:not(.bn-topbar-spacer) { +.bn-topbar > * { + flex-shrink: 0; +} + +.bn-topbar-controls { + align-items: center; + display: flex; + flex-shrink: 0; + gap: 10px; + height: 100%; + padding-right: 18px; +} + +.bn-topbar-controls > * { flex-shrink: 0; } @@ -608,13 +685,9 @@ textarea { } @media (max-width: 1180px) { - .bn-topbar { + .bn-topbar-controls { gap: 6px !important; - padding-inline: 10px !important; - } - - .bn-top-hint { - display: none; + padding-right: 10px; } .bn-top-button { @@ -697,6 +770,69 @@ textarea { font-weight: 650; } +.bn-device-setup-step { + display: grid; + grid-template-columns: 26px minmax(0, 1fr); + gap: 9px; + padding: 10px; + border: 1px solid var(--line); + border-radius: 7px; + background: var(--panel); +} + +.bn-device-setup-step > div { + min-width: 0; +} + +.bn-device-setup-number, +.bn-device-empty-icon { + display: grid; + width: 24px; + height: 24px; + place-items: center; + border-radius: 50%; + background: color-mix(in srgb, var(--accent) 16%, var(--lift)); + color: var(--accent); + font-size: 11px; + font-weight: 800; +} + +.bn-device-setup-step strong, +.bn-device-setup-step p { + display: block; +} + +.bn-device-setup-step strong { + color: var(--tx1); + font-size: 13px; +} + +.bn-device-setup-step p { + margin: 4px 0 8px; + color: var(--tx3); + font-size: 12px; + line-height: 1.45; +} + +.bn-device-setup-step p code { + color: var(--tx2); + font-family: var(--font-mono); +} + +.bn-device-setup-step pre { + overflow-x: auto; + margin: 0 0 8px; + padding: 8px; + border: 1px solid var(--line); + border-radius: 5px; + background: var(--bg); + color: var(--tx2); + font-family: var(--font-mono); + font-size: 11px; + line-height: 1.5; + white-space: pre; +} + .bn-device-help { color: var(--tx3); font-size: 13px; @@ -775,115 +911,1001 @@ textarea { gap: 6px; } -.bn-device-summary { - width: 100%; - display: grid; - grid-template-columns: 16px minmax(0, 1fr) auto; - align-items: stretch; - gap: 8px; - padding: 9px 10px 9px 6px; - color: var(--tx2); +.bn-device-install-progress { + display: flex; + flex-direction: column; + gap: 7px; + padding: 9px; + border: 1px solid var(--line2); + border-radius: var(--control-radius); + background: var(--panel); } -.bn-device-facts { - display: grid; - grid-template-columns: repeat(4, minmax(0, 1fr)); - gap: 6px; - margin-bottom: 10px; +.bn-device-install-progress > div { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + color: var(--tx2); + font-size: 12px; } -.bn-device-fact { - min-width: 0; - padding: 6px; - background: var(--panel); - border: 1px solid var(--line); - border-radius: 4px; +.bn-device-install-progress > div strong { + overflow-wrap: anywhere; + color: var(--tx1); + font-weight: 650; } -.bn-device-fact span, -.bn-device-fact strong { +.bn-device-install-progress-track { display: block; + height: 7px; overflow: hidden; - text-overflow: ellipsis; + border-radius: 999px; + background: var(--menu-active); } -.bn-device-fact span { - color: var(--tx3); - font-size: 11px; - text-transform: uppercase; +.bn-device-install-progress-track > span { + display: block; + height: 100%; + min-width: 3px; + border-radius: inherit; + background: var(--action); + transition: width .18s ease; } -.bn-device-fact strong { - margin-top: 3px; - color: var(--tx2); - font-family: var(--font-mono); +.bn-device-action-progress { + margin: 10px 0; +} + +.bn-device-action-progress.is-compact { + margin: 0 0 10px; + padding: 8px; +} + +.bn-device-action-progress.is-error { + border-color: var(--err); +} + +.bn-device-action-progress.is-error .bn-device-install-progress-track > span { + background: var(--err); +} + +.bn-device-mode-tabs { + display: grid; + grid-template-columns: 1fr 1fr; + padding: 3px; + border: 1px solid var(--line); + border-radius: 6px; + background: var(--panel); +} + +.bn-device-mode-tabs button { + min-height: 30px; + border: 0; + border-radius: 4px; + background: transparent; + color: var(--tx3); + cursor: pointer; + font-family: var(--font-ui); font-size: 12px; - white-space: nowrap; + font-weight: 650; } -@container devices-panel (max-width: 430px) { - .bn-devices-panel > .bn-runs-toolbar { - align-items: stretch; - flex-direction: column; - } +.bn-device-mode-tabs button.is-active { + background: var(--menu-active); + color: var(--tx1); + box-shadow: inset 0 0 0 1px var(--line2); +} - .bn-devices-panel > .bn-runs-toolbar .bn-runs-actions, - .bn-devices-panel .bn-run-detail-actions { - justify-content: flex-start; - } +.bn-device-ssh-grid { + display: grid; + grid-template-columns: minmax(0, 2fr) minmax(82px, .7fr); + gap: 9px; +} - .bn-devices-panel > .bn-runs-toolbar .bn-runs-actions button, - .bn-devices-panel .bn-run-detail-actions button, - .bn-device-form-actions button { - flex: 1 1 auto; - min-height: 30px; - } +.bn-device-fingerprint { + display: flex; + flex-direction: column; + gap: 4px; + padding: 9px; + border: 1px solid color-mix(in srgb, var(--ok) 45%, var(--line2)); + border-radius: 6px; + background: color-mix(in srgb, var(--ok) 7%, var(--panel)); +} - .bn-device-summary { - grid-template-columns: 16px minmax(0, 1fr); - } +.bn-device-fingerprint strong { + color: var(--tx1); + font-size: 13px; +} - .bn-device-summary .bn-run-status { - grid-column: 2; - align-items: flex-start; - white-space: normal; - } +.bn-device-fingerprint span, +.bn-device-fingerprint p { + color: var(--tx2); + font-size: 12px; + line-height: 1.45; +} - .bn-device-summary .bn-run-status > span:last-child { - overflow-wrap: anywhere; - } +.bn-device-fingerprint code { + overflow-wrap: anywhere; + color: var(--ok); + font-family: var(--font-mono); + font-size: 11px; +} - .bn-device-facts { - grid-template-columns: repeat(2, minmax(0, 1fr)); - } +.bn-device-fingerprint p { + margin: 2px 0 0; +} - .bn-devices-panel .bn-run-detail { - padding-left: 30px; - } +.bn-runtime-install-review, +.bn-runtime-uninstall { + display: flex; + flex-direction: column; + gap: 9px; + padding: 10px; + border: 1px solid var(--line2); + border-radius: 7px; + background: var(--panel); +} - .bn-devices-panel .bn-device-runtime-reuse { - align-items: stretch; - flex-direction: column; - } +.bn-runtime-install-review > div:first-child, +.bn-runtime-uninstall > div:first-child { + display: flex; + flex-direction: column; + gap: 3px; +} - .bn-devices-panel .bn-device-runtime-reuse button { - align-self: flex-start; - min-height: 30px; - } +.bn-runtime-install-review > div:first-child strong, +.bn-runtime-uninstall > div:first-child strong { + color: var(--tx1); + font-size: 13px; } -@container devices-panel (max-width: 280px) { - .bn-device-facts { - grid-template-columns: 1fr; - } +.bn-runtime-install-review > div:first-child span, +.bn-runtime-uninstall > div:first-child span { + color: var(--tx3); + font-size: 12px; + line-height: 1.45; +} - .bn-device-form-actions { - align-items: stretch; - flex-direction: column-reverse; - } +.bn-host-environment { + display: flex; + flex-direction: column; + gap: 8px; + padding: 9px; + border: 1px solid var(--line); + border-radius: var(--control-radius); + background: var(--lift); +} - .bn-devices-panel .bn-run-detail { - padding: 10px; +.bn-host-environment-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 8px; +} + +.bn-host-environment-head > div { + display: flex; + min-width: 0; + flex-direction: column; + gap: 2px; +} + +.bn-host-environment-head strong { + color: var(--tx1); + font-size: 12px; +} + +.bn-host-environment-head > div > span, +.bn-host-environment > p { + color: var(--tx3); + font-size: 11px; + line-height: 1.4; +} + +.bn-preserved-badge { + flex: 0 0 auto; + padding: 2px 6px; + border: 1px solid color-mix(in srgb, var(--ok) 55%, var(--line2)); + border-radius: 999px; + color: var(--ok); + font-size: 11px; + font-weight: 750; + letter-spacing: .025em; + text-transform: uppercase; +} + +.bn-host-environment-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 6px; +} + +.bn-host-environment-grid article { + display: grid; + grid-template-columns: 8px minmax(0, 1fr); + align-items: flex-start; + gap: 7px; + min-width: 0; + padding: 7px; + border: 1px solid var(--line); + border-radius: 5px; + background: var(--panel); +} + +.bn-host-environment-state { + width: 7px; + height: 7px; + margin-top: 4px; + border-radius: 50%; + background: var(--tx3); +} + +.bn-host-environment-state.is-available { + background: var(--ok); + box-shadow: 0 0 0 2px color-mix(in srgb, var(--ok) 18%, transparent); +} + +.bn-host-environment-grid article > div { + display: flex; + min-width: 0; + flex-direction: column; + gap: 2px; +} + +.bn-host-environment-grid strong, +.bn-host-environment-grid span, +.bn-host-environment-grid small { + overflow-wrap: anywhere; +} + +.bn-host-environment-grid strong { + color: var(--tx1); + font-size: 11px; +} + +.bn-host-environment-grid span { + color: var(--tx2); + font-size: 11px; +} + +.bn-host-environment-grid small { + color: var(--tx3); + font-size: 11px; + line-height: 1.35; +} + +.bn-host-environment > p { + margin: 0; +} + +.bn-runtime-install-options { + display: grid; + gap: 6px; +} + +.bn-runtime-install-options label { + display: grid; + grid-template-columns: 16px minmax(0, 1fr); + align-items: flex-start; + gap: 8px; + padding: 9px; + border: 1px solid var(--line); + border-radius: var(--control-radius); + background: var(--lift); + cursor: pointer; + transition: border-color .12s ease, background .12s ease; +} + +.bn-runtime-install-options label:hover { + border-color: var(--tx2); + background: var(--hover); +} + +.bn-runtime-install-options label.is-selected { + border-color: var(--action); + background: color-mix(in srgb, var(--action) 8%, var(--lift)); +} + +.bn-runtime-install-options label.is-selected.is-danger { + border-color: var(--err); + background: color-mix(in srgb, var(--err) 7%, var(--lift)); +} + +.bn-runtime-install-options label[aria-disabled="true"] { + cursor: not-allowed; + opacity: .55; +} + +.bn-runtime-install-options input { + margin: 2px 0 0; + accent-color: var(--action); +} + +.bn-runtime-install-options label > span { + display: flex; + min-width: 0; + flex-direction: column; + gap: 2px; +} + +.bn-runtime-install-options strong { + color: var(--tx1); + font-size: 12px; +} + +.bn-runtime-install-options small { + color: var(--tx3); + font-size: 11px; + line-height: 1.4; +} + +.bn-runtime-uninstall { + margin: 0 12px 10px; + border-color: color-mix(in srgb, var(--err) 50%, var(--line2)); + background: color-mix(in srgb, var(--err) 5%, var(--panel)); +} + +.bn-runtime-uninstall label { + display: flex; + flex-direction: column; + gap: 5px; +} + +.bn-runtime-uninstall label > span { + color: var(--tx2); + font-size: 12px; +} + +.bn-compute-device-grid { + display: grid; + flex: 0 0 auto; + grid-template-columns: repeat(auto-fill, 148px); + gap: 8px; + padding: 10px 12px; +} + +.bn-compute-device-card { + display: flex; + width: 148px; + height: 148px; + min-width: 0; + box-sizing: border-box; + flex-direction: column; + justify-content: space-between; + padding: 10px; + border: 1px solid var(--device-status); + border-radius: 7px; + background: var(--lift); + color: var(--tx2); + cursor: pointer; + font-family: var(--font-ui); + text-align: left; + transition: border-color .12s ease, background .12s ease, box-shadow .12s ease, transform .12s ease; +} + +.bn-compute-device-card:hover { + border-color: var(--tx1); + background: var(--hover); + box-shadow: 0 0 0 2px color-mix(in srgb, var(--device-status) 38%, transparent); +} + +.bn-compute-device-card:active, +button.bn-compute-device-card:active:not(:disabled) { + transform: translateY(1px); +} + +.bn-compute-device-card:focus-visible { + outline: 2px solid var(--device-status); + outline-offset: 2px; +} + +.bn-compute-device-card.is-selected { + border-color: var(--device-status); + box-shadow: 0 0 0 2px color-mix(in srgb, var(--device-status) 38%, transparent); +} + +.bn-compute-device-card-top, +.bn-compute-device-card-body { + display: flex; + min-width: 0; +} + +.bn-compute-device-card-top { + align-items: flex-start; + justify-content: flex-end; +} + +.bn-compute-device-status { + color: var(--device-status); + font-size: 11px; + font-weight: 800; + letter-spacing: .045em; +} + +.bn-compute-device-card-body { + flex: 1; + width: 100%; + align-items: center; + justify-content: center; + flex-direction: column; + gap: 3px; + text-align: center; +} + +.bn-compute-device-card-body span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.bn-compute-device-card-body strong { + display: -webkit-box; + overflow: hidden; + color: var(--tx1); + font-size: 16px; + overflow-wrap: anywhere; + white-space: normal; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; +} + +.bn-compute-device-card-body span { + color: var(--tx3); + font-size: 11px; +} + +.bn-compute-device-detail { + flex: 0 0 auto; + margin: 0 12px 12px; + border: 1px solid var(--line); + border-radius: 7px; + background: var(--lift); +} + +.bn-compute-device-detail-head, +.bn-compute-robots-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 10px; +} + +.bn-compute-device-detail-head { + border-bottom: 1px solid var(--line); +} + +.bn-device-back-button { + display: inline-flex; + min-height: 34px; + align-items: center; + gap: 7px; + margin: 0 0 12px; + padding: 0 11px; + border: 1px solid var(--line2); + border-radius: 6px; + background: var(--panel); + color: var(--tx1); + cursor: pointer; + font-family: var(--font-ui); + font-size: 13px; + font-weight: 700; + box-shadow: 0 1px 2px color-mix(in srgb, var(--bg) 28%, transparent); + transition: border-color .12s ease, background .12s ease; +} + +.bn-device-back-button:hover { + border-color: var(--accent); + background: var(--hover); +} + +.bn-compute-device-detail-head .bn-device-back-button > span { + display: grid; + width: 24px; + height: 24px; + flex: 0 0 24px; + place-items: center; + color: var(--accent); +} + +.bn-device-back-icon svg { + display: block; + width: 22px; + height: 22px; +} + +.bn-device-back-icon path { + fill: none; + stroke: currentColor; + stroke-linecap: round; + stroke-linejoin: round; + stroke-width: 2.5; +} + +.bn-device-back-button:focus-visible { + outline: 2px solid color-mix(in srgb, var(--accent) 45%, transparent); + outline-offset: 3px; +} + +.bn-runtime-check-button { + min-height: 32px; + padding: 4px 10px; + border: 1px solid var(--line2); + border-radius: 4px; + background: transparent; + color: var(--tx2); + cursor: pointer; + font-family: var(--font-ui); + font-size: 12px; + transition: transform .08s ease, border-color .12s ease, background .12s ease; +} + +.bn-runtime-check-button:hover:not(:disabled) { + border-color: var(--accent); + background: var(--hover); +} + +.bn-runtime-check-button:active:not(:disabled) { + transform: translateY(1px) scale(.98); + background: var(--menu-active); +} + +.bn-runtime-check-button.is-checking { + border-color: var(--accent); + background: var(--menu-active); + color: var(--accent); + cursor: wait; +} + +.bn-runtime-check-button:disabled { + opacity: .8; +} + +.bn-runtime-check-result { + display: flex; + align-items: flex-start; + gap: 9px; + margin: 10px 10px 0; + padding: 9px 10px; + border: 1px solid var(--line); + border-radius: 6px; + background: var(--panel); +} + +.bn-runtime-check-dot { + width: 9px; + height: 9px; + flex: 0 0 9px; + margin-top: 3px; + border-radius: 50%; + background: var(--tx3); +} + +.bn-runtime-check-result.is-ready .bn-runtime-check-dot { + background: var(--ok); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--ok) 16%, transparent); +} + +.bn-runtime-check-result.is-error .bn-runtime-check-dot { + background: var(--err); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--err) 14%, transparent); +} + +.bn-runtime-check-result.is-paused .bn-runtime-check-dot { + background: var(--warn); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--warn) 14%, transparent); +} + +.bn-runtime-check-result.is-checking .bn-runtime-check-dot { + background: var(--accent); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 16%, transparent); + animation: bn-runtime-check-pulse .8s ease-in-out infinite alternate; +} + +.bn-runtime-check-result strong, +.bn-runtime-check-result span { + display: block; +} + +.bn-runtime-check-result strong { + color: var(--tx1); + font-size: 12px; +} + +.bn-runtime-check-result span { + margin-top: 2px; + color: var(--tx3); + font-size: 11px; + line-height: 1.4; +} + +@keyframes bn-runtime-check-pulse { + from { opacity: .45; } + to { opacity: 1; } +} + +.bn-compute-device-detail-head > div:first-child, +.bn-compute-robots-head > div { + min-width: 0; +} + +.bn-compute-device-detail-head span, +.bn-compute-device-detail-head strong, +.bn-compute-device-detail-head code, +.bn-compute-robots-head strong, +.bn-compute-robots-head span { + display: block; +} + +.bn-compute-device-detail-head span { + color: var(--tx3); + font-size: 11px; + font-weight: 750; + letter-spacing: .04em; + text-transform: uppercase; +} + +.bn-compute-device-detail-head strong, +.bn-compute-robots-head strong { + color: var(--tx1); + font-size: 13px; +} + +.bn-compute-device-detail-head code { + overflow: hidden; + margin-top: 3px; + color: var(--tx3); + font-family: var(--font-mono); + font-size: 11px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.bn-compute-robots-head span { + margin-top: 2px; + color: var(--tx3); + font-size: 11px; +} + +.bn-compute-device-detail > .bn-device-error { + margin: 9px 10px 0; +} + +.bn-robot-form { + margin: 0 10px 10px; + border: 1px solid var(--line); + border-radius: 6px; +} + +.bn-device-robot-list { + display: flex; + flex-direction: column; + gap: 6px; + padding: 0 10px 10px; +} + +.bn-device-robot-empty { + padding: 14px; + border: 1px dashed var(--line2); + border-radius: 6px; + color: var(--tx3); + font-size: 12px; + line-height: 1.45; + text-align: center; +} + +.bn-device-card-summary { + display: grid; + width: 100%; + min-height: 48px; + grid-template-columns: 10px minmax(0, 1fr) auto 14px; + align-items: center; + gap: 9px; + padding: 8px 10px; + border: 0; + background: transparent; + color: var(--tx2); + cursor: pointer; + font-family: var(--font-ui); + text-align: left; +} + +.bn-device-list { + gap: 6px; + padding: 8px; +} + +.bn-card-list .bn-device-card { + border-radius: 6px; + box-shadow: none; +} + +.bn-device-card-summary:hover { + background: var(--hover); +} + +.bn-device-card-summary:focus-visible { + outline: 2px solid color-mix(in srgb, var(--accent) 45%, transparent); + outline-offset: -2px; +} + +.bn-device-card-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--run-status); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--run-status) 16%, transparent); +} + +.bn-device-card-main { + min-width: 0; +} + +.bn-device-card-main strong, +.bn-device-card-main span { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.bn-device-card-main strong { + color: var(--tx1); + font-size: 13px; + font-weight: 700; +} + +.bn-device-card-main span { + margin-top: 2px; + color: var(--tx3); + font-size: 11px; +} + +.bn-device-card-state { + padding: 2px 6px; + border: 1px solid color-mix(in srgb, var(--run-status) 70%, var(--line2)); + border-radius: 999px; + color: var(--run-status); + font-size: 11px; + font-weight: 800; + letter-spacing: .03em; + white-space: nowrap; +} + +.bn-device-card-chevron { + color: var(--tx3); + font-size: 20px; + line-height: 1; + transform: rotate(0deg); + transition: transform .12s ease; +} + +.bn-device-card.is-expanded .bn-device-card-chevron { + transform: rotate(90deg); +} + +.bn-device-card-detail { + padding: 10px; +} + +.bn-device-card-addresses { + display: flex; + min-width: 0; + flex-direction: column; + gap: 3px; + color: var(--tx3); + font-family: var(--font-mono); + font-size: 11px; +} + +.bn-device-card-addresses span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.bn-device-continue { + display: flex; + flex: 0 0 auto; + align-items: center; + justify-content: space-between; + gap: 12px; + margin: 10px 12px 0; + padding: 10px; + border: 1px solid color-mix(in srgb, var(--ok) 45%, var(--line)); + border-radius: 7px; + background: color-mix(in srgb, var(--ok) 9%, var(--lift)); +} + +.bn-device-continue strong, +.bn-device-continue span { + display: block; +} + +.bn-device-continue strong { + color: var(--tx1); + font-size: 13px; +} + +.bn-device-continue span { + margin-top: 3px; + color: var(--tx2); + font-size: 12px; + line-height: 1.4; +} + +.bn-device-empty { + display: flex; + flex-direction: column; + align-items: center; + padding: 30px 18px; + border: 1px dashed var(--line2); + border-radius: 8px; + background: var(--lift); + text-align: center; +} + +.bn-device-empty .bn-device-empty-icon { + width: 30px; + height: 30px; + margin-bottom: 9px; + font-size: 13px; +} + +.bn-device-empty strong { + color: var(--tx1); + font-size: 14px; +} + +.bn-device-empty p { + max-width: 360px; + margin: 6px 0 14px; + color: var(--tx3); + font-size: 12px; + line-height: 1.5; +} + +.bn-device-summary { + width: 100%; + display: grid; + grid-template-columns: 16px minmax(0, 1fr) auto; + align-items: stretch; + gap: 8px; + padding: 9px 10px 9px 6px; + color: var(--tx2); +} + +.bn-device-facts { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 6px; + margin-bottom: 10px; +} + +.bn-device-fact { + min-width: 0; + padding: 6px; + background: var(--panel); + border: 1px solid var(--line); + border-radius: 4px; +} + +.bn-device-fact span, +.bn-device-fact strong { + display: block; + overflow: hidden; + text-overflow: ellipsis; +} + +.bn-device-fact span { + color: var(--tx3); + font-size: 11px; + text-transform: uppercase; +} + +.bn-device-fact strong { + margin-top: 3px; + color: var(--tx2); + font-family: var(--font-mono); + font-size: 12px; + white-space: nowrap; +} + +@container devices-panel (max-width: 430px) { + .bn-devices-panel > .bn-runs-toolbar { + align-items: stretch; + flex-direction: column; + } + + .bn-devices-panel > .bn-runs-toolbar .bn-runs-actions, + .bn-devices-panel .bn-run-detail-actions { + justify-content: flex-start; + } + + .bn-devices-panel > .bn-runs-toolbar .bn-runs-actions button, + .bn-devices-panel .bn-run-detail-actions button, + .bn-device-form-actions button { + flex: 1 1 auto; + min-height: 30px; + } + + .bn-device-summary { + grid-template-columns: 16px minmax(0, 1fr); + } + + .bn-device-summary .bn-run-status { + grid-column: 2; + align-items: flex-start; + white-space: normal; + } + + .bn-device-summary .bn-run-status > span:last-child { + overflow-wrap: anywhere; + } + + .bn-device-facts { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .bn-host-environment-grid { + grid-template-columns: 1fr; + } + + .bn-compute-device-detail-head, + .bn-compute-robots-head { + align-items: stretch; + flex-direction: column; + } + + .bn-devices-panel .bn-device-card-detail { + padding: 10px; + } + + .bn-devices-panel .bn-device-runtime-reuse { + align-items: stretch; + flex-direction: column; + } + + .bn-device-continue { + align-items: stretch; + flex-direction: column; + } + + .bn-device-continue button { + align-self: flex-start; + } + + .bn-devices-panel .bn-device-runtime-reuse button { + align-self: flex-start; + min-height: 30px; + } +} + +@container devices-panel (max-width: 280px) { + .bn-device-facts { + grid-template-columns: 1fr; + } + + .bn-device-form-actions { + align-items: stretch; + flex-direction: column-reverse; + } + + .bn-devices-panel .bn-run-detail { + padding: 10px; + } + + .bn-device-ssh-grid { + grid-template-columns: 1fr; + } + + .bn-device-card-summary { + grid-template-columns: 9px minmax(0, 1fr) 12px; + } + + .bn-device-card-state { + display: none; } } diff --git a/editor/src/store.ts b/editor/src/store.ts index b931c72..01fc871 100644 --- a/editor/src/store.ts +++ b/editor/src/store.ts @@ -86,6 +86,116 @@ interface UndoSnapshot { const UNDO_LIMIT = 80 let dragUndoActive = false let learnedHighlightTimer: ReturnType | null = null +const artifactCaptureSignatures = new Map() + +const ARTIFACT_KINDS = new Set([ + 'blacknode.episode-dataset', + 'blacknode.training-job', + 'blacknode.training-run', + 'blacknode.action-chunking-checkpoint', + 'blacknode.policy-artifact', + 'blacknode.policy-replay-metrics', + 'blacknode.policy-runtime', +]) + +const ARTIFACT_CAPTURE_FIELDS = new Set([ + 'kind', + 'schema_version', + 'dataset_id', + 'path', + 'task', + 'fps', + 'episode_count', + 'total_frames', + 'duration_seconds', + 'robot_type', + 'joint_names', + 'cameras', + 'run_id', + 'output_dir', + 'phase', + 'running', + 'step', + 'steps', + 'progress', + 'train_loss', + 'validation_loss', + 'best_validation_loss', + 'checkpoint', + 'device', + 'started_at', + 'ended_at', + 'error', + 'name', + 'policy_type', + 'backend', + 'created_at', + 'source_checkpoint', + 'action_mode', + 'units', + 'camera_names', + 'state_dim', + 'action_dim', + 'metrics', + 'episode_index', + 'mean_absolute_error', + 'root_mean_square_error', + 'max_absolute_error', + 'motion_commanded', + 'log_path', + 'inference_count', + 'command_count', + 'blocked_count', + 'mean_inference_ms', + 'last_error', + 'armed', + 'emergency_stop', + 'human_takeover', +]) + +function artifactCandidates(value: unknown): { + payloads: Record[] + signature: string +} { + const stack: unknown[] = [value] + const matches: string[] = [] + const payloads: Record[] = [] + let visited = 0 + while (stack.length && visited < 160) { + const current = stack.pop() + visited += 1 + if (Array.isArray(current)) { + stack.push(...current.slice(0, 40)) + continue + } + if (!current || typeof current !== 'object') continue + const record = current as Record + const kind = typeof record.kind === 'string' ? record.kind : '' + if (ARTIFACT_KINDS.has(kind)) { + payloads.push(Object.fromEntries( + Object.entries(record).filter(([key]) => ARTIFACT_CAPTURE_FIELDS.has(key)), + )) + matches.push([ + kind, + record.path, + record.output_dir, + record.run_id, + record.phase, + record.running, + record.step, + record.episode_count, + ].map(item => String(item ?? '')).join(':')) + } + for (const [key, item] of Object.entries(record)) { + if (key === 'frames' || key === 'frames_data' || key === 'last_prediction') continue + stack.push(item) + } + } + return { + payloads, + signature: matches.sort().join('|'), + } +} interface Store { nodes: Node[] @@ -107,6 +217,7 @@ interface Store { activeTabId: string activeProject: ActiveProject | null workflowRevision: number + projectRevision: number workflowMetadata: WorkflowMetadata undoHistory: UndoSnapshot[] cookLog: CookLogEntry[] @@ -1328,6 +1439,7 @@ export const useStore = create((set, get) => ({ activeTabId: 'default', activeProject: null, workflowRevision: 0, + projectRevision: 0, workflowMetadata: {}, undoHistory: [], cookLog: [], @@ -3082,6 +3194,8 @@ export const useStore = create((set, get) => ({ cookNode: async (id, port = 'output', graphTargets, runMode = 'once') => { const cookTabId = get().activeTabId + const cookTab = get().tabs.find(tab => tab.id === cookTabId) + const cookProject = get().activeProject const stack = get().subnetStack const activeSubnetId = stack.length > 0 ? stack[stack.length - 1].subnetId : null const cookNodes = get().nodes @@ -3134,6 +3248,33 @@ export const useStore = create((set, get) => ({ } const applyCookEvent = (event: CookEvent) => { queueLiveReplay(event) + if ( + event.type === 'success' + && cookProject + && cookTab?.slug + && cookProject.workflowSlugs.includes(cookTab.slug) + ) { + const value = event.outputs ?? event.value + const candidates = artifactCandidates(value) + const signature = candidates.signature + const captureKey = `${cookProject.id}:${cookTab.slug}:${event.node_id}` + if (signature && artifactCaptureSignatures.get(captureKey) !== signature) { + artifactCaptureSignatures.set(captureKey, signature) + const nodeType = event.node_type + ?? cookNodes.find(node => node.id === event.node_id)?.data.type + ?? '' + void api.importProjectArtifacts(cookProject.id, { + workflow_slug: cookTab.slug, + node_type: nodeType, + value: candidates.payloads, + }).then(() => { + set(state => ({ projectRevision: state.projectRevision + 1 })) + }).catch(() => { + // Artifact capture is project evidence, not part of node execution. + // A capture failure must not turn a successful cook into a failure. + }) + } + } if (event.type === 'done') { set(s => ({ ...syncTabCookState(s, cookTabId, { diff --git a/tests/test_editor_devices.py b/tests/test_editor_devices.py index 253f8c5..48cdaf7 100644 --- a/tests/test_editor_devices.py +++ b/tests/test_editor_devices.py @@ -22,6 +22,7 @@ sys.path.insert(0, str(EDITOR_SERVER_DIR)) import device_registry # noqa: E402 +import device_installer # noqa: E402 import server # noqa: E402 @@ -265,6 +266,238 @@ def tearDown(self): server._project_store = self._original_project_store self._tmp.cleanup() + def test_runtime_inspection_keeps_remote_token_paths_private(self): + output = ( + "remote preface\n" + "__BLACKNODE_RUNTIME_INSPECTION__=" + + json.dumps({ + "instances": [{ + "instance_id": "default", + "port": 8766, + "_token_file": "/home/robot/.blacknode/runtime.auth.token", + }], + "environment": { + "policy": "preserve", + "docker": {"available": True, "server_version": "27.5.1"}, + }, + "suggested_port": 8767, + "suggested_instance_id": "instance-2", + }) + ) + + private = device_installer._parse_inspection(output) + public = device_installer._public_inspection(private) + + self.assertEqual(private["instances"][0]["_token_file"], "/home/robot/.blacknode/runtime.auth.token") + self.assertNotIn("_token_file", public["instances"][0]) + self.assertNotIn("auth.token", json.dumps(public)) + self.assertEqual(public["environment"]["docker"]["server_version"], "27.5.1") + self.assertEqual(public["suggested_port"], 8767) + + def test_runtime_instance_ids_reject_shell_metacharacters(self): + self.assertEqual(device_installer._clean_instance_id("instance-2"), "instance-2") + with self.assertRaises(device_installer.DeviceInstallError): + device_installer._clean_instance_id("instance-2; rm -rf") + + def test_nested_sudo_input_is_non_interactive_and_reusable(self): + sudo_input = device_installer._sudo_input("ssh-password", attempts=3) + + self.assertEqual(sudo_input, "ssh-password\n" * 3) + with self.assertRaises(device_installer.DeviceInstallError): + device_installer._clean_target( + "192.168.1.87", + 22, + "robot", + "line-one\nline-two", + ) + + def test_ssh_authentication_failure_is_clear_and_non_mutating(self): + class AuthenticationException(Exception): + pass + + class SSHException(Exception): + pass + + class MissingHostKeyPolicy: + pass + + class SSHClient: + def set_missing_host_key_policy(self, _policy): + return None + + def connect(self, *_args, **_kwargs): + raise AuthenticationException("Authentication failed") + + def close(self): + return None + + fake_paramiko = SimpleNamespace( + AuthenticationException=AuthenticationException, + SSHException=SSHException, + MissingHostKeyPolicy=MissingHostKeyPolicy, + SSHClient=SSHClient, + ) + + with patch.object(device_installer, "_load_paramiko", return_value=fake_paramiko): + with self.assertRaisesRegex( + device_installer.DeviceInstallError, + "SSH login was rejected for user 'alex'", + ): + device_installer._connect( + "192.168.1.87", + 22, + "alex", + "wrong-password", + expected_fingerprint="SHA256:trusted-device-key", + ) + + def test_remote_install_exports_a_non_interactive_sudo_wrapper(self): + script = next( + value + for value in device_installer.install_runtime.__code__.co_consts + if ( + isinstance(value, str) + and value.startswith("#!/usr/bin/env bash") + and "__BLACKNODE_INSTALL_PROGRESS__" in value + ) + ) + + self.assertIn("command sudo -S -p '' \"$@\"", script) + self.assertIn("export -f sudo", script) + self.assertIn('__BLACKNODE_RUNTIME_PORT__=$runtime_port', script) + self.assertIn('"$remove_old_port" == "1"', script) + self.assertIn("sock.bind((\"0.0.0.0\", candidate))", script) + self.assertIn("'blacknode-runtime*.service' 'blacknode-hardware*.service'", script) + self.assertIn('sudo systemctl start "$sibling_service"', script) + self.assertNotIn("keep_sudo_alive", script) + + self.assertIn( + "fallback_port = 0", + device_installer._INSPECTION_SCRIPT, + ) + + def test_reinstall_of_incomplete_instance_uses_next_available_port(self): + class RemoteFile(io.StringIO): + def __enter__(self): + return self + + def __exit__(self, *_args): + self.close() + return False + + class Sftp: + def file(self, _path, _mode): + return RemoteFile() + + def chmod(self, _path, _mode): + return None + + def close(self): + return None + + connection = SimpleNamespace( + client=SimpleNamespace(open_sftp=lambda: Sftp()), + fingerprint="SHA256:trusted-device-key", + close=lambda: None, + ) + inspection = { + "instances": [{ + "instance_id": "instance-2", + "port": 0, + "healthy": False, + "token_available": False, + }], + "suggested_port": 8768, + } + commands = [] + + def fake_run(_connection, command, **kwargs): + commands.append(command) + if "on_output" in kwargs: + kwargs["on_output"]("__BLACKNODE_RUNTIME_PORT__=8769") + return "" + + with ( + patch.object(device_installer, "_connect", return_value=connection), + patch.object(device_installer, "_inspect_connection", return_value=inspection), + patch.object(device_installer, "_run", side_effect=fake_run), + ): + result = device_installer.install_runtime( + host="192.168.1.87", + port=22, + username="robot", + password="ssh-password", + host_fingerprint="SHA256:trusted-device-key", + action="replace", + instance_id="instance-2", + ) + + self.assertEqual(result["runtime_port"], 8769) + self.assertIn("replace instance-2 8768", commands[0]) + + def test_robot_service_restart_resolves_exact_systemd_unit_by_port(self): + uploaded = [] + + class RemoteFile(io.StringIO): + def __enter__(self): + return self + + def __exit__(self, *_args): + uploaded.append(self.getvalue()) + self.close() + return False + + class Sftp: + def file(self, _path, _mode): + return RemoteFile() + + def chmod(self, _path, _mode): + return None + + def close(self): + return None + + connection = SimpleNamespace( + client=SimpleNamespace(open_sftp=lambda: Sftp()), + fingerprint="SHA256:trusted-device-key", + close=lambda: None, + ) + commands = [] + + def fake_run(_connection, command, **_kwargs): + commands.append(command) + if command.startswith("bash "): + return ( + "__BLACKNODE_HARDWARE_SERVICE__=" + "blacknode-hardware-follower.service\n" + "__BLACKNODE_HARDWARE_STATE__=active\n" + ) + return "" + + with ( + patch.object(device_installer, "_connect", return_value=connection), + patch.object(device_installer, "_run", side_effect=fake_run), + ): + result = device_installer.restart_hardware_service( + host="192.168.1.87", + port=22, + username="robot", + password="ssh-password", + host_fingerprint="SHA256:trusted-device-key", + hardware_port=8767, + ) + + self.assertEqual(result, { + "ok": True, + "hardware_port": 8767, + "service_name": "blacknode-hardware-follower.service", + "state": "active", + }) + self.assertIn(" 8767", commands[0]) + self.assertIn("systemctl list-unit-files 'blacknode-hardware*.service'", uploaded[0]) + self.assertIn('systemctl restart "$service_name"', uploaded[0]) + self.assertNotIn("ssh-password", uploaded[0]) + def test_workflow_requirements_are_normalized_and_exposed_by_graph(self): original_metadata = dict(server._session.metadata) try: @@ -375,6 +608,298 @@ def route(request, timeout=0): for _method, _path, authorization, _body in manifest_requests )) + def test_compute_device_is_paired_before_robots_and_keeps_tokens_private(self): + runtime = _HardwareService("runtime-token") + robot = _HardwareService( + "robot-token", + status_overrides={"device_id": "workshop-arm"}, + ) + + def route(request, timeout=0): + if urllib.parse.urlsplit(request.full_url).port == 8766: + return runtime(request, timeout) + return robot(request, timeout) + + with patch("device_registry.urllib.request.urlopen", side_effect=route): + paired_host = self.client.post("/device-hosts", json={ + "name": "Jetson Orin", + "runtime_url": "http://192.168.1.87:8766", + "runtime_token": runtime.token, + }) + host_id = paired_host.json()["device"]["id"] + paired_robot = self.client.post( + f"/device-hosts/{host_id}/robots", + json={ + "name": "Follower arm", + "base_url": "http://192.168.1.87:8765", + "token": robot.token, + }, + ) + listed = self.client.get("/device-hosts") + + self.assertEqual(paired_host.status_code, 200) + self.assertEqual(paired_host.json()["device"]["robots"], []) + self.assertNotIn(runtime.token, paired_host.text) + self.assertEqual(paired_robot.status_code, 200) + self.assertEqual(paired_robot.json()["robot"]["host_id"], host_id) + self.assertNotIn(robot.token, paired_robot.text) + self.assertEqual(len(listed.json()["devices"]), 1) + host = listed.json()["devices"][0] + self.assertEqual(host["name"], "Jetson Orin") + self.assertEqual(host["robots"][0]["name"], "Follower arm") + self.assertNotIn(runtime.token, listed.text) + self.assertNotIn(robot.token, listed.text) + + legacy_robots = self.client.get("/devices").json()["devices"] + self.assertEqual([item["id"] for item in legacy_robots], ["workshop-arm"]) + saved = json.loads(self.registry_path.read_text(encoding="utf-8")) + self.assertEqual(saved["schema_version"], 2) + self.assertEqual(saved["hosts"][host_id]["runtime_token"], runtime.token) + self.assertEqual(saved["devices"]["workshop-arm"]["token"], robot.token) + + def test_existing_robot_pairings_are_grouped_into_compute_devices(self): + hardware = _HardwareService() + with patch("device_registry.urllib.request.urlopen", side_effect=hardware): + robot = self.client.post("/devices", json={ + "name": "Existing arm", + "base_url": "http://192.168.1.87:8765", + "token": hardware.token, + }).json()["device"] + + listed = self.client.get("/device-hosts") + + self.assertEqual(listed.status_code, 200) + self.assertEqual(len(listed.json()["devices"]), 1) + host = listed.json()["devices"][0] + self.assertEqual(host["runtime_url"], "http://192.168.1.87:8766") + self.assertEqual(host["robots"][0]["id"], robot["id"]) + self.assertEqual(host["robots"][0]["host_id"], host["id"]) + + def test_automatic_install_requires_fingerprint_and_does_not_return_password_or_token(self): + runtime = _HardwareService("generated-runtime-token") + install_result = { + "ok": True, + "runtime_token": runtime.token, + "host_fingerprint": "SHA256:trusted-device-key", + "elapsed_seconds": 12.3, + "action": "install", + "instance_id": "default", + "runtime_port": 8766, + "service_name": "blacknode-runtime.service", + "runtime_dir": "~/blacknode-runtime", + } + with ( + patch.object(server, "install_runtime", return_value=install_result), + patch("device_registry.urllib.request.urlopen", side_effect=runtime), + ): + response = self.client.post("/device-hosts/install", json={ + "name": "Robot computer", + "host": "192.168.1.87", + "port": 22, + "username": "robot", + "password": "ssh-password", + "host_fingerprint": "SHA256:trusted-device-key", + }) + + self.assertEqual(response.status_code, 200) + self.assertTrue(response.json()["runtime"]["ok"]) + self.assertEqual( + response.json()["install"]["host_fingerprint"], + "SHA256:trusted-device-key", + ) + self.assertNotIn("runtime_token", response.json()["install"]) + self.assertNotIn("ssh-password", response.text) + self.assertNotIn(runtime.token, response.text) + managed = response.json()["device"]["managed_runtime"] + self.assertEqual(managed["instance_id"], "default") + self.assertEqual(managed["runtime_port"], 8766) + + def test_automatic_install_stream_reports_progress_and_finishes_pairing(self): + runtime = _HardwareService("generated-runtime-token") + install_result = { + "ok": True, + "runtime_token": runtime.token, + "host_fingerprint": "SHA256:trusted-device-key", + "elapsed_seconds": 12.3, + "action": "side_by_side", + "instance_id": "instance-2", + "runtime_port": 8767, + "service_name": "blacknode-runtime-instance-2.service", + "runtime_dir": "~/blacknode-runtimes/instance-2", + } + + def fake_install_runtime(**kwargs): + kwargs["progress"]({ + "progress": 48, + "message": "Downloading Blacknode Runtime", + }) + return install_result + + with ( + patch.object(server, "install_runtime", side_effect=fake_install_runtime), + patch("device_registry.urllib.request.urlopen", side_effect=runtime), + ): + response = self.client.post("/device-hosts/install-stream", json={ + "name": "Independent runtime", + "host": "192.168.1.87", + "port": 22, + "username": "robot", + "password": "ssh-password", + "host_fingerprint": "SHA256:trusted-device-key", + "action": "side_by_side", + "instance_id": "instance-2", + }) + + events = [json.loads(line) for line in response.text.splitlines()] + self.assertEqual(response.status_code, 200) + self.assertEqual(events[0], { + "type": "progress", + "progress": 48, + "message": "Downloading Blacknode Runtime", + }) + self.assertEqual(events[-1]["type"], "done") + self.assertEqual(events[-1]["result"]["device"]["managed_runtime"]["instance_id"], "instance-2") + self.assertNotIn("ssh-password", response.text) + self.assertNotIn(runtime.token, response.text) + + def test_ssh_inspection_reports_existing_instances_without_credentials(self): + inspection = { + "ok": True, + "host_fingerprint": "SHA256:trusted-device-key", + "instances": [{ + "instance_id": "default", + "runtime_dir": "/home/robot/blacknode-runtime", + "service_name": "blacknode-runtime.service", + "port": 8766, + "repository": True, + "configured": True, + "service_installed": True, + "running": True, + "healthy": True, + "token_available": True, + "runtime_version": "0.3.0", + "device_id": "robot-computer", + "error": "", + }], + "environment": { + "policy": "preserve", + "os": { + "name": "Ubuntu 24.04.2 LTS", + "version": "24.04", + "architecture": "aarch64", + }, + "python": { + "version": "3.12.3", + "executable": "/usr/bin/python3", + }, + "nvidia": { + "available": True, + "gpus": ["NVIDIA Jetson GPU"], + "driver_version": "550.54", + "driver_cuda_version": "12.4", + "cuda_toolkit_version": "12.6", + "nvidia_smi": True, + "nvcc": True, + "preserved": True, + }, + "ros2": { + "available": True, + "distributions": ["jazzy"], + "selected_distribution": "jazzy", + "ros2_on_path": False, + "preserved": True, + }, + "docker": { + "available": True, + "client_version": "27.5.1", + "server_version": "27.5.1", + "daemon_running": False, + "service_enabled": False, + "preserved": True, + }, + "runtime_setup_packages": ["git", "python3-pip", "python3-venv"], + }, + "suggested_port": 8767, + "suggested_instance_id": "instance-2", + } + with patch.object(server, "inspect_runtime", return_value=inspection) as inspect: + response = self.client.post("/device-hosts/inspect", json={ + "host": "192.168.1.87", + "port": 22, + "username": "robot", + "password": "ssh-password", + "host_fingerprint": "SHA256:trusted-device-key", + }) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json()["instances"][0]["port"], 8766) + self.assertEqual(response.json()["suggested_port"], 8767) + self.assertEqual( + response.json()["environment"]["nvidia"]["cuda_toolkit_version"], + "12.6", + ) + self.assertFalse(response.json()["environment"]["docker"]["daemon_running"]) + self.assertNotIn("ssh-password", response.text) + inspect.assert_called_once_with( + host="192.168.1.87", + port=22, + username="robot", + password="ssh-password", + host_fingerprint="SHA256:trusted-device-key", + ) + + def test_managed_runtime_uninstall_removes_only_selected_registry_tree(self): + runtime = _HardwareService("generated-runtime-token") + install_result = { + "ok": True, + "runtime_token": runtime.token, + "host_fingerprint": "SHA256:trusted-device-key", + "elapsed_seconds": 12.3, + "action": "side_by_side", + "instance_id": "instance-2", + "runtime_port": 8767, + "service_name": "blacknode-runtime-instance-2.service", + "runtime_dir": "~/blacknode-runtimes/instance-2", + } + with ( + patch.object(server, "install_runtime", return_value=install_result), + patch("device_registry.urllib.request.urlopen", side_effect=runtime), + ): + installed = self.client.post("/device-hosts/install", json={ + "name": "Independent runtime", + "host": "192.168.1.87", + "port": 22, + "username": "robot", + "password": "ssh-password", + "host_fingerprint": "SHA256:trusted-device-key", + "action": "side_by_side", + "instance_id": "instance-2", + }) + host_id = installed.json()["device"]["id"] + with patch.object(server, "uninstall_runtime", return_value={ + "ok": True, + "instance_id": "instance-2", + "runtime_port": 8767, + "already_absent": False, + }) as uninstall: + response = self.client.post( + f"/device-hosts/{host_id}/uninstall", + json={"password": "ssh-password"}, + ) + + self.assertEqual(response.status_code, 200) + self.assertEqual(self.client.get("/device-hosts").json()["devices"], []) + self.assertNotIn("ssh-password", response.text) + uninstall.assert_called_once_with( + host="192.168.1.87", + port=22, + username="robot", + password="ssh-password", + host_fingerprint="SHA256:trusted-device-key", + instance_id="instance-2", + runtime_port=8767, + ) + def test_device_can_be_renamed_without_repairing_or_exposing_tokens(self): hardware = _HardwareService() with patch("device_registry.urllib.request.urlopen", side_effect=hardware): @@ -520,6 +1045,286 @@ def test_status_and_rpc_use_saved_token_on_fixed_device_endpoints(self): self.assertEqual(hardware.requests[-1][1], "/rpc") self.assertEqual(hardware.requests[-1][3]["method"], "stop") + def test_managed_device_pause_stops_deployments_and_disarms_then_resumes(self): + hardware = _HardwareService("hardware-token") + runtime = _HardwareService("runtime-token") + runtime.runtime_deployments["live"] = { + "id": "live", + "name": "Live workflow", + "state": "running", + "target_device_id": "alex-desktop", + } + host = server._device_registry.pair_host( + name="Robot computer", + runtime_url="http://192.168.1.87:8766", + runtime_token=runtime.token, + manifest={ + "service": "blacknode-runtime", + "protocol_version": 1, + "device_id": "robot-computer", + }, + managed_runtime={ + "ssh_host": "192.168.1.87", + "ssh_port": 22, + "ssh_username": "robot", + "host_fingerprint": "SHA256:trusted-device-key", + "instance_id": "default", + "runtime_port": 8766, + "service_name": "blacknode-runtime.service", + "runtime_dir": "~/blacknode-runtime", + }, + ) + server._device_registry.pair( + name="Workshop arm", + base_url="http://192.168.1.87:8765", + token=hardware.token, + host_id=host["id"], + status=hardware.status_payload, + ) + + def route(request, timeout=0): + if urllib.parse.urlsplit(request.full_url).port == 8766: + return runtime(request, timeout) + return hardware(request, timeout) + + with ( + patch("device_registry.urllib.request.urlopen", side_effect=route), + patch.object(server, "control_runtime", side_effect=[ + { + "ok": True, + "action": "pause", + "state": "inactive", + "service_name": "blacknode-runtime.service", + }, + { + "ok": True, + "action": "resume", + "state": "active", + "service_name": "blacknode-runtime.service", + }, + ]) as control, + ): + paused = self.client.post( + f"/device-hosts/{host['id']}/lifecycle", + json={"action": "pause", "password": "ssh-password"}, + ) + paused_robot = self.client.get("/devices/alex-desktop/status") + resumed = self.client.post( + f"/device-hosts/{host['id']}/lifecycle", + json={"action": "resume", "password": "ssh-password"}, + ) + + self.assertEqual(paused.status_code, 200) + self.assertTrue(paused.json()["device"]["paused"]) + self.assertEqual(runtime.runtime_deployments["live"]["state"], "stopped") + self.assertTrue(paused_robot.json()["paused"]) + self.assertFalse(resumed.json()["device"]["paused"]) + self.assertEqual([call.kwargs["action"] for call in control.call_args_list], ["pause", "resume"]) + rpc_methods = [ + body["method"] + for method, path, _auth, body in hardware.requests + if method == "POST" and path == "/rpc" + ] + self.assertIn("stop", rpc_methods) + self.assertIn("resume", rpc_methods) + + def test_robot_can_be_paused_without_stopping_its_compute_runtime(self): + hardware = _HardwareService() + with patch("device_registry.urllib.request.urlopen", side_effect=hardware): + robot = self.client.post("/devices", json={ + "name": "Workshop arm", + "base_url": "http://192.168.1.87:8765", + "token": hardware.token, + }).json()["device"] + paused = self.client.post( + f"/devices/{robot['id']}/lifecycle", + json={"action": "pause"}, + ) + status = self.client.get(f"/devices/{robot['id']}/status") + resumed = self.client.post( + f"/devices/{robot['id']}/lifecycle", + json={"action": "resume"}, + ) + + self.assertTrue(paused.json()["status"]["paused"]) + self.assertTrue(status.json()["paused"]) + self.assertFalse(resumed.json()["status"]["paused"]) + + def test_robot_pause_still_disarms_when_deployment_runtime_is_unreachable(self): + hardware = _HardwareService(status_overrides={ + "deployment_lease": { + "id": "live", + "name": "Live workflow", + "state": "running", + }, + }) + host = server._device_registry.pair_host( + name="Robot computer", + runtime_url="http://192.168.1.87:8766", + runtime_token="runtime-token", + manifest={ + "service": "blacknode-runtime", + "protocol_version": 1, + "device_id": "robot-computer", + }, + ) + robot = server._device_registry.pair( + name="Follower", + base_url="http://192.168.1.87:8767", + token=hardware.token, + host_id=host["id"], + status=hardware.status_payload, + ) + + def route(request, timeout=0): + if urllib.parse.urlsplit(request.full_url).port == 8766: + raise urllib.error.URLError("timed out") + return hardware(request, timeout) + + with patch("device_registry.urllib.request.urlopen", side_effect=route): + response = self.client.post( + f"/devices/{robot['id']}/lifecycle", + json={"action": "pause"}, + ) + + self.assertEqual(response.status_code, 200) + payload = response.json() + self.assertTrue(payload["status"]["paused"]) + self.assertIn("8766", payload["warnings"][0]) + self.assertIn("continued with the robot hardware stop request", payload["warnings"][0]) + self.assertIn("physical torque cannot be verified", payload["warnings"][0]) + rpc_methods = [ + body["method"] + for method, path, _auth, body in hardware.requests + if method == "POST" and path == "/rpc" + ] + self.assertIn("stop", rpc_methods) + + def test_managed_device_lifecycle_stream_reports_real_progress(self): + host = server._device_registry.pair_host( + name="Robot computer", + runtime_url="http://192.168.1.87:8768", + runtime_token="runtime-token", + manifest={ + "service": "blacknode-runtime", + "protocol_version": 1, + "device_id": "robot-computer-instance-2", + }, + managed_runtime={ + "ssh_host": "192.168.1.87", + "ssh_port": 22, + "ssh_username": "robot", + "host_fingerprint": "SHA256:trusted-device-key", + "instance_id": "instance-2", + "runtime_port": 8768, + "service_name": "blacknode-runtime-instance-2.service", + "runtime_dir": "~/blacknode-runtimes/instance-2", + }, + ) + runtime = SimpleNamespace(list_deployments=lambda: {"deployments": []}) + with ( + patch.object(server._device_registry, "host_client", return_value=runtime), + patch.object(server, "control_runtime", return_value={ + "ok": True, + "action": "pause", + "state": "inactive", + "service_name": "blacknode-runtime-instance-2.service", + }), + ): + response = self.client.post( + f"/device-hosts/{host['id']}/lifecycle-stream", + json={"action": "pause", "password": "ssh-password"}, + ) + + events = [json.loads(line) for line in response.text.splitlines() if line] + progress = [event for event in events if event["type"] == "progress"] + self.assertGreaterEqual(len(progress), 3) + self.assertEqual(progress[0]["progress"], 1) + self.assertEqual(progress[-1]["progress"], 100) + self.assertEqual(events[-1]["type"], "done") + self.assertTrue(events[-1]["result"]["device"]["paused"]) + + def test_managed_robot_service_can_be_restarted_by_exact_hardware_port(self): + hardware = _HardwareService() + host = server._device_registry.pair_host( + name="Robot computer", + runtime_url="http://192.168.1.87:8768", + runtime_token="runtime-token", + manifest={ + "service": "blacknode-runtime", + "protocol_version": 1, + "device_id": "robot-computer-instance-2", + }, + managed_runtime={ + "ssh_host": "192.168.1.87", + "ssh_port": 22, + "ssh_username": "robot", + "host_fingerprint": "SHA256:trusted-device-key", + "instance_id": "instance-2", + "runtime_port": 8768, + "service_name": "blacknode-runtime-instance-2.service", + "runtime_dir": "~/blacknode-runtimes/instance-2", + }, + ) + robot = server._device_registry.pair( + name="Follower", + base_url="http://192.168.1.87:8767", + token=hardware.token, + host_id=host["id"], + status=hardware.status_payload, + ) + with ( + patch("device_registry.urllib.request.urlopen", side_effect=hardware), + patch.object(server, "restart_hardware_service", return_value={ + "ok": True, + "hardware_port": 8767, + "service_name": "blacknode-hardware-follower.service", + "state": "active", + }) as restart, + ): + response = self.client.post( + f"/devices/{robot['id']}/lifecycle", + json={"action": "restart", "password": "ssh-password"}, + ) + + self.assertEqual(response.status_code, 200) + payload = response.json() + self.assertEqual(payload["action"], "restart") + self.assertFalse(payload["status"]["armed"]) + self.assertFalse(payload["status"]["paused"]) + self.assertIn("blacknode-hardware-follower.service", payload["summary"]) + self.assertNotIn("ssh-password", response.text) + restart.assert_called_once_with( + host="192.168.1.87", + port=22, + username="robot", + password="ssh-password", + host_fingerprint="SHA256:trusted-device-key", + hardware_port=8767, + ) + + def test_robot_service_restart_is_blocked_while_deployment_is_active(self): + hardware = _HardwareService(status_overrides={ + "deployment_lease": { + "id": "live", + "name": "Live workflow", + "state": "running", + }, + }) + with patch("device_registry.urllib.request.urlopen", side_effect=hardware): + robot = self.client.post("/devices", json={ + "name": "Follower", + "base_url": "http://192.168.1.87:8767", + "token": hardware.token, + }).json()["device"] + response = self.client.post( + f"/devices/{robot['id']}/lifecycle", + json={"action": "restart", "password": "ssh-password"}, + ) + + self.assertEqual(response.status_code, 409) + self.assertIn("Stop the robot's active deployment", response.text) + def test_device_status_recovers_a_stale_deployment_lease(self): hardware = _HardwareService(status_overrides={ "connected": False, diff --git a/tests/test_editor_projects.py b/tests/test_editor_projects.py index dc86509..dabe1ad 100644 --- a/tests/test_editor_projects.py +++ b/tests/test_editor_projects.py @@ -16,6 +16,7 @@ sys.path.insert(0, str(EDITOR_SERVER_DIR)) import server # noqa: E402 +from artifact_store import ArtifactStore # noqa: E402 from device_registry import DeviceRegistry # noqa: E402 from project_store import ProjectStore # noqa: E402 @@ -25,18 +26,37 @@ def setUp(self): self.client = TestClient(server.app) self._tmp = tempfile.TemporaryDirectory() root = Path(self._tmp.name) + self.root = root self.workflows = root / "workflows" self.workflows.mkdir() + self.templates = root / "templates" + self.templates.mkdir() self._original_workflows_dir = server._WORKFLOWS_DIR + self._original_templates_dir = server._TEMPLATES_DIR + self._original_starter_kits = server._PROJECT_STARTER_KITS self._original_project_store = server._project_store + self._original_artifact_store = server._artifact_store self._original_device_registry = server._device_registry server._WORKFLOWS_DIR = str(self.workflows) + server._TEMPLATES_DIR = str(self.templates) + server._PROJECT_STARTER_KITS = { + "robot_learning": { + "collect": { + "template_slug": "starter-collect", + "name": "Collect demonstrations", + }, + }, + } server._project_store = ProjectStore(root / ".blacknode" / "projects.json") + server._artifact_store = ArtifactStore(root / ".blacknode" / "artifacts.json") server._device_registry = DeviceRegistry(root / ".blacknode" / "devices.json") def tearDown(self): server._WORKFLOWS_DIR = self._original_workflows_dir + server._TEMPLATES_DIR = self._original_templates_dir + server._PROJECT_STARTER_KITS = self._original_starter_kits server._project_store = self._original_project_store + server._artifact_store = self._original_artifact_store server._device_registry = self._original_device_registry self._tmp.cleanup() @@ -182,6 +202,375 @@ def test_duplicate_names_receive_distinct_stable_ids(self): self.assertEqual(first["id"], "demo") self.assertEqual(second["id"], "demo-2") + def test_project_created_before_artifacts_loads_with_empty_references(self): + server._project_store.path.parent.mkdir(parents=True) + server._project_store.path.write_text(json.dumps({ + "schema_version": 1, + "projects": [{ + "id": "legacy", + "name": "Legacy", + "description": "", + "workflow_slugs": [], + "device_ids": [], + "active_workflow_slug": None, + "created_at": "2026-07-24T20:00:00+00:00", + "updated_at": "2026-07-24T20:00:00+00:00", + }], + }), encoding="utf-8") + + project = self.client.get("/projects/legacy") + + self.assertEqual(project.status_code, 200) + self.assertEqual(project.json()["artifact_ids"], []) + self.assertEqual(project.json()["artifacts"], []) + self.assertIsNone(project.json()["starter_kit"]) + + def test_guided_starter_materializes_links_and_reuses_saved_workflow(self): + (self.templates / "starter-collect.json").write_text(json.dumps({ + "kind": "blacknode.workflow", + "schema_version": 1, + "name": "Starter collection", + "entrypoint": {"node_id": "out", "port": "value"}, + "metadata": { + "template": True, + "description": "Safe starter fixture", + }, + "node_meta": { + "out": { + "id": "out", + "type": "Output", + "pos": [0, 0], + "params": {"label": "Result"}, + "inputs": ["value"], + "outputs": [], + "input_types": {"value": "Any"}, + "output_types": {}, + "input_defaults": {}, + }, + }, + "edges": [], + }), encoding="utf-8") + project = self.client.post("/projects", json={ + "name": "Guided Arm", + "starter_kit": "robot_learning", + }).json() + + created = self.client.post( + f"/projects/{project['id']}/starter-workflows/collect", + ) + reused = self.client.post( + f"/projects/{project['id']}/starter-workflows/collect", + ) + + self.assertEqual(created.status_code, 200) + self.assertTrue(created.json()["created"]) + result = created.json() + workflow = result["workflow"] + self.assertEqual(workflow["starter_kit"], "robot_learning") + self.assertEqual(workflow["starter_stage"], "collect") + self.assertEqual(workflow["source_template"], "starter-collect") + self.assertEqual(result["project"]["workflow_slugs"], [workflow["slug"]]) + self.assertEqual( + result["project"]["active_workflow_slug"], + workflow["slug"], + ) + saved = json.loads( + (self.workflows / f"{workflow['slug']}.json").read_text( + encoding="utf-8", + ) + ) + self.assertNotIn("template", saved["metadata"]) + self.assertEqual(saved["metadata"]["project_id"], project["id"]) + self.assertEqual(reused.status_code, 200) + self.assertFalse(reused.json()["created"]) + self.assertEqual( + reused.json()["workflow"]["slug"], + workflow["slug"], + ) + + def test_custom_project_does_not_materialize_starter_workflows(self): + project = self.client.post("/projects", json={ + "name": "Custom", + }).json() + response = self.client.post( + f"/projects/{project['id']}/starter-workflows/collect", + ) + self.assertEqual(response.status_code, 409) + self.assertIn("Enable", response.json()["detail"]) + + def test_starter_prefills_dataset_policy_and_safe_runtime_defaults(self): + project = server._project_store.create( + name="Prefilled", + starter_kit="robot_learning", + ) + dataset_path = self.root / "datasets" / "demo" + dataset_path.mkdir(parents=True) + policy_path = self.root / "policies" / "demo" + policy_path.mkdir(parents=True) + artifacts = server._artifact_store.import_value([ + { + "kind": "blacknode.episode-dataset", + "dataset_id": "demo", + "path": str(dataset_path), + "fps": 30, + "task": "Pick", + "episode_count": 4, + }, + { + "kind": "blacknode.policy-artifact", + "path": str(policy_path), + "policy_type": "act", + }, + ]) + project = server._project_store.link_artifact_ids( + project["id"], + [artifact["id"] for artifact in artifacts], + ) + training = { + "node_meta": { + "dataset_browser": { + "params": {"dataset": {}, "root": "", "dataset_id": ""}, + "input_defaults": { + "dataset": {}, + "root": "", + "dataset_id": "", + }, + }, + "training": { + "params": {"action": "start", "run_id": "act-training"}, + "input_defaults": { + "action": "start", + "run_id": "act-training", + }, + }, + }, + } + simulation = { + "node_meta": { + "artifact_path": { + "params": {"value": "placeholder"}, + "input_defaults": {}, + }, + "runtime": { + "params": {"action": "start", "run_id": "isaac-policy"}, + "input_defaults": { + "action": "start", + "run_id": "isaac-policy", + }, + }, + }, + } + + server._configure_project_starter_workflow(training, "train", project) + server._configure_project_starter_workflow( + simulation, + "simulate", + project, + ) + + browser = training["node_meta"]["dataset_browser"]["params"] + self.assertEqual(browser["dataset"]["path"], str(dataset_path.resolve())) + self.assertEqual(browser["dataset_id"], "demo") + self.assertEqual( + training["node_meta"]["training"]["params"]["run_id"], + "prefilled-act", + ) + self.assertEqual( + simulation["node_meta"]["artifact_path"]["params"]["value"], + str(policy_path.resolve()), + ) + self.assertEqual( + simulation["node_meta"]["runtime"]["params"]["action"], + "status", + ) + + def test_artifact_capture_links_dataset_evidence_and_deduplicates(self): + self._workflow( + "record", + "Record", + node_types=["EpisodeRecorder"], + ) + project = self.client.post("/projects", json={ + "name": "Dataset Project", + "workflow_slugs": ["record"], + }).json() + dataset_path = Path(self._tmp.name) / "datasets" / "pick-cube" + dataset_path.mkdir(parents=True) + payload = { + "kind": "blacknode.episode-dataset", + "schema_version": 1, + "dataset_id": "pick-cube", + "path": str(dataset_path), + "task": "Pick cube", + "fps": 30, + "episode_count": 2, + "pairing_token": "must-not-be-indexed", + } + + first = self.client.post( + f"/projects/{project['id']}/artifacts/import", + json={ + "workflow_slug": "record", + "node_type": "EpisodeRecorder", + "value": payload, + }, + ) + second = self.client.post( + f"/projects/{project['id']}/artifacts/import", + json={ + "workflow_slug": "record", + "node_type": "EpisodeRecorder", + "value": {**payload, "episode_count": 3}, + }, + ) + + self.assertEqual(first.status_code, 200) + self.assertEqual(second.status_code, 200) + result = second.json()["project"] + self.assertEqual(len(result["artifact_ids"]), 1) + self.assertEqual(len(result["artifacts"]), 1) + artifact = result["artifacts"][0] + self.assertEqual(artifact["artifact_type"], "dataset") + self.assertEqual(artifact["status"], "completed") + self.assertEqual(artifact["metadata"]["episode_count"], 3) + self.assertNotIn("must-not-be-indexed", json.dumps(result)) + + def test_existing_manifest_can_be_added_and_unlinked_without_deletion(self): + project = self.client.post("/projects", json={ + "name": "Existing Policy", + }).json() + policy_path = Path(self._tmp.name) / "policies" / "act-v1" + policy_path.mkdir(parents=True) + manifest = policy_path / "manifest.json" + manifest.write_text(json.dumps({ + "kind": "blacknode.policy-artifact", + "schema_version": 1, + "path": str(policy_path), + "policy_type": "act", + "step": 1200, + }), encoding="utf-8") + + added = self.client.post( + f"/projects/{project['id']}/artifacts/inspect", + json={"path": str(policy_path)}, + ) + + self.assertEqual(added.status_code, 200) + linked = added.json()["project"] + self.assertEqual(linked["artifacts"][0]["artifact_type"], "policy") + artifact_id = linked["artifacts"][0]["id"] + unlinked = self.client.patch( + f"/projects/{project['id']}", + json={"artifact_ids": []}, + ) + self.assertEqual(unlinked.status_code, 200) + self.assertEqual(unlinked.json()["artifacts"], []) + self.assertTrue(manifest.exists()) + self.assertTrue( + server._artifact_store.list([artifact_id]), + "Unlinking must not delete the provider-owned artifact reference", + ) + + def test_native_dataset_manifest_and_run_outputs_map_to_typed_evidence(self): + self._workflow( + "learning", + "Learning", + node_types=["EpisodeRecorder", "ACTTraining", "IsaacPolicyRuntime"], + ) + project = self.client.post("/projects", json={ + "name": "Learning Project", + "workflow_slugs": ["learning"], + }).json() + + dataset_path = self.root / "datasets" / "pick-cube" + dataset_path.mkdir(parents=True) + (dataset_path / "dataset.json").write_text(json.dumps({ + "kind": "blacknode.episode-dataset", + "schema_version": 1, + "dataset_id": "pick-cube", + "fps": 30, + "task": "Pick cube", + "episodes": [{"frames": 50}, {"frames": 60}], + }), encoding="utf-8") + inspected = self.client.post( + f"/projects/{project['id']}/artifacts/inspect", + json={"path": str(dataset_path), "workflow_slug": "learning"}, + ) + self.assertEqual(inspected.status_code, 200) + dataset = inspected.json()["artifacts"][0] + self.assertEqual(dataset["metadata"]["episode_count"], 2) + self.assertEqual(dataset["metadata"]["total_frames"], 110) + + output_dir = self.root / "training" / "act-run" + output_dir.mkdir(parents=True) + checkpoint = output_dir / "checkpoint-000100.pt" + checkpoint.touch() + policy_path = self.root / "policies" / "act-run" + policy_path.mkdir(parents=True) + simulation_log = self.root / "runs" / "isaac.jsonl" + simulation_log.parent.mkdir(parents=True) + simulation_log.touch() + captured = self.client.post( + f"/projects/{project['id']}/artifacts/import", + json={ + "workflow_slug": "learning", + "node_type": "IsaacPolicyRuntime", + "value": [ + { + "kind": "blacknode.training-job", + "run_id": "act-run", + "output_dir": str(output_dir), + "checkpoint": str(checkpoint), + "phase": "training", + "running": True, + "step": 100, + "steps": 1000, + }, + { + "kind": "blacknode.policy-artifact", + "path": str(policy_path), + "policy_type": "act", + }, + { + "kind": "blacknode.policy-runtime", + "run_id": "isaac-eval", + "log_path": str(simulation_log), + "phase": "stopped", + "running": False, + }, + ], + }, + ) + self.assertEqual(captured.status_code, 200) + artifacts = captured.json()["project"]["artifacts"] + self.assertEqual( + {item["artifact_type"] for item in artifacts}, + {"dataset", "training_run", "checkpoint", "policy", "simulation_run"}, + ) + training = next( + item for item in artifacts if item["artifact_type"] == "training_run" + ) + simulation = next( + item for item in artifacts if item["artifact_type"] == "simulation_run" + ) + self.assertEqual(training["status"], "running") + self.assertEqual(simulation["status"], "completed") + + def test_artifact_workflow_must_belong_to_project(self): + project = self.client.post("/projects", json={"name": "Scoped"}).json() + response = self.client.post( + f"/projects/{project['id']}/artifacts/import", + json={ + "workflow_slug": "other-workflow", + "value": { + "kind": "blacknode.training-job", + "run_id": "run-1", + }, + }, + ) + self.assertEqual(response.status_code, 400) + self.assertIn("linked", response.json()["detail"]) + if __name__ == "__main__": unittest.main()
jointleaderobservedaction