Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 20 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,11 @@ jobs:
- name: Run tests
run: python -m pytest

- name: Validate managed-device source pins
env:
GITHUB_TOKEN: ${{ github.token }}
run: python scripts/update_device_source_lock.py --verify-merged

editor:
name: Editor build
runs-on: ubuntu-latest
Expand All @@ -54,6 +59,18 @@ jobs:
working-directory: editor
run: npm ci

- name: Test editor
working-directory: editor
run: npm test

- name: Install Chromium
working-directory: editor
run: npx playwright install --with-deps chromium

- name: Test packaged App flow
working-directory: editor
run: npm run test:e2e

- name: Build editor
working-directory: editor
run: npm run build
Expand Down Expand Up @@ -92,7 +109,7 @@ jobs:
run: .\.venv\Scripts\blacknode.exe demo

rust:
name: Rust check
name: Rust tests
runs-on: ubuntu-latest
env:
PYO3_PYTHON: python
Expand All @@ -109,5 +126,5 @@ jobs:
- name: Set up Rust
uses: dtolnay/rust-toolchain@stable

- name: Check Rust workspace
run: cargo check
- name: Test Rust workspace
run: cargo test --workspace --all-targets
64 changes: 64 additions & 0 deletions .github/workflows/update-device-source-lock.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
name: Update device source lock

on:
workflow_dispatch:
inputs:
runtime_commit:
description: Full merged blacknode-runtime commit SHA
required: true
type: string
core_commit:
description: Full merged Blacknode core commit SHA
required: true
type: string
hardware_commit:
description: Full merged blacknode-robot commit SHA
required: true
type: string

permissions:
contents: write
pull-requests: write

jobs:
source-lock:
runs-on: ubuntu-latest
steps:
- name: Check out repository
uses: actions/checkout@v6

- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: "3.13"

- name: Update and verify merged source pins
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
RUNTIME_COMMIT: ${{ inputs.runtime_commit }}
CORE_COMMIT: ${{ inputs.core_commit }}
HARDWARE_COMMIT: ${{ inputs.hardware_commit }}
run: >-
python scripts/update_device_source_lock.py
--runtime-commit "$RUNTIME_COMMIT"
--core-commit "$CORE_COMMIT"
--hardware-commit "$HARDWARE_COMMIT"
--verify-merged
--write

- name: Run source-lock tests
run: python -m unittest tests.test_device_source_lock_script

- name: Open source-lock pull request
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
RUNTIME_COMMIT: ${{ inputs.runtime_commit }}
run: |
branch="release/device-source-lock-${RUNTIME_COMMIT:0:12}"
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git switch -c "$branch"
git add editor-server/device-runtime-sources.lock.json
git commit -m "release: update managed-device source lock"
git push origin "$branch"
gh pr create --base master --head "$branch" --title "release: update managed-device source lock" --body "Pins merged Runtime, core, and hardware commits after validating default-branch ancestry."
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ venv/
.idea/
*.swp
node_modules/
editor/test-results/
editor/playwright-report/

