diff --git a/README.md b/README.md index 6ba7047..13153d9 100644 --- a/README.md +++ b/README.md @@ -202,6 +202,7 @@ editor computer. [Datasets](docs/episode-datasets.md) · [Policy training](docs/robot-policy-training.md) - Platform: [Packages](docs/packages.md) · + [Projects](docs/projects.md) · [Device deployment](docs/deployment-architecture.md) · [Workflow schema](docs/workflow-schema.md) · [Custom nodes](docs/custom-nodes.md) · diff --git a/docs/deployment-architecture.md b/docs/deployment-architecture.md index d4d1a06..c2ae5dc 100644 --- a/docs/deployment-architecture.md +++ b/docs/deployment-architecture.md @@ -50,6 +50,8 @@ Every transport carries the same logical artifact: |---|---| | `schema_version` | Selects the artifact contract | | `name` | Human-readable deployment name | +| `project_id` | Stable owning Project ID when deployed from a Project | +| `workflow_slug` | Saved workflow identity within the owning Project | | `workflow_hash` | Binds the artifact to the exact validated graph | | `artifact_hash` | Identifies the exported executable content | | `entrypoint` | Declares what the runtime executes | @@ -62,6 +64,12 @@ Large artifacts do not have to travel through the command transport. An MQTT or Zenoh command can carry a content-addressed HTTPS or object-store reference, while the same artifact manifest and hashes remain authoritative. +`project_id` and `workflow_slug` are an optional pair. The editor validates +that the project links the workflow and target device before staging. The +runtime preserves ownership across revisions and rejects an update that would +move an existing owned deployment to another project or workflow. Deployment +records created before ownership support remain valid and report empty fields. + The editor persists deployment requirements in workflow metadata. A remote robot deployment currently targets one physical robot. Additional robots use separate workflows and are deployed one at a time to the computer connected to diff --git a/docs/projects.md b/docs/projects.md new file mode 100644 index 0000000..bd037a6 --- /dev/null +++ b/docs/projects.md @@ -0,0 +1,175 @@ +# Projects + +Projects are the durable workspace above individual Blacknode workflows. A +project groups the workflows and devices used to move a robotics application +from building and calibration through deployment and operation. + +Workflow tabs remain the place where graphs are edited. Opening a project +restores its saved workflows as tabs and adds project context around them. + +## Projects v1 + +Projects v1 provides: + +- local project creation, naming, description, renaming, and removal; +- links to any number of saved workflows; +- links to any number of paired devices; +- 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 +- 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. + +## Ownership + +A project stores references, not copies: + +| Resource | Owner | Project reference | +|---|---|---| +| Workflow graph | `workflows/.json` | workflow slug | +| Paired device and credentials | device registry | device ID | +| 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 | + +This keeps credentials and physical-hardware calibration bound to their +existing secure and hardware-aware owners. + +## Stored record + +The local project registry uses schema version 1: + +```json +{ + "schema_version": 1, + "projects": [ + { + "id": "leader-follower-demo", + "name": "Leader Follower Demo", + "description": "SO-ARM leader and follower bring-up", + "workflow_slugs": [ + "so-arm101-leader-deploy", + "so-arm102-follower-deploy" + ], + "device_ids": [ + "alex-desktop-usb-...31481...", + "alex-desktop-usb-...31741..." + ], + "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" + } + ] +} +``` + +Project IDs are stable. Renaming a project does not change its ID. Workflow +renames update project references. Missing or deleted resources remain visible +as missing references until the user removes or replaces them, avoiding silent +changes to project intent. + +`active_workflow_slug` is the workflow selected after opening the project. It +must be one of `workflow_slugs`; when it is absent the first linked workflow is +used. + +## HTTP API + +The editor server exposes: + +- `GET /projects` +- `POST /projects` +- `GET /projects/{project_id}` +- `PATCH /projects/{project_id}` +- `DELETE /projects/{project_id}` + +Create and update requests use the persisted fields above. Read responses also +hydrate workflow and device references with their current names, availability, +node types, and non-secret device information. + +The API tolerates resources that are temporarily unavailable. A missing +workflow or device is returned with `exists: false` instead of preventing the +rest of the project from opening. + +## Deployment ownership + +New remote deployment revisions may declare both: + +```json +{ + "project_id": "leader-follower-demo", + "workflow_slug": "so-arm101-leader-deploy" +} +``` + +The editor server accepts the pair only when the project exists, the workflow +is linked to it, and the target device is linked to it. The target runtime +persists both fields on the deployment record and revision manifest. A revision +with no ownership fields preserves the deployment's existing ownership. + +An existing owned deployment cannot be reassigned to another project or +workflow by staging an update. Stage a new deployment for the new owner. +Previously created deployment records remain compatible and are returned with +empty ownership fields; the editor describes them as unassigned and keeps them +available in Deployments. + +The Projects overview includes only deployments whose `project_id` exactly +matches the open project. This makes lifecycle status and deployment counts +project-specific rather than inferred from every process on a linked device. + +## Lifecycle evidence + +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 | +| 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. + +The next action is selected from the first actionable gap: + +1. link a saved workflow; +2. link a paired device; +3. select calibration when a robot workflow requires it; +4. open the linked workflows; +5. deploy or monitor a running deployment. + +## Editor behavior + +- **Projects** is a top-level editor panel beside Workflows and Devices. +- **Workflows** continues to manage individual saved graphs. +- Opening a project opens every available linked workflow as a tab and selects + `active_workflow_slug`. +- Existing unrelated or dirty tabs are preserved. +- The breadcrumb reads `Project / Workflow` only when the active workflow + belongs to the active project. +- A project can link the current tab only after that workflow has been saved. +- Device and workflow selectors show existing names as well as stable IDs or + slugs so similarly named robots remain distinguishable. + +## 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. + +Those additions should preserve the core boundary: applications and workflows +depend on capabilities and stable resource identities, while providers retain +ownership of credentials, physical hardware, and generated artifacts. diff --git a/editor-server/project_store.py b/editor-server/project_store.py new file mode 100644 index 0000000..de00486 --- /dev/null +++ b/editor-server/project_store.py @@ -0,0 +1,252 @@ +"""Local project registry for grouping workflows and paired devices.""" + +from __future__ import annotations + +import json +import os +import re +import threading +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + + +_ID_RE = re.compile(r"[^a-z0-9]+") +_WORKFLOW_SLUG_RE = re.compile(r"[a-zA-Z0-9_-]{1,60}") + + +class ProjectStoreError(RuntimeError): + """A project record could not be read, validated, or persisted.""" + + +def _iso_now() -> str: + return datetime.now(timezone.utc).isoformat() + + +def _slug(value: str) -> str: + return _ID_RE.sub("-", value.strip().lower()).strip("-")[:60] or "project" + + +def _unique_strings(values: list[str] | None, *, field: str) -> list[str]: + result: list[str] = [] + seen: set[str] = set() + for value in values or []: + clean = str(value or "").strip() + if not clean: + continue + if len(clean) > 240 or any(ord(char) < 32 for char in clean): + raise ProjectStoreError(f"{field} contains an invalid value.") + if field == "workflow_slugs" and not _WORKFLOW_SLUG_RE.fullmatch(clean): + raise ProjectStoreError(f"Invalid workflow slug '{clean}'.") + if clean not in seen: + result.append(clean) + seen.add(clean) + return result + + +class ProjectStore: + """Persist stable project records without copying linked resources.""" + + def __init__(self, path: Path) -> None: + self.path = Path(path) + self._lock = threading.RLock() + + def list(self) -> list[dict[str, Any]]: + with self._lock: + return [ + dict(record) + for record in sorted( + self._load().values(), + key=lambda item: ( + str(item.get("updated_at", "")), + str(item.get("name", "")).lower(), + ), + reverse=True, + ) + ] + + def get(self, project_id: str) -> dict[str, Any] | None: + with self._lock: + record = self._load().get(project_id) + return dict(record) if record is not None else None + + def create( + self, + *, + name: str, + description: str = "", + workflow_slugs: list[str] | None = None, + device_ids: list[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_active = str(active_workflow_slug or "").strip() or None + if clean_active and clean_active not in clean_workflows: + raise ProjectStoreError( + "active_workflow_slug must be one of workflow_slugs." + ) + with self._lock: + records = self._load() + base_id = _slug(clean_name) + project_id = base_id + suffix = 2 + while project_id in records: + project_id = f"{base_id[: 60 - len(str(suffix)) - 1]}-{suffix}" + suffix += 1 + now = _iso_now() + record = { + "id": project_id, + "name": clean_name, + "description": clean_description, + "workflow_slugs": clean_workflows, + "device_ids": clean_devices, + "active_workflow_slug": ( + clean_active + or (clean_workflows[0] if clean_workflows else None) + ), + "created_at": now, + "updated_at": now, + } + records[project_id] = record + self._save(records) + return dict(record) + + def update( + self, + project_id: str, + *, + name: str | None = None, + description: str | None = None, + workflow_slugs: list[str] | None = None, + device_ids: list[str] | None = None, + active_workflow_slug: str | None = None, + update_active_workflow: bool = False, + ) -> dict[str, Any]: + with self._lock: + records = self._load() + record = records.get(project_id) + if record is None: + raise KeyError(project_id) + if name is not None: + record["name"] = self._clean_name(name) + if description is not None: + record["description"] = self._clean_description(description) + if workflow_slugs is not None: + record["workflow_slugs"] = _unique_strings( + workflow_slugs, + field="workflow_slugs", + ) + if device_ids is not None: + record["device_ids"] = _unique_strings( + device_ids, + field="device_ids", + ) + workflows = list(record.get("workflow_slugs") or []) + if update_active_workflow: + clean_active = str(active_workflow_slug or "").strip() or None + if clean_active and clean_active not in workflows: + raise ProjectStoreError( + "active_workflow_slug must be one of workflow_slugs." + ) + record["active_workflow_slug"] = clean_active + if record.get("active_workflow_slug") not in workflows: + record["active_workflow_slug"] = workflows[0] if workflows else None + record["updated_at"] = _iso_now() + records[project_id] = record + self._save(records) + return dict(record) + + def delete(self, project_id: str) -> bool: + with self._lock: + records = self._load() + if project_id not in records: + return False + del records[project_id] + self._save(records) + return True + + 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: + return + with self._lock: + records = self._load() + changed = False + now = _iso_now() + for record in records.values(): + workflows = list(record.get("workflow_slugs") or []) + if previous_slug not in workflows: + continue + replaced = [ + next_slug if slug == previous_slug else slug + for slug in workflows + ] + record["workflow_slugs"] = _unique_strings( + replaced, + field="workflow_slugs", + ) + if record.get("active_workflow_slug") == previous_slug: + record["active_workflow_slug"] = next_slug + record["updated_at"] = now + changed = True + if changed: + self._save(records) + + 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 ProjectStoreError( + f"Could not read local project registry at {self.path}: {exc}" + ) from exc + projects = payload.get("projects", []) if isinstance(payload, dict) else [] + if not isinstance(projects, list): + raise ProjectStoreError("Local project registry has an invalid format.") + records: dict[str, dict[str, Any]] = {} + for item in projects: + if not isinstance(item, dict): + continue + project_id = str(item.get("id") or "").strip() + if project_id: + records[project_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, + "projects": 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 _clean_name(value: str) -> str: + clean = str(value or "").strip() + if not clean: + raise ProjectStoreError("Project name is required.") + if len(clean) > 120 or any(ord(char) < 32 for char in clean): + raise ProjectStoreError("Project name is invalid.") + return clean + + @staticmethod + def _clean_description(value: str) -> str: + clean = str(value or "").strip() + if len(clean) > 2000: + raise ProjectStoreError( + "Project description must be 2,000 characters or fewer." + ) + return clean diff --git a/editor-server/server.py b/editor-server/server.py index 92e31b6..27d932f 100644 --- a/editor-server/server.py +++ b/editor-server/server.py @@ -54,6 +54,7 @@ from blacknode.workflow import validate_workflow as validate_bn_workflow from device_registry import DeviceRegistry, DeviceRegistryError, HardwareDeviceClient +from project_store import ProjectStore, ProjectStoreError from run_store import RunStore app = FastAPI(title="Blacknode Editor Server") @@ -112,6 +113,8 @@ def _reap_orphaned_stream_servers() -> None: _deployment_store = DeploymentStore(_DEPLOYMENTS_DIR) _DEVICES_PATH = Path(__file__).resolve().parents[1] / ".blacknode" / "devices.json" _device_registry = DeviceRegistry(_DEVICES_PATH) +_PROJECTS_PATH = Path(__file__).resolve().parents[1] / ".blacknode" / "projects.json" +_project_store = ProjectStore(_PROJECTS_PATH) _save_timer: threading.Timer | None = None _SUBGRAPH_NODE_TYPES = {"Subnet", "SubnetAsTool", "VisualAgentLoop"} _TOOLBOX_NODE_TYPES = {"ToolBox"} @@ -308,6 +311,20 @@ class SaveWorkflowReq(BaseModel): class RenameWorkflowReq(BaseModel): name: str +class CreateProjectReq(BaseModel): + name: str + description: str = "" + workflow_slugs: list[str] = [] + device_ids: list[str] = [] + active_workflow_slug: str | None = None + +class UpdateProjectReq(BaseModel): + name: str | None = None + description: str | None = None + workflow_slugs: list[str] | None = None + device_ids: list[str] | None = None + active_workflow_slug: str | None = None + class NewWorkflowTabReq(BaseModel): name: str = "Untitled" @@ -375,6 +392,8 @@ class RemoteDeployReq(BaseModel): workflow_hash: str start: bool = False deployment_id: str | None = None + project_id: str | None = None + workflow_slug: str | None = None class RemoteRollbackReq(BaseModel): start: bool = False @@ -4171,8 +4190,51 @@ def list_device_deployments(device_id: str): raise HTTPException(502, str(exc)) from exc +def _remote_deployment_owner( + device_id: str, + req: RemoteDeployReq, +) -> dict[str, str]: + project_id = str(req.project_id or "").strip() + workflow_slug = str(req.workflow_slug or "").strip() + if not project_id and not workflow_slug: + return {} + if not project_id or not workflow_slug: + raise HTTPException( + 400, + "Deployment ownership requires both project_id and workflow_slug.", + ) + try: + project = _project_store.get(project_id) + except ProjectStoreError as exc: + raise HTTPException(400, str(exc)) from exc + if project is None: + raise HTTPException(404, f"Project '{project_id}' not found") + if workflow_slug not in project.get("workflow_slugs", []): + raise HTTPException( + 409, + f"Workflow '{workflow_slug}' is not linked to project " + f"'{project.get('name') or project_id}'.", + ) + if device_id not in project.get("device_ids", []): + raise HTTPException( + 409, + f"Device '{device_id}' is not linked to project " + f"'{project.get('name') or project_id}'. Link it in Projects before staging.", + ) + if not os.path.exists(_workflow_path(workflow_slug)): + raise HTTPException( + 409, + f"Workflow '{workflow_slug}' is no longer saved. Save it again before staging.", + ) + return { + "project_id": project_id, + "workflow_slug": workflow_slug, + } + + @app.post("/devices/{device_id}/deployments") def stage_device_deployment(device_id: str, req: RemoteDeployReq): + deployment_owner = _remote_deployment_owner(device_id, req) workflow = _device_deployment_workflow() current_hash = _device_deployment_hash(workflow) requested_hash = req.workflow_hash.strip().lower() @@ -4206,6 +4268,15 @@ def stage_device_deployment(device_id: str, req: RemoteDeployReq): name = req.name.strip() or str(workflow.get("name") or "Deployed graph") runtime_manifest = preflight.get("runtime") or {} + if ( + deployment_owner + and "deployment_ownership_v1" not in set(runtime_manifest.get("features") or []) + ): + raise HTTPException( + 409, + "This target runtime cannot record Project ownership yet. Update " + "blacknode-runtime to 0.3.8 or newer, restart it, and check setup again.", + ) payload: dict[str, Any] = { "name": name, "script": script, @@ -4222,6 +4293,7 @@ def stage_device_deployment(device_id: str, req: RemoteDeployReq): "runtime_protocol_version": runtime_manifest.get("protocol_version"), "target_device_id": device_id, "created_at": datetime.now().astimezone().isoformat(timespec="seconds"), + **deployment_owner, }, } if req.deployment_id: @@ -5934,6 +6006,7 @@ def _save_workflow(name: str, previous_slug: str | None = None): old_path = _workflow_path(previous_slug) if os.path.exists(old_path): os.remove(old_path) + _project_store.replace_workflow_slug(previous_slug, slug) return {"ok": True, "slug": slug} def _restore_session( @@ -6054,6 +6127,192 @@ def _insert_workflow(node_meta: dict, edges: list): }) +_PROJECT_COLLECT_NODES = { + "EpisodeRecorder", +} +_PROJECT_TRAIN_NODES = { + "ACTTraining", +} +_PROJECT_SIMULATE_NODES = { + "IsaacPolicySafetyGate", + "IsaacPolicyBridge", + "IsaacPolicyRuntime", +} +_PROJECT_ROBOT_NODE_MARKERS = ( + "robot", + "servo", + "joint", + "leaderfollower", + "policydeployment", +) + + +def _project_workflow_reference(slug: str) -> dict[str, Any]: + path = _workflow_path(slug) + if not os.path.exists(path): + return { + "slug": slug, + "name": slug, + "exists": False, + "node_types": [], + "stages": [], + "requires_calibration": False, + "calibration": None, + } + try: + data = _read_workflow_file(path) + except (OSError, json.JSONDecodeError, HTTPException): + return { + "slug": slug, + "name": slug, + "exists": False, + "node_types": [], + "stages": [], + "requires_calibration": False, + "calibration": None, + } + node_meta = data.get("node_meta") + node_types = sorted({ + str(meta.get("type")) + for meta in (node_meta.values() if isinstance(node_meta, dict) else []) + if isinstance(meta, dict) and meta.get("type") + }) + metadata = data.get("metadata") if isinstance(data.get("metadata"), dict) else {} + required_capabilities = [ + str(value) + for value in metadata.get("required_capabilities", []) + if isinstance(value, str) + ] + raw_calibration = metadata.get("device_calibration") + calibration = ( + { + key: str(raw_calibration[key]) + for key in ("profile_id", "hardware_id") + if raw_calibration.get(key) + } + if isinstance(raw_calibration, dict) + else None + ) + calibration = calibration or None + lowered_types = [node_type.lower() for node_type in node_types] + requires_calibration = ( + "joint_group" in required_capabilities + or any( + marker in node_type + for node_type in lowered_types + for marker in _PROJECT_ROBOT_NODE_MARKERS + ) + ) + stages = [] + if any(node_type in _PROJECT_COLLECT_NODES for node_type in node_types): + stages.append("collect") + if any(node_type in _PROJECT_TRAIN_NODES for node_type in node_types): + stages.append("train") + if any(node_type in _PROJECT_SIMULATE_NODES for node_type in node_types): + stages.append("simulate") + return { + "slug": slug, + "name": data.get("name", slug), + "saved_at": data.get("saved_at", ""), + "exists": True, + "node_types": node_types, + "stages": stages, + "requires_calibration": requires_calibration, + "calibration": calibration, + } + + +def _project_payload(record: dict[str, Any]) -> dict[str, Any]: + workflows = [ + _project_workflow_reference(str(slug)) + for slug in record.get("workflow_slugs", []) + ] + devices = [] + for device_id in record.get("device_ids", []): + device = _device_registry.get_public(str(device_id)) + devices.append( + {**device, "exists": True} + if device is not None + else {"id": str(device_id), "name": str(device_id), "exists": False} + ) + return { + **record, + "workflows": workflows, + "devices": devices, + } + + +def _project_error(exc: ProjectStoreError) -> HTTPException: + return HTTPException(400, str(exc)) + + +@app.get("/projects") +def list_projects(): + try: + return [_project_payload(record) for record in _project_store.list()] + except ProjectStoreError as exc: + raise _project_error(exc) from exc + + +@app.post("/projects") +def create_project(req: CreateProjectReq): + try: + return _project_payload(_project_store.create( + name=req.name, + description=req.description, + workflow_slugs=req.workflow_slugs, + device_ids=req.device_ids, + active_workflow_slug=req.active_workflow_slug, + )) + except ProjectStoreError as exc: + raise _project_error(exc) from exc + + +@app.get("/projects/{project_id}") +def get_project(project_id: str): + try: + record = _project_store.get(project_id) + except ProjectStoreError as exc: + raise _project_error(exc) from exc + if record is None: + raise HTTPException(404, f"Project '{project_id}' not found") + return _project_payload(record) + + +@app.patch("/projects/{project_id}") +def update_project(project_id: str, req: UpdateProjectReq): + fields_set = ( + req.model_fields_set + if hasattr(req, "model_fields_set") + else req.__fields_set__ + ) + try: + return _project_payload(_project_store.update( + project_id, + name=req.name, + description=req.description, + workflow_slugs=req.workflow_slugs, + device_ids=req.device_ids, + active_workflow_slug=req.active_workflow_slug, + update_active_workflow="active_workflow_slug" in fields_set, + )) + except KeyError as exc: + raise HTTPException(404, f"Project '{project_id}' not found") from exc + except ProjectStoreError as exc: + raise _project_error(exc) from exc + + +@app.delete("/projects/{project_id}") +def delete_project(project_id: str): + try: + deleted = _project_store.delete(project_id) + except ProjectStoreError as exc: + raise _project_error(exc) from exc + if not deleted: + raise HTTPException(404, f"Project '{project_id}' not found") + return {"ok": True} + + @app.get("/workflows") def list_workflows(): os.makedirs(_WORKFLOWS_DIR, exist_ok=True) @@ -6260,6 +6519,7 @@ def rename_workflow(slug: str, req: RenameWorkflowReq): json.dump(data, f, indent=2) if next_slug != slug: os.remove(path) + _project_store.replace_workflow_slug(slug, next_slug) return {"slug": next_slug, "name": clean_name, "saved_at": data["saved_at"]} diff --git a/editor/src/App.tsx b/editor/src/App.tsx index 531d8e4..1ad8426 100644 --- a/editor/src/App.tsx +++ b/editor/src/App.tsx @@ -112,7 +112,7 @@ function nodeIdAtScreenPoint(point: { x: number; y: number }): string | null { export default function App() { const { nodes, edges, nodeTypes, nodeDefs, serverOk, serverError, cookLog, cookActive, cookStatusHidden, - tabs, activeTabId, + tabs, activeTabId, activeProject, onNodesChange, onEdgesChange, onConnect: storeOnConnect, disconnectEdge, reconnectEdge, addNode, selectNode, loadNodeTypes, loadGraph, loadApiKeys, loadApiKeyStatus, loadCustomModels, loadLearnedNodes, loadDriverStatus, loadRuntimeNodeOutputs, loadDrivers, addNodeFromConnection, copySelection, pasteClipboard, @@ -1213,6 +1213,31 @@ export default function App() { gap: 2, overflowX: 'auto', }}> + {activeProject && activeTab?.slug && activeProject.workflowSlugs.includes(activeTab.slug) && ( +
+ {activeProject.name} + / +
+ )} {tabs.map(tab => { const active = tab.id === activeTabId const editing = editingTabId === tab.id diff --git a/editor/src/api.ts b/editor/src/api.ts index f233794..58658c7 100644 --- a/editor/src/api.ts +++ b/editor/src/api.ts @@ -153,6 +153,39 @@ export interface WorkflowMetadata extends Record { } } +export interface ProjectWorkflow { + slug: string + name: string + saved_at?: string + exists: boolean + node_types: string[] + stages: Array<'collect' | 'train' | 'simulate'> + requires_calibration: boolean + calibration: { + profile_id?: string + hardware_id?: string + } | null +} + +export interface ProjectDevice extends Partial { + id: string + name: string + exists: boolean +} + +export interface Project { + id: string + name: string + description: string + workflow_slugs: string[] + device_ids: string[] + active_workflow_slug: string | null + created_at: string + updated_at: string + workflows: ProjectWorkflow[] + devices: ProjectDevice[] +} + export interface GraphSnapshot { nodes: any[] edges: any[] @@ -247,6 +280,9 @@ export type RemoteDeploymentState = 'staged' | 'running' | 'stopped' | 'exited' export interface RemoteDeployment { id: string name: string + target_device_id?: string + project_id?: string + workflow_slug?: string state: RemoteDeploymentState staged_revision: string active_revision: string | null @@ -703,6 +739,8 @@ export const api = { workflowHash: string, start = false, deploymentId?: string, + projectId?: string, + workflowSlug?: string, ) => req<{ deployment: RemoteDeployment; workflow_hash: string; started: boolean }>( 'POST', @@ -712,6 +750,8 @@ export const api = { workflow_hash: workflowHash, start, deployment_id: deploymentId ?? null, + project_id: projectId ?? null, + workflow_slug: workflowSlug ?? null, }, 600000, ), @@ -781,6 +821,29 @@ export const api = { deleteWorkflow: (slug: string) => req('DELETE', `/workflows/${encodeURIComponent(slug)}`), + listProjects: () => + req('GET', '/projects'), + getProject: (projectId: string) => + req('GET', `/projects/${encodeURIComponent(projectId)}`), + createProject: (payload: { + name: string + description?: string + workflow_slugs?: string[] + device_ids?: string[] + active_workflow_slug?: string | null + }) => + req('POST', '/projects', payload), + updateProject: ( + projectId: string, + payload: Partial>, + ) => + req('PATCH', `/projects/${encodeURIComponent(projectId)}`, payload), + deleteProject: (projectId: string) => + req<{ ok: boolean }>('DELETE', `/projects/${encodeURIComponent(projectId)}`), + listTemplates: () => req('GET', '/templates'), loadTemplate: (slug: string) => diff --git a/editor/src/components/DeploymentsPanel.tsx b/editor/src/components/DeploymentsPanel.tsx index 26711cc..3c125ff 100644 --- a/editor/src/components/DeploymentsPanel.tsx +++ b/editor/src/components/DeploymentsPanel.tsx @@ -106,6 +106,20 @@ export default function DeploymentsPanel({ onOpenTemplates }: DeploymentsPanelPr const activeTabId = useStore(s => s.activeTabId) const activeTab = tabs.find(tab => tab.id === activeTabId) const activeDeploymentName = deploymentNameFromTab(activeTab?.name) + const activeProject = useStore(s => s.activeProject) + const deploymentProject = ( + activeProject + && activeTab?.slug + && activeProject.workflowSlugs.includes(activeTab.slug) + ) + ? { + id: activeProject.id, + name: activeProject.name, + workflowSlug: activeTab.slug, + workflowName: activeTab.name, + deviceIds: activeProject.deviceIds, + } + : null const switchTab = useStore(s => s.switchTab) const workflowRevision = useStore(s => s.workflowRevision) const workflowMetadata = useStore(s => s.workflowMetadata) @@ -477,6 +491,31 @@ export default function DeploymentsPanel({ onOpenTemplates }: DeploymentsPanelPr existing?: RemoteDeployment, ) => { if (!selectedDeviceId || !preflight?.ready) return + if ( + existing?.project_id + && ( + !deploymentProject + || deploymentProject.id !== existing.project_id + || deploymentProject.workflowSlug !== existing.workflow_slug + ) + ) { + setError( + `Deployment "${existing.name}" belongs to project ` + + `"${existing.project_id}" / "${existing.workflow_slug}". Open that ` + + 'workflow from its Project before staging an update.', + ) + return + } + if ( + deploymentProject + && !deploymentProject.deviceIds.includes(selectedDeviceId) + ) { + setError( + `The selected device is not linked to project "${deploymentProject.name}". ` + + 'Link it in Projects before staging this workflow.', + ) + return + } const name = ( existing?.name || remoteDeploymentName.trim() @@ -495,6 +534,8 @@ export default function DeploymentsPanel({ onOpenTemplates }: DeploymentsPanelPr preflight.workflow.hash, start, existing?.id, + deploymentProject?.id, + deploymentProject?.workflowSlug, ) setRemoteOpenId(result.deployment.id) await refreshRemote() @@ -568,6 +609,12 @@ export default function DeploymentsPanel({ onOpenTemplates }: DeploymentsPanelPr ) : (
Pair a Raspberry Pi in the Devices tab first.
)} +
+ Deployment ownership:{' '} + {deploymentProject + ? `${deploymentProject.name} / ${deploymentProject.workflowName}` + : 'Unassigned. Open this workflow from a Project to attach deployment history.'} +
{calibrationMatchedDevice && selectedDeviceId === calibrationMatchedDevice.id && (
Matched to the graph calibration: @@ -1059,6 +1106,9 @@ function RemoteDeploymentRow({ const isRunning = deployment.state === 'running' const canRollback = deployment.revisions.length > 1 const badges = [ + deployment.project_id + ? `project ${deployment.project_id} / ${deployment.workflow_slug || 'workflow'}` + : 'unassigned', `${deployment.revisions.length} revision${deployment.revisions.length === 1 ? '' : 's'}`, `staged ${deployment.staged_revision}`, deployment.active_revision ? `active ${deployment.active_revision}` : null, diff --git a/editor/src/components/NodePalette.tsx b/editor/src/components/NodePalette.tsx index b04c189..9c00f1d 100644 --- a/editor/src/components/NodePalette.tsx +++ b/editor/src/components/NodePalette.tsx @@ -16,6 +16,7 @@ import ConsolePanel from './ConsolePanel' import ScriptEditor from './ScriptEditor' import TemplateGallery from './TemplateGallery' import WorkflowManager from './WorkflowManager' +import ProjectPanel from './ProjectPanel' // Curated ordering for the built-in categories; anything else sorts by name. const CATEGORY_ORDER = Object.keys(CATEGORIES) @@ -25,12 +26,13 @@ const CATEGORY_ORDER = Object.keys(CATEGORIES) interface PaletteSubGroup { name: string; color: string; types: string[] } interface PaletteGroup { name: string; color: string; subgroups: PaletteSubGroup[]; count: number } -type Tab = 'nodes' | 'templates' | 'workflows' | 'script' | 'runs' | 'runtime' | 'console' | 'deployments' | 'devices' | 'learned' | 'mcp' | 'packages' +type Tab = 'nodes' | 'projects' | 'templates' | 'workflows' | 'script' | 'runs' | 'runtime' | 'console' | 'deployments' | 'devices' | 'learned' | 'mcp' | 'packages' const TOP_BAR_H = 44 const RAIL_W = 78 const PANEL_DEFAULT_W = 240 const PANEL_MIN_W = 188 +const PANEL_PROJECT_W = 420 const PANEL_DEPLOY_W = 460 const PANEL_EXPANDED_W = 760 const PANEL_MAX_W = 860 @@ -68,6 +70,12 @@ const ICON_WORKFLOWS = ( ) +const ICON_PROJECTS = ( + + + + +) const ICON_SCRIPT = ( @@ -135,6 +143,7 @@ const ICON_CONSOLE = ( const TABS: { id: Tab; label: string; icon: React.ReactNode }[] = [ { id: 'nodes', label: 'Nodes', icon: ICON_NODES }, + { id: 'projects', label: 'Projects', icon: ICON_PROJECTS }, { id: 'templates', label: 'Templates', icon: ICON_TEMPLATES }, { id: 'workflows', label: 'Workflows', icon: ICON_WORKFLOWS }, { id: 'script', label: 'Script', icon: ICON_SCRIPT }, @@ -184,11 +193,16 @@ export default function NodePalette() { setTemplateSearch('') setTemplateOpenInNewTab(false) } - if (tab === 'deployments' && panelWidth < PANEL_DEPLOY_W) { - setPanelWidth(clampPanelWidth(PANEL_DEPLOY_W)) - } else if (tab !== 'deployments' && panelExpanded) { - setPanelWidth(clampPanelWidth(compactWidthRef.current)) - setPanelExpanded(false) + if (tab === 'deployments') { + if (panelWidth < PANEL_DEPLOY_W) { + setPanelWidth(clampPanelWidth(PANEL_DEPLOY_W)) + } + } else { + const compactWidth = panelExpanded ? compactWidthRef.current : panelWidth + setPanelWidth(clampPanelWidth( + tab === 'projects' ? Math.max(compactWidth, PANEL_PROJECT_W) : compactWidth, + )) + if (panelExpanded) setPanelExpanded(false) } } @@ -597,7 +611,15 @@ export default function NodePalette() {
-
+
{TABS.map(tab => { const active = activeTab === tab.id return ( @@ -769,6 +791,9 @@ export default function NodePalette() { {/* ── WORKFLOWS ── */} {activeTab === 'workflows' && } + {/* ── PROJECTS ── */} + {activeTab === 'projects' && } + {/* ── SCRIPT ── */} {activeTab === 'script' && (
diff --git a/editor/src/components/ProjectPanel.css b/editor/src/components/ProjectPanel.css new file mode 100644 index 0000000..2458a94 --- /dev/null +++ b/editor/src/components/ProjectPanel.css @@ -0,0 +1,553 @@ +.bn-project-panel { + display: flex; + flex: 1; + min-height: 0; + flex-direction: column; + overflow: hidden; + color: var(--tx1); + font-family: var(--font-ui); + font-size: 14px; +} + +.bn-project-panel button, +.bn-project-panel input, +.bn-project-panel textarea, +.bn-project-panel select { + font: inherit; +} + +.bn-project-panel button { + min-height: 32px; + padding: 6px 10px; + border: 1px solid var(--line2); + border-radius: 7px; + background: var(--lift); + color: var(--tx2); + cursor: pointer; +} + +.bn-project-panel button:hover:not(:disabled) { + border-color: var(--accent); + color: var(--tx1); +} + +.bn-project-panel button:disabled { + cursor: default; + opacity: 0.45; +} + +.bn-project-panel button.primary { + border-color: var(--accent); + background: var(--accent); + color: #fff; + font-weight: 700; +} + +.bn-project-panel button.danger-text { + background: transparent; + color: #c87b78; +} + +.bn-project-panel input, +.bn-project-panel textarea, +.bn-project-panel select { + width: 100%; + box-sizing: border-box; + border: 1px solid var(--line2); + border-radius: 7px; + outline: none; + background: var(--lift); + color: var(--tx1); + padding: 8px 9px; +} + +.bn-project-panel input:focus, +.bn-project-panel textarea:focus, +.bn-project-panel select:focus { + border-color: var(--accent); +} + +.bn-project-panel textarea { + resize: vertical; +} + +.bn-project-panel label { + display: flex; + flex-direction: column; + gap: 5px; + color: var(--tx2); + font-size: 13px; + font-weight: 700; +} + +.bn-project-panel label > span { + color: var(--tx3); + font-weight: 500; +} + +.bn-project-list-header, +.bn-project-toolbar { + display: flex; + flex-shrink: 0; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 13px 14px; + border-bottom: 1px solid var(--line); +} + +.bn-project-list-header h2, +.bn-project-hero h2 { + margin: 0; + color: var(--tx1); + font-size: 20px; + line-height: 1.2; +} + +.bn-project-list-header p { + margin: 4px 0 0; + color: var(--tx3); + font-size: 13px; + line-height: 1.4; +} + +.bn-project-create { + display: flex; + flex-shrink: 0; + flex-direction: column; + gap: 10px; + padding: 12px 14px; + border-bottom: 1px solid var(--line); + background: color-mix(in srgb, var(--accent) 5%, var(--bg)); +} + +.bn-project-list, +.bn-project-scroll { + flex: 1; + min-height: 0; + overflow-y: auto; + overscroll-behavior: contain; +} + +.bn-project-list { + padding: 10px; +} + +.bn-project-card { + display: flex; + width: 100%; + min-height: 0 !important; + flex-direction: column; + align-items: stretch; + gap: 7px; + margin-bottom: 8px; + padding: 12px !important; + text-align: left; +} + +.bn-project-card-title { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} + +.bn-project-card-title strong { + overflow: hidden; + color: var(--tx1); + font-size: 15px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.bn-project-card-title span, +.bn-project-active, +.bn-project-local { + padding: 2px 7px; + border: 1px solid color-mix(in srgb, var(--ok) 50%, var(--line)); + border-radius: 999px; + background: color-mix(in srgb, var(--ok) 12%, transparent); + color: var(--ok); + font-size: 11px; + font-style: normal; + font-weight: 800; + text-transform: uppercase; +} + +.bn-project-local { + border-color: var(--line2); + background: var(--lift); + color: var(--tx3); +} + +.bn-project-card p { + display: -webkit-box; + overflow: hidden; + margin: 0; + color: var(--tx2); + line-height: 1.4; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; +} + +.bn-project-card-stats { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.bn-project-card-stats span { + padding: 3px 7px; + border-radius: 5px; + background: var(--bg); + color: var(--tx2); + font-size: 12px; +} + +.bn-project-card em, +.bn-project-updated { + color: var(--tx3); + font-size: 11px; + font-style: normal; +} + +.bn-project-onboarding { + display: flex; + flex-direction: column; + align-items: center; + padding: 34px 16px; + text-align: center; +} + +.bn-project-onboarding-icon { + margin-bottom: 10px; + color: var(--accent); + font-size: 38px; +} + +.bn-project-onboarding h3 { + margin: 0 0 8px; + font-size: 17px; +} + +.bn-project-onboarding p { + max-width: 310px; + margin: 0 0 18px; + color: var(--tx2); + line-height: 1.5; +} + +.bn-project-link-button, +.bn-project-refresh { + border-color: transparent !important; + background: transparent !important; +} + +.bn-project-scroll { + padding: 12px; +} + +.bn-project-error { + margin: 10px 12px; + padding: 9px 10px; + border: 1px solid color-mix(in srgb, #d16d6a 55%, var(--line)); + border-radius: 7px; + background: color-mix(in srgb, #d16d6a 10%, transparent); + color: #d98a87; + line-height: 1.4; +} + +.bn-project-scroll > .bn-project-error { + margin: 0 0 10px; +} + +.bn-project-hero, +.bn-project-section, +.bn-project-next { + margin-bottom: 10px; + border: 1px solid var(--line2); + border-radius: 10px; + background: var(--lift); +} + +.bn-project-hero, +.bn-project-section { + padding: 13px; +} + +.bn-project-title-row, +.bn-project-section-title { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 10px; +} + +.bn-project-eyebrow { + margin-bottom: 3px; + color: var(--accent); + font-size: 11px; + font-weight: 800; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.bn-project-hero p { + margin: 9px 0 0; + color: var(--tx2); + line-height: 1.45; +} + +.bn-project-actions { + display: flex; + flex-wrap: wrap; + gap: 7px; + margin-top: 12px; +} + +.bn-project-next { + display: flex; + flex-direction: column; + gap: 4px; + padding: 11px 13px; + border-color: color-mix(in srgb, var(--accent) 45%, var(--line)); + background: color-mix(in srgb, var(--accent) 9%, var(--lift)); +} + +.bn-project-next span { + color: var(--accent); + font-size: 11px; + font-weight: 800; + letter-spacing: 0.06em; + text-transform: uppercase; +} + +.bn-project-next strong { + font-size: 14px; + line-height: 1.4; +} + +.bn-project-section-title { + align-items: baseline; + margin-bottom: 10px; +} + +.bn-project-section-title h3 { + margin: 0; + font-size: 15px; +} + +.bn-project-section-title span { + color: var(--tx3); + font-size: 11px; +} + +.bn-project-lifecycle { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 7px; +} + +.bn-project-stage { + display: flex; + min-width: 0; + gap: 8px; + padding: 8px; + border: 1px solid var(--line); + border-radius: 7px; + background: var(--bg); +} + +.bn-project-stage-marker { + display: grid; + width: 22px; + height: 22px; + flex: 0 0 22px; + place-items: center; + border-radius: 50%; + background: var(--lift); + color: var(--tx3); + font-size: 11px; + font-weight: 800; +} + +.bn-project-stage.complete .bn-project-stage-marker { + background: color-mix(in srgb, var(--ok) 18%, var(--lift)); + color: var(--ok); +} + +.bn-project-stage.available .bn-project-stage-marker { + background: color-mix(in srgb, var(--accent) 18%, var(--lift)); + color: var(--accent); +} + +.bn-project-stage.waiting .bn-project-stage-marker { + background: color-mix(in srgb, #d0a14d 16%, var(--lift)); + color: #d0a14d; +} + +.bn-project-stage strong, +.bn-project-stage span { + display: block; +} + +.bn-project-stage strong { + margin-bottom: 2px; + font-size: 13px; +} + +.bn-project-stage span { + color: var(--tx3); + font-size: 11px; + line-height: 1.35; +} + +.bn-project-add-row { + display: flex; + gap: 7px; + margin-bottom: 8px; +} + +.bn-project-add-row select { + min-width: 0; + flex: 1; +} + +.bn-project-wide-button { + width: 100%; + margin-bottom: 8px; +} + +.bn-project-hint { + margin-bottom: 8px; + color: var(--tx3); + font-size: 12px; + line-height: 1.4; +} + +.bn-project-resource-list { + display: flex; + flex-direction: column; + gap: 7px; +} + +.bn-project-resource { + display: flex; + align-items: center; + justify-content: space-between; + gap: 9px; + padding: 9px; + border: 1px solid var(--line); + border-radius: 7px; + background: var(--bg); +} + +.bn-project-resource.missing { + border-style: dashed; + opacity: 0.8; +} + +.bn-project-resource-main { + min-width: 0; +} + +.bn-project-resource-main strong, +.bn-project-resource-main span, +.bn-project-resource-main em { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.bn-project-resource-main strong { + font-size: 13px; +} + +.bn-project-resource-main span, +.bn-project-resource-main em { + margin-top: 2px; + color: var(--tx3); + font-size: 11px; + font-style: normal; +} + +.bn-project-resource-main em { + color: color-mix(in srgb, var(--accent) 70%, var(--tx2)); +} + +.bn-project-resource-actions { + display: flex; + flex-shrink: 0; + flex-wrap: wrap; + justify-content: flex-end; + gap: 5px; +} + +.bn-project-resource-actions button { + min-height: 28px; + padding: 4px 7px; + font-size: 11px; +} + +.bn-project-starts-here { + align-self: center; + color: var(--accent); + font-size: 11px; + font-weight: 700; +} + +.bn-project-deployment-state { + flex-shrink: 0; + padding: 3px 7px; + border-radius: 999px; + background: var(--lift); + color: var(--tx2); + font-size: 11px; + font-weight: 800; + text-transform: uppercase; +} + +.bn-project-deployment-state.running { + background: color-mix(in srgb, var(--ok) 15%, var(--lift)); + color: var(--ok); +} + +.bn-project-deployment-state.failed { + background: color-mix(in srgb, #d16d6a 15%, var(--lift)); + color: #d98a87; +} + +.bn-project-empty { + padding: 12px 5px; + color: var(--tx3); + font-size: 13px; + line-height: 1.4; +} + +.bn-project-edit-form { + display: flex; + flex-direction: column; + gap: 10px; +} + +.bn-project-updated { + padding: 3px 2px 14px; + text-align: right; +} + +@media (max-width: 780px) { + .bn-project-lifecycle { + grid-template-columns: 1fr; + } + + .bn-project-resource { + align-items: flex-start; + flex-direction: column; + } + + .bn-project-resource-actions { + width: 100%; + justify-content: flex-start; + } +} diff --git a/editor/src/components/ProjectPanel.tsx b/editor/src/components/ProjectPanel.tsx new file mode 100644 index 0000000..e023135 --- /dev/null +++ b/editor/src/components/ProjectPanel.tsx @@ -0,0 +1,677 @@ +import { useCallback, useEffect, useMemo, useState } from 'react' +import { + api, + type HardwareDevice, + type Project, + type RemoteDeployment, +} from '../api' +import { useStore } from '../store' +import './ProjectPanel.css' + + +interface SavedWorkflow { + slug: string + name: string + saved_at: string +} + +interface DeviceDeployment { + deviceId: string + deviceName: string + deployment: RemoteDeployment +} + +interface LifecycleItem { + id: string + label: string + state: 'complete' | 'available' | 'waiting' | 'optional' + detail: string +} + + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error) +} + +function formatUpdated(value: string): string { + if (!value) return '' + const date = new Date(value) + return Number.isNaN(date.valueOf()) + ? value + : date.toLocaleString(undefined, { + month: 'short', + day: 'numeric', + hour: 'numeric', + minute: '2-digit', + }) +} + + +export default function ProjectPanel() { + const { + tabs, + activeTabId, + activeProject, + workflowRevision, + openWorkflowAsTab, + setActiveProject, + } = useStore() + const activeTab = tabs.find(tab => tab.id === activeTabId) + + const [projects, setProjects] = useState([]) + const [workflows, setWorkflows] = useState([]) + const [devices, setDevices] = useState([]) + const [selectedId, setSelectedId] = useState(null) + const [deployments, setDeployments] = useState([]) + const [loading, setLoading] = useState(true) + const [busy, setBusy] = useState('') + const [error, setError] = useState('') + const [newName, setNewName] = useState('') + const [newDescription, setNewDescription] = useState('') + 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 selected = projects.find(project => project.id === selectedId) ?? null + + const refresh = useCallback(async () => { + setLoading(true) + setError('') + try { + const [nextProjects, nextWorkflows, deviceResult] = await Promise.all([ + api.listProjects(), + api.listWorkflows(), + api.listDevices(), + ]) + setProjects(nextProjects) + setWorkflows(nextWorkflows) + setDevices(deviceResult.devices) + setSelectedId(current => ( + current && nextProjects.some(project => project.id === current) + ? current + : null + )) + } catch (nextError) { + setError(errorMessage(nextError)) + } finally { + setLoading(false) + } + }, []) + + useEffect(() => { + void refresh() + }, [refresh, workflowRevision]) + + const deploymentDeviceKey = selected?.device_ids.join('|') ?? '' + useEffect(() => { + let cancelled = false + if (!selected) { + setDeployments([]) + return + } + const availableDevices = selected.devices.filter(device => device.exists) + if (availableDevices.length === 0) { + setDeployments([]) + return + } + void Promise.all(availableDevices.map(async device => { + try { + const result = await api.listRemoteDeployments(device.id) + return result.deployments + .filter(deployment => deployment.project_id === selected.id) + .map(deployment => ({ + deviceId: device.id, + deviceName: device.name, + deployment, + })) + } catch { + return [] + } + })).then(groups => { + if (!cancelled) setDeployments(groups.flat()) + }) + return () => { + cancelled = true + } + }, [selectedId, deploymentDeviceKey]) + + useEffect(() => { + if (!selected) return + setEditName(selected.name) + setEditDescription(selected.description) + }, [selected?.id, selected?.name, selected?.description]) + + const replaceProject = useCallback((project: Project) => { + setProjects(current => current.map(item => item.id === project.id ? project : item)) + if (activeProject?.id === project.id) { + setActiveProject({ + id: project.id, + name: project.name, + workflowSlugs: project.workflow_slugs, + deviceIds: project.device_ids, + }) + } + }, [activeProject?.id, setActiveProject]) + + const updateSelected = useCallback(async ( + patch: Parameters[1], + action: string, + ) => { + if (!selected) return + setBusy(action) + setError('') + try { + replaceProject(await api.updateProject(selected.id, patch)) + } catch (nextError) { + setError(errorMessage(nextError)) + } finally { + setBusy('') + } + }, [replaceProject, selected]) + + const createProject = async () => { + const name = newName.trim() + if (!name) return + setBusy('create') + setError('') + try { + const project = await api.createProject({ + name, + description: newDescription.trim(), + workflow_slugs: activeTab?.slug ? [activeTab.slug] : [], + active_workflow_slug: activeTab?.slug ?? null, + }) + setProjects(current => [project, ...current]) + setSelectedId(project.id) + setCreating(false) + setNewName('') + setNewDescription('') + } catch (nextError) { + setError(errorMessage(nextError)) + } finally { + setBusy('') + } + } + + const saveProjectDetails = async () => { + if (!editName.trim()) return + await updateSelected({ + name: editName.trim(), + description: editDescription.trim(), + }, 'edit') + setEditing(false) + } + + const removeProject = async () => { + if (!selected) return + if (!window.confirm(`Remove project “${selected.name}”? Linked workflows and devices will not be deleted.`)) { + return + } + setBusy('delete') + setError('') + try { + await api.deleteProject(selected.id) + setProjects(current => current.filter(project => project.id !== selected.id)) + if (activeProject?.id === selected.id) setActiveProject(null) + setSelectedId(null) + } catch (nextError) { + setError(errorMessage(nextError)) + } finally { + setBusy('') + } + } + + const openProject = async () => { + if (!selected) return + const available = selected.workflows.filter(workflow => workflow.exists) + if (available.length === 0) { + setError('This project has no available saved workflows to open.') + return + } + setBusy('open') + setError('') + const failed: string[] = [] + setActiveProject({ + id: selected.id, + name: selected.name, + workflowSlugs: selected.workflow_slugs, + deviceIds: selected.device_ids, + }) + for (const workflow of available) { + try { + await openWorkflowAsTab(workflow.slug, workflow.name) + } catch { + failed.push(workflow.name) + } + } + const preferred = available.find( + workflow => workflow.slug === selected.active_workflow_slug, + ) + if (preferred && preferred.slug !== available[available.length - 1]?.slug) { + try { + await openWorkflowAsTab(preferred.slug, preferred.name) + } catch { + if (!failed.includes(preferred.name)) failed.push(preferred.name) + } + } + if (failed.length > 0) { + setError(`Could not open: ${failed.join(', ')}.`) + } + setBusy('') + } + + const addWorkflow = async (slug: string) => { + if (!selected || !slug || selected.workflow_slugs.includes(slug)) return + await updateSelected({ + workflow_slugs: [...selected.workflow_slugs, slug], + active_workflow_slug: selected.active_workflow_slug ?? slug, + }, `workflow:${slug}`) + setWorkflowToAdd('') + } + + const removeWorkflow = async (slug: string) => { + if (!selected) return + await updateSelected({ + workflow_slugs: selected.workflow_slugs.filter(value => value !== slug), + }, `workflow:${slug}`) + } + + const addDevice = async (deviceId: string) => { + if (!selected || !deviceId || selected.device_ids.includes(deviceId)) return + await updateSelected({ + device_ids: [...selected.device_ids, deviceId], + }, `device:${deviceId}`) + setDeviceToAdd('') + } + + const removeDevice = async (deviceId: string) => { + if (!selected) return + await updateSelected({ + device_ids: selected.device_ids.filter(value => value !== deviceId), + }, `device:${deviceId}`) + } + + const availableWorkflows = workflows.filter( + workflow => !selected?.workflow_slugs.includes(workflow.slug), + ) + const availableDevices = devices.filter( + device => !selected?.device_ids.includes(device.id), + ) + const runningDeployments = deployments.filter(item => item.deployment.state === 'running') + const stagedDeployments = deployments.filter(item => ( + item.deployment.state === 'staged' + || item.deployment.active_revision + )) + + const lifecycle = useMemo(() => { + if (!selected) return [] + const existingWorkflows = selected.workflows.filter(workflow => workflow.exists) + const existingDevices = selected.devices.filter(device => device.exists) + const robotWorkflows = existingWorkflows.filter(workflow => workflow.requires_calibration) + const calibratedWorkflows = robotWorkflows.filter(workflow => workflow.calibration) + 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', + }, + { + 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', + }, + { + id: 'calibrate', + label: 'Calibrate', + state: robotWorkflows.length === 0 + ? 'optional' + : calibratedWorkflows.length === robotWorkflows.length + ? 'complete' + : 'waiting', + detail: robotWorkflows.length === 0 + ? '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'}`, + }, + { + id: 'collect', + label: 'Collect', + state: hasStage('collect') ? 'available' : 'optional', + detail: hasStage('collect') ? 'Dataset recording workflow available' : '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', + }, + { + id: 'simulate', + label: 'Simulate', + state: hasStage('simulate') ? 'available' : 'optional', + detail: hasStage('simulate') ? 'Simulation workflow available' : 'Not configured for this project', + }, + { + id: 'deploy', + label: 'Deploy', + state: runningDeployments.length + ? 'complete' + : stagedDeployments.length + ? 'available' + : existingDevices.length && existingWorkflows.length + ? 'available' + : 'waiting', + detail: runningDeployments.length + ? `${runningDeployments.length} deployment${runningDeployments.length === 1 ? '' : 's'} running` + : stagedDeployments.length + ? `${stagedDeployments.length} deployment${stagedDeployments.length === 1 ? '' : 's'} staged or active` + : 'Open a workflow and deploy it', + }, + { + id: 'operate', + label: 'Operate', + state: runningDeployments.length ? 'complete' : 'waiting', + detail: runningDeployments.length ? 'Monitor the running robot deployment' : 'Waiting for a running deployment', + }, + ] + }, [selected, runningDeployments.length, stagedDeployments.length]) + + const nextStep = useMemo(() => { + if (!selected) return '' + 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 (selected) { + return ( +
+
+ + +
+ +
+ {error &&
{error}
} + +
+ {editing ? ( +
+ +