# Claude Code — local only, not for repo
CLAUDE.md
Expand Down
85 changes: 80 additions & 5 deletions crates/blacknode-core/src/graph.rs
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ impl Graph {
.get(&to)
.ok_or_else(|| BlacknodeError::NodeNotFound(to.to_string()))?;

self.dag.add_edge(
let edge_index = self.dag.add_edge(
fi,
ti,
Edge {
Expand All @@ -67,10 +67,7 @@ impl Graph {
);

if is_cyclic_directed(&self.dag) {
// roll back the edge we just added
if let Some(ei) = self.dag.find_edge(fi, ti) {
self.dag.remove_edge(ei);
}
self.dag.remove_edge(edge_index);
return Err(BlacknodeError::CycleDetected);
}

Expand Down Expand Up @@ -179,3 +176,81 @@ impl Default for Graph {
Self::new()
}
}

#[cfg(test)]
mod tests {
use super::*;
use crate::{NodeMeta, Port};
use std::sync::{
atomic::{AtomicUsize, Ordering},
Arc,
};

struct AddNode {
meta: NodeMeta,
cooks: Arc<AtomicUsize>,
}

impl AddNode {
fn new(value: i64, cooks: Arc<AtomicUsize>) -> Self {
Self {
meta: NodeMeta::new("Add")
.with_port(Port::input("value", "Int"))
.with_port(Port::output("result", "Int"))
.with_param("value", value),
cooks,
}
}
}

impl Node for AddNode {
fn meta(&self) -> &NodeMeta {
&self.meta
}

fn meta_mut(&mut self) -> &mut NodeMeta {
&mut self.meta
}

fn cook(&self, inputs: HashMap<String, Value>) -> anyhow::Result<HashMap<String, Value>> {
self.cooks.fetch_add(1, Ordering::SeqCst);
let value = inputs.get("value").and_then(Value::as_f64).unwrap_or_default() as i64;
Ok(HashMap::from([("result".to_string(), Value::Int(value + 1))]))
}
}

#[test]
fn caches_results_and_invalidates_downstream_after_parameter_update() {
let first_cooks = Arc::new(AtomicUsize::new(0));
let second_cooks = Arc::new(AtomicUsize::new(0));
let mut graph = Graph::new();
let first = graph.add_node(Box::new(AddNode::new(1, first_cooks.clone())));
let second = graph.add_node(Box::new(AddNode::new(0, second_cooks.clone())));
graph.connect(first, "result", second, "value").unwrap();

assert_eq!(graph.cook(second, "result").unwrap(), Value::Int(3));
assert_eq!(graph.cook(second, "result").unwrap(), Value::Int(3));
assert_eq!(first_cooks.load(Ordering::SeqCst), 1);
assert_eq!(second_cooks.load(Ordering::SeqCst), 1);

graph.set_param(first, "value", Value::Int(5)).unwrap();
assert_eq!(graph.cook(second, "result").unwrap(), Value::Int(7));
assert_eq!(first_cooks.load(Ordering::SeqCst), 2);
assert_eq!(second_cooks.load(Ordering::SeqCst), 2);
}

#[test]
fn rejects_cycles_without_removing_an_existing_parallel_edge() {
let mut graph = Graph::new();
let cooks = Arc::new(AtomicUsize::new(0));
let first = graph.add_node(Box::new(AddNode::new(1, cooks.clone())));
let second = graph.add_node(Box::new(AddNode::new(2, cooks)));
graph.connect(first, "result", second, "value").unwrap();

assert!(matches!(
graph.connect(second, "result", first, "value"),
Err(BlacknodeError::CycleDetected)
));
assert_eq!(graph.cook(second, "result").unwrap(), Value::Int(3));
}
}
59 changes: 54 additions & 5 deletions crates/blacknode-runtime/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
use blacknode_core::{BlacknodeError, Graph, NodeId};
use blacknode_types::Value;
use std::collections::HashMap;
use std::sync::Arc;

/// Async executor that cooks multiple terminal nodes in parallel
/// using tokio tasks. Each branch is independent once inputs are resolved.
Expand All @@ -9,7 +10,7 @@ pub struct AsyncExecutor;
impl AsyncExecutor {
/// Cook a set of (node_id, port) targets concurrently.
pub async fn cook_many(
graph: &Graph,
graph: Arc<Graph>,
targets: Vec<(NodeId, String)>,
) -> HashMap<(NodeId, String), Result<Value, BlacknodeError>> {
let mut results = HashMap::new();
Expand All @@ -18,12 +19,10 @@ impl AsyncExecutor {
let handles: Vec<_> = targets
.into_iter()
.map(|(id, port)| {
// SAFETY: Graph is Sync — shared reference across tasks.
let g = graph as *const Graph as usize;
let graph = Arc::clone(&graph);
let port_clone = port.clone();
let handle = tokio::task::spawn_blocking(move || {
let g = unsafe { &*(g as *const Graph) };
((id, port_clone.clone()), g.cook(id, &port_clone))
((id, port_clone.clone()), graph.cook(id, &port_clone))
});
handle
})
Expand All @@ -37,3 +36,53 @@ impl AsyncExecutor {
results
}
}

#[cfg(test)]
mod tests {
use super::*;
use blacknode_core::{Node, NodeMeta, Port};

struct ConstantNode {
meta: NodeMeta,
value: i64,
}

impl ConstantNode {
fn new(value: i64) -> Self {
Self {
meta: NodeMeta::new("Constant").with_port(Port::output("value", "Int")),
value,
}
}
}

impl Node for ConstantNode {
fn meta(&self) -> &NodeMeta {
&self.meta
}

fn meta_mut(&mut self) -> &mut NodeMeta {
&mut self.meta
}

fn cook(&self, _inputs: HashMap<String, Value>) -> anyhow::Result<HashMap<String, Value>> {
Ok(HashMap::from([("value".to_string(), Value::Int(self.value))]))
}
}

#[tokio::test]
async fn cooks_multiple_targets_without_borrowing_graph_memory_into_tasks() {
let mut graph = Graph::new();
let first = graph.add_node(Box::new(ConstantNode::new(2)));
let second = graph.add_node(Box::new(ConstantNode::new(5)));

let results = AsyncExecutor::cook_many(
Arc::new(graph),
vec![(first, "value".to_string()), (second, "value".to_string())],
)
.await;

assert_eq!(results[&(first, "value".to_string())].as_ref().unwrap(), &Value::Int(2));
assert_eq!(results[&(second, "value".to_string())].as_ref().unwrap(), &Value::Int(5));
}
}
19 changes: 19 additions & 0 deletions crates/blacknode-types/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -86,3 +86,22 @@ impl From<HashMap<String, Value>> for Value {
Value::Map(m)
}
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn values_round_trip_through_the_tagged_json_contract() {
let value = Value::Map(HashMap::from([
("enabled".to_string(), Value::Bool(true)),
("samples".to_string(), Value::List(vec![Value::Int(3), Value::Float(4.5)])),
]));

let encoded = serde_json::to_string(&value).unwrap();
let decoded: Value = serde_json::from_str(&encoded).unwrap();

assert_eq!(decoded, value);
assert_eq!(decoded.type_name(), "Map");
}
}
17 changes: 17 additions & 0 deletions docs/app-deployments.md
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,23 @@ For a customer domain, also set `BLACKNODE_APP_PUBLIC_ORIGINS` to the exact
comma-separated HTTPS origins allowed to send operator commands. Local startup
accepts `http://localhost:3000` and `http://127.0.0.1:3000` by default.

App file fields can browse only the extensions declared by their operator-view
field. The browser is rooted at the deployment user's home directory by
default. Set `BLACKNODE_APP_FILE_ROOTS` to an OS-path-separator-delimited list
of existing directories to expose narrower or additional artifact roots:

```powershell
$env:BLACKNODE_APP_FILE_ROOTS = "D:\RobotModels;D:\Datasets"
```

```bash
export BLACKNODE_APP_FILE_ROOTS="/srv/robot-models:/srv/datasets"
```

Paths outside those roots and file types absent from the active App contract
are rejected by the server. Keep credentials and other sensitive host data
outside the configured roots.

Opening Blacknode now enters the customer App shell automatically. A direct App
link uses `/app/<app-id>`, such as `/app/collect-episodes`.

Expand Down
14 changes: 10 additions & 4 deletions docs/operator-apps.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,15 +84,21 @@ An operator view is declared inside workflow metadata:

Supported widgets are `image`, `viewer`, `status`, `metrics`, `fields`, and
`actions`. Image, viewer, status, and metric widgets read live node output
ports. A `viewer` embeds an HTTP(S) URL produced by a trusted workflow node and
is intended for managed simulation, robot-scene, and other interactive browser
surfaces. Field widgets update declared node parameters. Actions can update
ports. A `viewer` embeds a sandboxed HTTP(S) URL produced by a trusted workflow
node and is intended for managed simulation, robot-scene, and other interactive
browser surfaces. Relative URLs, loopback hosts, private-network addresses, and
`.local` hosts are accepted automatically. Declare `trusted_origins` on the
viewer widget when it must load a public origin, using exact origins such as
`["https://viewer.example.com"]`. Viewer frames use a least-privilege capability
set with scripting, forms, pointer-lock, and fullscreen. Field widgets update
declared node parameters. Actions can update
parameters, cook a declared node output, or call a node's existing
direct-control endpoint.

Use `input: "file_path"` for a path that must exist on the App host. It keeps
the path editable and adds a **Browse…** button backed by Blacknode's filesystem
browser. Declare `extensions` to filter selectable files, and optionally set
browser. Declare a non-empty `extensions` allowlist to grant selectable file
types, and optionally set
`picker_title` and `button_label`. This is appropriate for robot descriptions,
scenes, datasets, checkpoints, and other host-side artifacts.

Expand Down
1 change: 1 addition & 0 deletions editor-server/app_mode.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ def route_allowed(method: str, path: str) -> bool:
"/cook",
"/cook-stream",
"/cook/stop",
"/filesystem/browse",
"/runtime/stop",
}:
return True
Expand Down
Loading
Loading