Compare commits

..

14 Commits

Author SHA1 Message Date
Zoe Forbes
3fe43710fa fix(ui): remove addStretch from auth panel, use compact size policy
Replace layout.addStretch() with QSizePolicy.Maximum so the
Database Auth dock panel only takes the height its content needs,
leaving more vertical space for the Database Activity panel below.

Closes #190
2026-02-15 09:43:15 -06:00
Zoe Forbes
8cbd872e5c Merge branch 'feat/worker-client-ui' into main
Resolve conflicts in silo_commands.py:
- Keep event-based activity feed (_append_activity_event, _rebuild_activity_feed)
- Adapt DAG/job SSE handlers to use _append_activity_event
- Keep _relative_time formatting for activity entries
- Include DNS diagnostic IP display from feature branch
2026-02-15 08:32:55 -06:00
Zoe Forbes
e31321ac95 feat: add Jobs and Runners commands with SSE event wiring
- Add JobMonitorDialog (Silo_Jobs): filter, view, trigger, cancel jobs
- Add RunnerAdminDialog (Silo_Runners): list, register, delete runners
- Wire job_claimed, job_progress, job_cancelled SSE signals to handlers
- Add activity panel entries for job lifecycle events
- Register Silo_Jobs in toolbar and menu, Silo_Runners in menu
- Update silo-client submodule with worker API methods
2026-02-15 05:07:33 -06:00
Zoe Forbes
dc64a66f0f feat: show DAG status and job events in Activity panel
Connects dag_updated, dag_validated, and job lifecycle signals
from SiloEventListener to the Database Activity dock widget.

- dag.updated: inserts DAG sync status (node/edge count)
- dag.validated: inserts pass/fail badge with failed count
- job.created: inserts queued job entry
- job.completed: refreshes the full activity list
- job.failed: inserts error entry

Live entries are inserted at the top of the activity list,
styled in Catppuccin Blue, capped at 50 entries.

Closes kindred/create#219
2026-02-14 15:28:40 -06:00
Zoe Forbes
3d38e4b4c3 feat: handle DAG and job SSE events in SiloEventListener
New signals:
- dag_updated(part_number, node_count, edge_count)
- dag_validated(part_number, valid, failed_count)
- job_created/claimed/progress/completed/failed/cancelled

Dispatch logic parses payloads and emits typed signals for
downstream UI and logging consumers.

Closes kindred/create#218
2026-02-14 15:22:29 -06:00
Zoe Forbes
da2a360c56 feat: add headless runner entry points
New file: runner.py with three entry points for silorunner:
- dag_extract(input_path, output_path): extract feature DAG as JSON
- validate(input_path, output_path): rebuild features, report pass/fail
- export(input_path, output_path, format): export to STEP/IGES/STL/OBJ

Invoked via: create --console -e 'from runner import dag_extract; ...'

Closes kindred/create#217
2026-02-14 15:17:30 -06:00
Zoe Forbes
3dd0da3964 feat: push DAG on save and commit
Adds _push_dag_after_upload() helper that extracts the feature DAG
and pushes it to Silo after a successful file upload.

Hooked into both Silo_Save and Silo_Commit commands. DAG sync
failures are logged as warnings and never block the save/commit.

Closes kindred/create#216
2026-02-14 15:10:50 -06:00
Zoe Forbes
4921095296 feat: update silo-client — add DAG API methods
Points silo-client to feat/dag-api-methods with push_dag/get_dag.

Closes kindred/create#215
2026-02-14 15:06:31 -06:00
Zoe Forbes
3a9fe6aed8 feat: add DAG extraction engine
Implements extract_dag(), classify_type(), and compute_properties_hash()
for extracting feature trees from FreeCAD documents.

- classify_type: maps ~50 FreeCAD TypeIds to 8 DAG node types
- compute_properties_hash: SHA-256 of per-feature parametric inputs
- extract_dag: two-pass walk of doc.Objects producing nodes + edges

No GUI dependencies -- works in both desktop and headless mode.

Closes kindred/create#214
2026-02-14 14:41:35 -06:00
Zoe Forbes
9e99b83091 fix: default cert browser to home dir instead of /etc/ssl/certs (#203)
The CA certificate file browser hardcoded /etc/ssl/certs as fallback,
which confused users when the dialog opened to a system directory.
Default to the user's home directory instead.
2026-02-14 12:50:01 -06:00
0f407360ed Merge pull request 'feat: use .kc extension for new files, find both .kc and .FCStd' (#23) from feat/kc-file-format-layer1 into main
Reviewed-on: #23
2026-02-13 19:42:03 +00:00
fa4f3145c6 Merge branch 'main' into feat/kc-file-format-layer1 2026-02-13 19:41:55 +00:00
Zoe Forbes
fed72676bc feat: use .kc extension for new files, find both .kc and .FCStd
- get_cad_file_path() now generates .kc paths instead of .FCStd
- find_file_by_part_number() searches .kc first, falls back to .FCStd
- search_local_files() lists both .kc and .FCStd files
2026-02-13 13:39:22 -06:00
d3e27010d8 Merge pull request 'feat: live SSE-based activity feed for Database Activity panel' (#22) from feat/live-activity-panel into main
Reviewed-on: #22
2026-02-13 01:57:03 +00:00
5 changed files with 1262 additions and 20 deletions

View File

@@ -45,6 +45,7 @@ class SiloWorkbench(FreeCADGui.Workbench):
"Separator",
"Silo_Info",
"Silo_BOM",
"Silo_Jobs",
]
self.appendToolbar("Silo Origin", self.silo_toolbar_commands, "Unavailable")
@@ -52,12 +53,14 @@ class SiloWorkbench(FreeCADGui.Workbench):
self.menu_commands = [
"Silo_Info",
"Silo_BOM",
"Silo_Jobs",
"Silo_TagProjects",
"Silo_SetStatus",
"Silo_Rollback",
"Separator",
"Silo_Settings",
"Silo_Auth",
"Silo_Runners",
"Silo_StartPanel",
"Silo_Diag",
]

463
freecad/dag.py Normal file
View File

@@ -0,0 +1,463 @@
"""DAG extraction engine for FreeCAD documents.
Extracts the feature tree from a FreeCAD document as nodes and edges
for syncing to the Silo server. No GUI dependencies -- usable in both
desktop and headless (``--console``) mode.
Public API
----------
classify_type(type_id) -> Optional[str]
compute_properties_hash(obj) -> str
extract_dag(doc) -> (nodes, edges)
"""
import hashlib
import json
import math
from typing import Any, Dict, List, Optional, Set, Tuple
# ---------------------------------------------------------------------------
# TypeId -> DAG node type mapping
# ---------------------------------------------------------------------------
_TYPE_MAP: Dict[str, str] = {
# Sketch
"Sketcher::SketchObject": "sketch",
# Sketch-based additive
"PartDesign::Pad": "pad",
"PartDesign::Revolution": "pad",
"PartDesign::AdditivePipe": "pad",
"PartDesign::AdditiveLoft": "pad",
"PartDesign::AdditiveHelix": "pad",
# Sketch-based subtractive
"PartDesign::Pocket": "pocket",
"PartDesign::Groove": "pocket",
"PartDesign::Hole": "pocket",
"PartDesign::SubtractivePipe": "pocket",
"PartDesign::SubtractiveLoft": "pocket",
"PartDesign::SubtractiveHelix": "pocket",
# Dress-up
"PartDesign::Fillet": "fillet",
"PartDesign::Chamfer": "chamfer",
"PartDesign::Draft": "chamfer",
"PartDesign::Thickness": "chamfer",
# Transformations
"PartDesign::Mirrored": "pad",
"PartDesign::LinearPattern": "pad",
"PartDesign::PolarPattern": "pad",
"PartDesign::Scaled": "pad",
"PartDesign::MultiTransform": "pad",
# Boolean
"PartDesign::Boolean": "pad",
# Additive primitives
"PartDesign::AdditiveBox": "pad",
"PartDesign::AdditiveCylinder": "pad",
"PartDesign::AdditiveSphere": "pad",
"PartDesign::AdditiveCone": "pad",
"PartDesign::AdditiveEllipsoid": "pad",
"PartDesign::AdditiveTorus": "pad",
"PartDesign::AdditivePrism": "pad",
"PartDesign::AdditiveWedge": "pad",
# Subtractive primitives
"PartDesign::SubtractiveBox": "pocket",
"PartDesign::SubtractiveCylinder": "pocket",
"PartDesign::SubtractiveSphere": "pocket",
"PartDesign::SubtractiveCone": "pocket",
"PartDesign::SubtractiveEllipsoid": "pocket",
"PartDesign::SubtractiveTorus": "pocket",
"PartDesign::SubtractivePrism": "pocket",
"PartDesign::SubtractiveWedge": "pocket",
# Containers
"PartDesign::Body": "body",
"App::Part": "part",
"Part::Feature": "part",
# Datum / reference
"PartDesign::Point": "datum",
"PartDesign::Line": "datum",
"PartDesign::Plane": "datum",
"PartDesign::CoordinateSystem": "datum",
"PartDesign::ShapeBinder": "datum",
"PartDesign::SubShapeBinder": "datum",
}
def classify_type(type_id: str) -> Optional[str]:
"""Map a FreeCAD TypeId string to a DAG node type.
Returns one of ``sketch``, ``pad``, ``pocket``, ``fillet``,
``chamfer``, ``body``, ``part``, ``datum``, or ``None`` if the
TypeId is not a recognized feature.
"""
return _TYPE_MAP.get(type_id)
# ---------------------------------------------------------------------------
# Properties hash
# ---------------------------------------------------------------------------
def _safe_float(val: Any) -> Any:
"""Convert a float to a JSON-safe value, replacing NaN/Infinity with 0."""
if isinstance(val, float) and (math.isnan(val) or math.isinf(val)):
return 0.0
return val
def _prop_value(obj: Any, name: str) -> Any:
"""Safely read ``obj.<name>.Value``, returning *None* on failure."""
try:
return _safe_float(getattr(obj, name).Value)
except Exception:
return None
def _prop_raw(obj: Any, name: str) -> Any:
"""Safely read ``obj.<name>``, returning *None* on failure."""
try:
return getattr(obj, name)
except Exception:
return None
def _link_name(obj: Any, name: str) -> Optional[str]:
"""Return the ``.Name`` of a linked object property, or *None*."""
try:
link = getattr(obj, name)
if isinstance(link, (list, tuple)):
link = link[0]
return link.Name if link is not None else None
except Exception:
return None
def _collect_inputs(obj: Any) -> Dict[str, Any]:
"""Extract the parametric inputs that affect *obj*'s geometry.
The returned dict is JSON-serialized and hashed to produce the
``properties_hash`` for the DAG node.
"""
tid = obj.TypeId
inputs: Dict[str, Any] = {"_type": tid}
# --- Sketch ---
if tid == "Sketcher::SketchObject":
inputs["geometry_count"] = _prop_raw(obj, "GeometryCount")
inputs["constraint_count"] = _prop_raw(obj, "ConstraintCount")
try:
inputs["geometry"] = obj.Shape.exportBrepToString()
except Exception:
pass
return inputs
# --- Extrude (Pad / Pocket) ---
if tid in ("PartDesign::Pad", "PartDesign::Pocket"):
inputs["length"] = _prop_value(obj, "Length")
inputs["type"] = str(_prop_raw(obj, "Type") or "")
inputs["reversed"] = _prop_raw(obj, "Reversed")
inputs["sketch"] = _link_name(obj, "Profile")
return inputs
# --- Revolution / Groove ---
if tid in ("PartDesign::Revolution", "PartDesign::Groove"):
inputs["angle"] = _prop_value(obj, "Angle")
inputs["type"] = str(_prop_raw(obj, "Type") or "")
inputs["reversed"] = _prop_raw(obj, "Reversed")
inputs["sketch"] = _link_name(obj, "Profile")
return inputs
# --- Hole ---
if tid == "PartDesign::Hole":
inputs["diameter"] = _prop_value(obj, "Diameter")
inputs["depth"] = _prop_value(obj, "Depth")
inputs["threaded"] = _prop_raw(obj, "Threaded")
inputs["thread_type"] = str(_prop_raw(obj, "ThreadType") or "")
inputs["depth_type"] = str(_prop_raw(obj, "DepthType") or "")
inputs["sketch"] = _link_name(obj, "Profile")
return inputs
# --- Pipe / Loft / Helix (additive + subtractive) ---
if tid in (
"PartDesign::AdditivePipe",
"PartDesign::SubtractivePipe",
"PartDesign::AdditiveLoft",
"PartDesign::SubtractiveLoft",
"PartDesign::AdditiveHelix",
"PartDesign::SubtractiveHelix",
):
inputs["sketch"] = _link_name(obj, "Profile")
inputs["spine"] = _link_name(obj, "Spine")
return inputs
# --- Fillet ---
if tid == "PartDesign::Fillet":
inputs["radius"] = _prop_value(obj, "Radius")
return inputs
# --- Chamfer ---
if tid == "PartDesign::Chamfer":
inputs["chamfer_type"] = str(_prop_raw(obj, "ChamferType") or "")
inputs["size"] = _prop_value(obj, "Size")
inputs["size2"] = _prop_value(obj, "Size2")
inputs["angle"] = _prop_value(obj, "Angle")
return inputs
# --- Draft ---
if tid == "PartDesign::Draft":
inputs["angle"] = _prop_value(obj, "Angle")
inputs["reversed"] = _prop_raw(obj, "Reversed")
return inputs
# --- Thickness ---
if tid == "PartDesign::Thickness":
inputs["value"] = _prop_value(obj, "Value")
inputs["reversed"] = _prop_raw(obj, "Reversed")
inputs["mode"] = str(_prop_raw(obj, "Mode") or "")
inputs["join"] = str(_prop_raw(obj, "Join") or "")
return inputs
# --- Mirrored ---
if tid == "PartDesign::Mirrored":
inputs["mirror_plane"] = _link_name(obj, "MirrorPlane")
return inputs
# --- LinearPattern ---
if tid == "PartDesign::LinearPattern":
inputs["direction"] = _link_name(obj, "Direction")
inputs["reversed"] = _prop_raw(obj, "Reversed")
inputs["length"] = _prop_value(obj, "Length")
inputs["occurrences"] = _prop_value(obj, "Occurrences")
return inputs
# --- PolarPattern ---
if tid == "PartDesign::PolarPattern":
inputs["axis"] = _link_name(obj, "Axis")
inputs["reversed"] = _prop_raw(obj, "Reversed")
inputs["angle"] = _prop_value(obj, "Angle")
inputs["occurrences"] = _prop_value(obj, "Occurrences")
return inputs
# --- Scaled ---
if tid == "PartDesign::Scaled":
inputs["factor"] = _prop_value(obj, "Factor")
inputs["occurrences"] = _prop_value(obj, "Occurrences")
return inputs
# --- MultiTransform ---
if tid == "PartDesign::MultiTransform":
try:
inputs["transform_count"] = len(obj.Transformations)
except Exception:
pass
return inputs
# --- Boolean ---
if tid == "PartDesign::Boolean":
inputs["type"] = str(_prop_raw(obj, "Type") or "")
return inputs
# --- Primitives (additive) ---
if tid in (
"PartDesign::AdditiveBox",
"PartDesign::SubtractiveBox",
):
inputs["length"] = _prop_value(obj, "Length")
inputs["width"] = _prop_value(obj, "Width")
inputs["height"] = _prop_value(obj, "Height")
return inputs
if tid in (
"PartDesign::AdditiveCylinder",
"PartDesign::SubtractiveCylinder",
):
inputs["radius"] = _prop_value(obj, "Radius")
inputs["height"] = _prop_value(obj, "Height")
inputs["angle"] = _prop_value(obj, "Angle")
return inputs
if tid in (
"PartDesign::AdditiveSphere",
"PartDesign::SubtractiveSphere",
):
inputs["radius"] = _prop_value(obj, "Radius")
return inputs
if tid in (
"PartDesign::AdditiveCone",
"PartDesign::SubtractiveCone",
):
inputs["radius1"] = _prop_value(obj, "Radius1")
inputs["radius2"] = _prop_value(obj, "Radius2")
inputs["height"] = _prop_value(obj, "Height")
return inputs
if tid in (
"PartDesign::AdditiveEllipsoid",
"PartDesign::SubtractiveEllipsoid",
):
inputs["radius1"] = _prop_value(obj, "Radius1")
inputs["radius2"] = _prop_value(obj, "Radius2")
inputs["radius3"] = _prop_value(obj, "Radius3")
return inputs
if tid in (
"PartDesign::AdditiveTorus",
"PartDesign::SubtractiveTorus",
):
inputs["radius1"] = _prop_value(obj, "Radius1")
inputs["radius2"] = _prop_value(obj, "Radius2")
return inputs
if tid in (
"PartDesign::AdditivePrism",
"PartDesign::SubtractivePrism",
):
inputs["polygon"] = _prop_raw(obj, "Polygon")
inputs["circumradius"] = _prop_value(obj, "Circumradius")
inputs["height"] = _prop_value(obj, "Height")
return inputs
if tid in (
"PartDesign::AdditiveWedge",
"PartDesign::SubtractiveWedge",
):
for dim in (
"Xmin",
"Ymin",
"Zmin",
"X2min",
"Z2min",
"Xmax",
"Ymax",
"Zmax",
"X2max",
"Z2max",
):
inputs[dim.lower()] = _prop_value(obj, dim)
return inputs
# --- Datum / ShapeBinder ---
if tid in (
"PartDesign::Point",
"PartDesign::Line",
"PartDesign::Plane",
"PartDesign::CoordinateSystem",
"PartDesign::ShapeBinder",
"PartDesign::SubShapeBinder",
):
try:
p = obj.Placement
inputs["position"] = {
"x": _safe_float(p.Base.x),
"y": _safe_float(p.Base.y),
"z": _safe_float(p.Base.z),
}
inputs["rotation"] = {
"axis_x": _safe_float(p.Rotation.Axis.x),
"axis_y": _safe_float(p.Rotation.Axis.y),
"axis_z": _safe_float(p.Rotation.Axis.z),
"angle": _safe_float(p.Rotation.Angle),
}
except Exception:
pass
return inputs
# --- Body / Part (containers) ---
if tid in ("PartDesign::Body", "App::Part", "Part::Feature"):
try:
inputs["child_count"] = len(obj.Group)
except Exception:
inputs["child_count"] = 0
return inputs
# --- Fallback ---
inputs["label"] = obj.Label
return inputs
def compute_properties_hash(obj: Any) -> str:
"""Return a SHA-256 hex digest of *obj*'s parametric inputs.
The hash is used for memoization -- if a node's inputs haven't
changed since the last validation run, re-validation can be skipped.
"""
inputs = _collect_inputs(obj)
canonical = json.dumps(inputs, sort_keys=True, default=str)
return hashlib.sha256(canonical.encode()).hexdigest()
# ---------------------------------------------------------------------------
# DAG extraction
# ---------------------------------------------------------------------------
def extract_dag(
doc: Any,
) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]]]:
"""Walk a FreeCAD document and return ``(nodes, edges)``.
*nodes* is a list of dicts matching the Silo ``PUT /dag`` payload
schema. *edges* connects dependencies (source) to dependents
(target).
Only objects whose TypeId is recognized by :func:`classify_type`
are included. Edges are limited to pairs where **both** endpoints
are included, preventing dangling references to internal objects
such as ``App::Origin``.
"""
# Pass 1 -- identify included objects
included: Set[str] = set()
classified: Dict[str, str] = {} # obj.Name -> node_type
for obj in doc.Objects:
if not hasattr(obj, "TypeId"):
continue
node_type = classify_type(obj.TypeId)
if node_type is not None:
included.add(obj.Name)
classified[obj.Name] = node_type
# Pass 2 -- build nodes and edges
nodes: List[Dict[str, Any]] = []
edges: List[Dict[str, Any]] = []
seen_edges: Set[Tuple[str, str]] = set()
for obj in doc.Objects:
if obj.Name not in included:
continue
nodes.append(
{
"node_key": obj.Name,
"node_type": classified[obj.Name],
"properties_hash": compute_properties_hash(obj),
"metadata": {
"label": obj.Label,
"type_id": obj.TypeId,
},
}
)
# Walk dependencies: OutList contains objects this one depends on
try:
out_list = obj.OutList
except Exception:
continue
for dep in out_list:
if not hasattr(dep, "Name"):
continue
if dep.Name not in included:
continue
edge_key = (dep.Name, obj.Name)
if edge_key in seen_edges:
continue
seen_edges.add(edge_key)
edges.append(
{
"source_key": dep.Name,
"target_key": obj.Name,
"edge_type": "depends_on",
}
)
return nodes, edges

156
freecad/runner.py Normal file
View File

@@ -0,0 +1,156 @@
"""Headless runner entry points for silorunner compute jobs.
These functions are invoked via ``create --console -e`` by the
silorunner binary. They must work without a display server.
Entry Points
------------
dag_extract(input_path, output_path)
Extract feature DAG and write JSON.
validate(input_path, output_path)
Rebuild all features and report pass/fail per node.
export(input_path, output_path, format='step')
Export geometry to STEP, IGES, STL, or OBJ.
"""
import json
import FreeCAD
def dag_extract(input_path, output_path):
"""Extract the feature DAG from a Create file.
Parameters
----------
input_path : str
Path to the ``.kc`` or ``.FCStd`` file.
output_path : str
Path to write the JSON output.
Output JSON::
{"nodes": [...], "edges": [...]}
"""
from dag import extract_dag
doc = FreeCAD.openDocument(input_path)
try:
nodes, edges = extract_dag(doc)
with open(output_path, "w") as f:
json.dump({"nodes": nodes, "edges": edges}, f)
FreeCAD.Console.PrintMessage(
f"DAG extracted: {len(nodes)} nodes, {len(edges)} edges -> {output_path}\n"
)
finally:
FreeCAD.closeDocument(doc.Name)
def validate(input_path, output_path):
"""Validate a Create file by rebuilding all features.
Parameters
----------
input_path : str
Path to the ``.kc`` or ``.FCStd`` file.
output_path : str
Path to write the JSON output.
Output JSON::
{
"valid": true/false,
"nodes": [
{"node_key": "Pad001", "state": "clean", "message": null, "properties_hash": "..."},
...
]
}
"""
from dag import classify_type, compute_properties_hash
doc = FreeCAD.openDocument(input_path)
try:
doc.recompute()
results = []
all_valid = True
for obj in doc.Objects:
if not hasattr(obj, "TypeId"):
continue
node_type = classify_type(obj.TypeId)
if node_type is None:
continue
state = "clean"
message = None
if hasattr(obj, "isValid") and not obj.isValid():
state = "failed"
message = f"Feature {obj.Label} failed to recompute"
all_valid = False
results.append(
{
"node_key": obj.Name,
"state": state,
"message": message,
"properties_hash": compute_properties_hash(obj),
}
)
with open(output_path, "w") as f:
json.dump({"valid": all_valid, "nodes": results}, f)
status = "PASS" if all_valid else "FAIL"
FreeCAD.Console.PrintMessage(
f"Validation {status}: {len(results)} nodes -> {output_path}\n"
)
finally:
FreeCAD.closeDocument(doc.Name)
def export(input_path, output_path, format="step"):
"""Export a Create file to an external geometry format.
Parameters
----------
input_path : str
Path to the ``.kc`` or ``.FCStd`` file.
output_path : str
Path to write the exported file.
format : str
One of ``step``, ``iges``, ``stl``, ``obj``.
"""
import Part
doc = FreeCAD.openDocument(input_path)
try:
shapes = [
obj.Shape for obj in doc.Objects if hasattr(obj, "Shape") and obj.Shape
]
if not shapes:
raise ValueError("No geometry found in document")
compound = Part.makeCompound(shapes)
format_lower = format.lower()
if format_lower == "step":
compound.exportStep(output_path)
elif format_lower == "iges":
compound.exportIges(output_path)
elif format_lower == "stl":
import Mesh
Mesh.export([compound], output_path)
elif format_lower == "obj":
import Mesh
Mesh.export([compound], output_path)
else:
raise ValueError(f"Unsupported format: {format}")
FreeCAD.Console.PrintMessage(
f"Exported {format_lower.upper()} -> {output_path}\n"
)
finally:
FreeCAD.closeDocument(doc.Name)

View File

@@ -234,41 +234,45 @@ def get_projects_dir() -> Path:
def get_cad_file_path(part_number: str, description: str = "") -> Path:
"""Generate canonical file path for a CAD file.
Path format: ~/projects/cad/{category_code}_{category_name}/{part_number}_{description}.FCStd
Path format: ~/projects/cad/{category_code}_{category_name}/{part_number}_{description}.kc
"""
category, _ = parse_part_number(part_number)
folder_name = get_category_folder_name(category)
if description:
filename = f"{part_number}_{sanitize_filename(description)}.FCStd"
filename = f"{part_number}_{sanitize_filename(description)}.kc"
else:
filename = f"{part_number}.FCStd"
filename = f"{part_number}.kc"
return get_projects_dir() / "cad" / folder_name / filename
def find_file_by_part_number(part_number: str) -> Optional[Path]:
"""Find existing CAD file for a part number."""
"""Find existing CAD file for a part number. Prefers .kc over .FCStd."""
category, _ = parse_part_number(part_number)
folder_name = get_category_folder_name(category)
cad_dir = get_projects_dir() / "cad" / folder_name
if cad_dir.exists():
matches = list(cad_dir.glob(f"{part_number}*.FCStd"))
if matches:
return matches[0]
base_cad_dir = get_projects_dir() / "cad"
if base_cad_dir.exists():
for subdir in base_cad_dir.iterdir():
if subdir.is_dir():
matches = list(subdir.glob(f"{part_number}*.FCStd"))
if matches:
return matches[0]
for search_dir in _search_dirs(cad_dir):
for ext in ("*.kc", "*.FCStd"):
matches = list(search_dir.glob(f"{part_number}{ext[1:]}"))
if matches:
return matches[0]
return None
def _search_dirs(category_dir: Path):
"""Yield the category dir, then all sibling dirs under cad/."""
if category_dir.exists():
yield category_dir
base_cad_dir = category_dir.parent
if base_cad_dir.exists():
for subdir in base_cad_dir.iterdir():
if subdir.is_dir() and subdir != category_dir:
yield subdir
def search_local_files(search_term: str = "", category_filter: str = "") -> list:
"""Search for CAD files in local cad directory."""
results = []
@@ -288,7 +292,9 @@ def search_local_files(search_term: str = "", category_filter: str = "") -> list
if category_filter and category_code.upper() != category_filter.upper():
continue
for fcstd_file in category_dir.glob("*.FCStd"):
for fcstd_file in sorted(
list(category_dir.glob("*.kc")) + list(category_dir.glob("*.FCStd"))
):
filename = fcstd_file.stem
parts = filename.split("_", 1)
part_number = parts[0]
@@ -731,6 +737,28 @@ class Silo_New:
return _server_mode == "normal"
def _push_dag_after_upload(doc, part_number, revision_number):
"""Extract and push the feature DAG after a successful upload.
Failures are logged as warnings -- DAG sync must never block save.
"""
try:
from dag import extract_dag
nodes, edges = extract_dag(doc)
if not nodes:
return
result = _client.push_dag(part_number, revision_number, nodes, edges)
node_count = result.get("node_count", len(nodes))
edge_count = result.get("edge_count", len(edges))
FreeCAD.Console.PrintMessage(
f"DAG synced: {node_count} nodes, {edge_count} edges\n"
)
except Exception as e:
FreeCAD.Console.PrintWarning(f"DAG sync failed: {e}\n")
class Silo_Save:
"""Save locally and upload to MinIO."""
@@ -801,6 +829,8 @@ class Silo_Save:
new_rev = result["revision_number"]
FreeCAD.Console.PrintMessage(f"Uploaded as revision {new_rev}\n")
_push_dag_after_upload(doc, part_number, new_rev)
except Exception as e:
FreeCAD.Console.PrintWarning(f"Upload failed: {e}\n")
FreeCAD.Console.PrintMessage("File saved locally but not uploaded.\n")
@@ -855,6 +885,8 @@ class Silo_Commit:
new_rev = result["revision_number"]
FreeCAD.Console.PrintMessage(f"Committed revision {new_rev}: {comment}\n")
_push_dag_after_upload(doc, part_number, new_rev)
except Exception as e:
FreeCAD.Console.PrintError(f"Commit failed: {e}\n")
@@ -1777,7 +1809,7 @@ class Silo_Settings:
path, _ = QtGui.QFileDialog.getOpenFileName(
dialog,
"Select CA Certificate",
os.path.dirname(cert_input.text()) or "/etc/ssl/certs",
os.path.dirname(cert_input.text()) or os.path.expanduser("~"),
"Certificates (*.pem *.crt *.cer);;All Files (*)",
)
if path:
@@ -2326,6 +2358,18 @@ class SiloEventListener(QtCore.QThread):
) # (status, retry_count, error_message)
server_mode_changed = QtCore.Signal(str) # "normal" / "read-only" / "degraded"
# DAG events
dag_updated = QtCore.Signal(str, int, int) # part_number, node_count, edge_count
dag_validated = QtCore.Signal(str, bool, int) # part_number, valid, failed_count
# Job lifecycle events
job_created = QtCore.Signal(str, str, str) # job_id, definition_name, part_number
job_claimed = QtCore.Signal(str, str) # job_id, runner_id
job_progress = QtCore.Signal(str, int, str) # job_id, progress, message
job_completed = QtCore.Signal(str) # job_id
job_failed = QtCore.Signal(str, str) # job_id, error
job_cancelled = QtCore.Signal(str) # job_id
_MAX_RETRIES = 10
_BASE_DELAY = 1 # seconds, doubles each retry
_MAX_DELAY = 60 # seconds, backoff cap
@@ -2442,6 +2486,35 @@ class SiloEventListener(QtCore.QThread):
self.server_mode_changed.emit(mode)
return
# Job lifecycle events (keyed by job_id, not part_number)
job_id = payload.get("job_id", "")
if event_type == "job.created":
self.job_created.emit(
job_id,
payload.get("definition_name", ""),
payload.get("part_number", ""),
)
return
if event_type == "job.claimed":
self.job_claimed.emit(job_id, payload.get("runner_id", ""))
return
if event_type == "job.progress":
self.job_progress.emit(
job_id,
int(payload.get("progress", 0)),
payload.get("message", ""),
)
return
if event_type == "job.completed":
self.job_completed.emit(job_id)
return
if event_type == "job.failed":
self.job_failed.emit(job_id, payload.get("error", ""))
return
if event_type == "job.cancelled":
self.job_cancelled.emit(job_id)
return
pn = payload.get("part_number", "")
if not pn:
return
@@ -2451,6 +2524,18 @@ class SiloEventListener(QtCore.QThread):
elif event_type == "revision_created":
rev = payload.get("revision", 0)
self.revision_created.emit(pn, int(rev))
elif event_type == "dag.updated":
self.dag_updated.emit(
pn,
int(payload.get("node_count", 0)),
int(payload.get("edge_count", 0)),
)
elif event_type == "dag.validated":
self.dag_validated.emit(
pn,
bool(payload.get("valid", False)),
int(payload.get("failed_count", 0)),
)
class _SSEUnsupported(Exception):
@@ -2582,7 +2667,11 @@ class SiloAuthDockWidget:
btn_row.addWidget(settings_btn)
layout.addLayout(btn_row)
layout.addStretch()
# Keep the auth panel compact so the Activity panel below gets more space
self.widget.setSizePolicy(
QtGui.QSizePolicy.Preferred, QtGui.QSizePolicy.Maximum
)
# -- Status refresh -----------------------------------------------------
@@ -2674,6 +2763,14 @@ class SiloAuthDockWidget:
self._event_listener.revision_created.connect(self._on_remote_revision)
self._event_listener.connection_status.connect(self._on_sse_status)
self._event_listener.server_mode_changed.connect(self._on_server_mode)
self._event_listener.dag_updated.connect(self._on_dag_updated)
self._event_listener.dag_validated.connect(self._on_dag_validated)
self._event_listener.job_created.connect(self._on_job_created)
self._event_listener.job_claimed.connect(self._on_job_claimed)
self._event_listener.job_progress.connect(self._on_job_progress)
self._event_listener.job_completed.connect(self._on_job_completed)
self._event_listener.job_failed.connect(self._on_job_failed)
self._event_listener.job_cancelled.connect(self._on_job_cancelled)
self._event_listener.start()
else:
if self._event_listener is not None and self._event_listener.isRunning():
@@ -2830,6 +2927,60 @@ class SiloAuthDockWidget:
list_item.setData(QtCore.Qt.UserRole, pn)
activity_list.addItem(list_item)
def _on_dag_updated(self, part_number, node_count, edge_count):
FreeCAD.Console.PrintMessage(
f"Silo: DAG updated for {part_number}"
f" ({node_count} nodes, {edge_count} edges)\n"
)
self._append_activity_event(
f"\u25b6 {part_number} \u2013 DAG synced"
f" ({node_count} nodes, {edge_count} edges)",
part_number,
)
def _on_dag_validated(self, part_number, valid, failed_count):
if valid:
status = "\u2713 PASS"
FreeCAD.Console.PrintMessage(f"Silo: Validation passed for {part_number}\n")
else:
status = f"\u2717 FAIL ({failed_count} failed)"
FreeCAD.Console.PrintWarning(
f"Silo: Validation failed for {part_number}"
f" ({failed_count} features failed)\n"
)
self._append_activity_event(f"{status} \u2013 {part_number}", part_number)
def _on_job_created(self, job_id, definition_name, part_number):
FreeCAD.Console.PrintMessage(
f"Silo: Job {definition_name} created for {part_number}\n"
)
self._append_activity_event(
f"\u23f3 {part_number} \u2013 {definition_name} queued",
part_number,
)
def _on_job_completed(self, job_id):
FreeCAD.Console.PrintMessage(f"Silo: Job {job_id} completed\n")
self._rebuild_activity_feed()
def _on_job_failed(self, job_id, error):
FreeCAD.Console.PrintError(f"Silo: Job {job_id} failed: {error}\n")
self._append_activity_event(f"\u2717 Job {job_id[:8]} failed: {error}")
def _on_job_claimed(self, job_id, runner_id):
FreeCAD.Console.PrintMessage(
f"Silo: Job {job_id[:8]} claimed by runner {runner_id}\n"
)
def _on_job_progress(self, job_id, progress, message):
FreeCAD.Console.PrintMessage(
f"Silo: Job {job_id[:8]} progress {progress}%: {message}\n"
)
def _on_job_cancelled(self, job_id):
FreeCAD.Console.PrintMessage(f"Silo: Job {job_id[:8]} cancelled\n")
self._append_activity_event(f"\u2718 Job {job_id[:8]} cancelled")
def _on_activity_double_click(self, item):
"""Open/checkout item from activity pane."""
pn = item.data(256) # Qt.UserRole
@@ -3024,6 +3175,471 @@ class Silo_Auth:
return True
# ---------------------------------------------------------------------------
# Jobs
# ---------------------------------------------------------------------------
_STATUS_ICONS = {
"pending": "\u23f3", # hourglass
"claimed": "\u2699", # gear
"running": "\u25b6", # play
"completed": "\u2714", # check
"failed": "\u2717", # cross
"cancelled": "\u2013", # dash
}
class JobMonitorDialog:
"""Dialog showing job status, logs, and actions."""
def __init__(self, parent=None, part_number=None):
from PySide import QtCore, QtGui
self._part_number = part_number
self._jobs = []
self.dialog = QtGui.QDialog(parent)
self.dialog.setWindowTitle("Jobs")
self.dialog.setMinimumWidth(850)
self.dialog.setMinimumHeight(500)
layout = QtGui.QVBoxLayout(self.dialog)
# -- Filter bar --
filter_layout = QtGui.QHBoxLayout()
self._status_combo = QtGui.QComboBox()
self._status_combo.addItems(
["All", "pending", "claimed", "running", "completed", "failed", "cancelled"]
)
self._status_combo.currentIndexChanged.connect(self._refresh)
filter_layout.addWidget(QtGui.QLabel("Status:"))
filter_layout.addWidget(self._status_combo)
self._search_edit = QtGui.QLineEdit()
self._search_edit.setPlaceholderText("Filter by item or definition...")
self._search_edit.returnPressed.connect(self._refresh)
filter_layout.addWidget(self._search_edit)
filter_layout.addStretch()
trigger_btn = QtGui.QPushButton("Trigger Job...")
trigger_btn.clicked.connect(self._trigger_job)
filter_layout.addWidget(trigger_btn)
layout.addLayout(filter_layout)
# -- Splitter: table + detail --
splitter = QtGui.QSplitter(QtCore.Qt.Vertical)
layout.addWidget(splitter)
# Job table
self._table = QtGui.QTableWidget()
self._table.setColumnCount(7)
self._table.setHorizontalHeaderLabels(
[
"Status",
"Definition",
"Item",
"Runner",
"Progress",
"Created",
"Duration",
]
)
self._table.setSelectionBehavior(QtGui.QAbstractItemView.SelectRows)
self._table.setSelectionMode(QtGui.QAbstractItemView.SingleSelection)
self._table.setEditTriggers(QtGui.QAbstractItemView.NoEditTriggers)
self._table.horizontalHeader().setStretchLastSection(True)
self._table.currentCellChanged.connect(self._on_selection_changed)
splitter.addWidget(self._table)
# Detail panel
detail_widget = QtGui.QWidget()
detail_layout = QtGui.QVBoxLayout(detail_widget)
detail_layout.setContentsMargins(0, 0, 0, 0)
detail_header = QtGui.QHBoxLayout()
self._detail_label = QtGui.QLabel("Select a job to view details")
detail_header.addWidget(self._detail_label)
detail_header.addStretch()
self._cancel_btn = QtGui.QPushButton("Cancel Job")
self._cancel_btn.setEnabled(False)
self._cancel_btn.clicked.connect(self._cancel_job)
detail_header.addWidget(self._cancel_btn)
detail_layout.addLayout(detail_header)
self._log_view = QtGui.QTextEdit()
self._log_view.setReadOnly(True)
self._log_view.setFontFamily("monospace")
detail_layout.addWidget(self._log_view)
splitter.addWidget(detail_widget)
splitter.setSizes([300, 200])
self._refresh()
def _refresh(self):
from PySide import QtGui
status_filter = self._status_combo.currentText()
if status_filter == "All":
status_filter = ""
try:
self._jobs = _client.list_jobs(
status=status_filter,
definition=self._search_edit.text(),
)
except Exception as e:
FreeCAD.Console.PrintError(f"Silo: Failed to list jobs: {e}\n")
self._jobs = []
# Filter by part_number client-side if scoped
if self._part_number:
self._jobs = [
j
for j in self._jobs
if j.get("part_number") == self._part_number
or j.get("item_id") == self._part_number
]
self._table.setRowCount(len(self._jobs))
for row, job in enumerate(self._jobs):
status = job.get("status", "")
icon = _STATUS_ICONS.get(status, "?")
self._table.setItem(row, 0, QtGui.QTableWidgetItem(f"{icon} {status}"))
self._table.setItem(
row, 1, QtGui.QTableWidgetItem(job.get("definition_name", ""))
)
self._table.setItem(
row, 2, QtGui.QTableWidgetItem(job.get("part_number", ""))
)
self._table.setItem(
row, 3, QtGui.QTableWidgetItem(job.get("runner_name", ""))
)
progress = job.get("progress", 0)
progress_msg = job.get("progress_message", "")
progress_text = f"{progress}%" if progress else ""
if progress_msg:
progress_text += f" {progress_msg}"
self._table.setItem(row, 4, QtGui.QTableWidgetItem(progress_text))
created = job.get("created_at", "")
if created and len(created) > 16:
created = created[:16].replace("T", " ")
self._table.setItem(row, 5, QtGui.QTableWidgetItem(created))
duration = job.get("duration_seconds")
dur_text = f"{duration}s" if duration else ""
self._table.setItem(row, 6, QtGui.QTableWidgetItem(dur_text))
self._table.resizeColumnsToContents()
def _on_selection_changed(self, row, _col, _prev_row, _prev_col):
from PySide import QtGui
if row < 0 or row >= len(self._jobs):
self._detail_label.setText("Select a job to view details")
self._log_view.clear()
self._cancel_btn.setEnabled(False)
return
job = self._jobs[row]
job_id = job.get("id", "")
status = job.get("status", "")
defn = job.get("definition_name", "")
pn = job.get("part_number", "")
error = job.get("error_message", "")
self._detail_label.setText(f"<b>{defn}</b> \u2014 {pn} \u2014 {status}")
self._cancel_btn.setEnabled(status in ("pending", "claimed", "running"))
# Load logs
self._log_view.clear()
if error:
self._log_view.append(f"ERROR: {error}\n")
try:
logs = _client.get_job_logs(job_id)
for entry in logs:
level = entry.get("level", "info").upper()
msg = entry.get("message", "")
self._log_view.append(f"[{level}] {msg}")
except Exception as e:
self._log_view.append(f"(failed to load logs: {e})")
def _cancel_job(self):
from PySide import QtGui
row = self._table.currentRow()
if row < 0 or row >= len(self._jobs):
return
job = self._jobs[row]
job_id = job.get("id", "")
reply = QtGui.QMessageBox.question(
self.dialog,
"Cancel Job",
f"Cancel job {job.get('definition_name', '')} for "
f"{job.get('part_number', '')}?",
QtGui.QMessageBox.Yes | QtGui.QMessageBox.No,
)
if reply != QtGui.QMessageBox.Yes:
return
try:
_client.cancel_job(job_id)
FreeCAD.Console.PrintMessage(f"Silo: Job {job_id} cancelled\n")
except Exception as e:
QtGui.QMessageBox.warning(
self.dialog, "Cancel Failed", f"Failed to cancel job:\n{e}"
)
self._refresh()
def _trigger_job(self):
from PySide import QtGui
try:
definitions = _client.list_job_definitions()
except Exception as e:
QtGui.QMessageBox.warning(
self.dialog, "Error", f"Failed to load job definitions:\n{e}"
)
return
if not definitions:
QtGui.QMessageBox.information(
self.dialog,
"No Definitions",
"No job definitions are loaded on the server.",
)
return
names = [d.get("name", "") for d in definitions]
name, ok = QtGui.QInputDialog.getItem(
self.dialog, "Trigger Job", "Job definition:", names, editable=False
)
if not ok or not name:
return
pn = self._part_number or ""
if not pn:
pn, ok = QtGui.QInputDialog.getText(
self.dialog, "Trigger Job", "Part number (optional):"
)
if not ok:
return
try:
result = _client.trigger_job(name, part_number=pn)
FreeCAD.Console.PrintMessage(
f"Silo: Job triggered: {result.get('id', '')}\n"
)
except Exception as e:
QtGui.QMessageBox.warning(
self.dialog, "Trigger Failed", f"Failed to trigger job:\n{e}"
)
self._refresh()
def on_job_event(self):
"""Called from SSE handlers to refresh the table."""
if self.dialog.isVisible():
self._refresh()
def exec_(self):
self.dialog.exec_()
class Silo_Jobs:
"""View and manage compute jobs."""
def GetResources(self):
return {
"MenuText": "Jobs",
"ToolTip": "View and manage compute jobs",
"Pixmap": _icon("info"),
}
def Activated(self):
doc = FreeCAD.ActiveDocument
part_number = None
if doc:
obj = get_tracked_object(doc)
if obj and hasattr(obj, "SiloPartNumber"):
part_number = obj.SiloPartNumber
monitor = JobMonitorDialog(
parent=FreeCADGui.getMainWindow(),
part_number=part_number,
)
monitor.exec_()
def IsActive(self):
return _client.is_authenticated()
# ---------------------------------------------------------------------------
# Runners (admin)
# ---------------------------------------------------------------------------
class RunnerAdminDialog:
"""Dialog for managing runner registrations."""
def __init__(self, parent=None):
from PySide import QtCore, QtGui
self._runners = []
self.dialog = QtGui.QDialog(parent)
self.dialog.setWindowTitle("Runners")
self.dialog.setMinimumWidth(650)
self.dialog.setMinimumHeight(350)
layout = QtGui.QVBoxLayout(self.dialog)
# Runner table
self._table = QtGui.QTableWidget()
self._table.setColumnCount(5)
self._table.setHorizontalHeaderLabels(
["Name", "Tags", "Status", "Last Heartbeat", "Jobs"]
)
self._table.setSelectionBehavior(QtGui.QAbstractItemView.SelectRows)
self._table.setSelectionMode(QtGui.QAbstractItemView.SingleSelection)
self._table.setEditTriggers(QtGui.QAbstractItemView.NoEditTriggers)
self._table.horizontalHeader().setStretchLastSection(True)
layout.addWidget(self._table)
# Buttons
btn_layout = QtGui.QHBoxLayout()
register_btn = QtGui.QPushButton("Register Runner...")
register_btn.clicked.connect(self._register_runner)
btn_layout.addWidget(register_btn)
delete_btn = QtGui.QPushButton("Delete Runner")
delete_btn.clicked.connect(self._delete_runner)
btn_layout.addWidget(delete_btn)
btn_layout.addStretch()
refresh_btn = QtGui.QPushButton("Refresh")
refresh_btn.clicked.connect(self._refresh)
btn_layout.addWidget(refresh_btn)
layout.addLayout(btn_layout)
self._refresh()
def _refresh(self):
from PySide import QtGui
try:
self._runners = _client.list_runners()
except Exception as e:
FreeCAD.Console.PrintError(f"Silo: Failed to list runners: {e}\n")
self._runners = []
self._table.setRowCount(len(self._runners))
for row, runner in enumerate(self._runners):
self._table.setItem(row, 0, QtGui.QTableWidgetItem(runner.get("name", "")))
tags = ", ".join(runner.get("tags", []))
self._table.setItem(row, 1, QtGui.QTableWidgetItem(tags))
status = runner.get("status", "unknown")
icon = "\u2705" if status == "online" else "\u26aa"
self._table.setItem(row, 2, QtGui.QTableWidgetItem(f"{icon} {status}"))
heartbeat = runner.get("last_heartbeat", "")
if heartbeat and len(heartbeat) > 16:
heartbeat = heartbeat[:16].replace("T", " ")
self._table.setItem(row, 3, QtGui.QTableWidgetItem(heartbeat))
jobs = runner.get("jobs_completed", 0)
self._table.setItem(row, 4, QtGui.QTableWidgetItem(str(jobs)))
self._table.resizeColumnsToContents()
def _register_runner(self):
from PySide import QtGui
name, ok = QtGui.QInputDialog.getText(
self.dialog, "Register Runner", "Runner name:"
)
if not ok or not name:
return
tags_str, ok = QtGui.QInputDialog.getText(
self.dialog,
"Register Runner",
"Tags (comma-separated, e.g. create,linux):",
)
if not ok:
return
tags = [t.strip() for t in tags_str.split(",") if t.strip()]
try:
result = _client.register_runner(name, tags)
token = result.get("token", "")
QtGui.QMessageBox.information(
self.dialog,
"Runner Registered",
f"Runner <b>{name}</b> registered.\n\n"
f"Token (copy now — shown only once):\n\n"
f"<code>{token}</code>",
)
except Exception as e:
QtGui.QMessageBox.warning(
self.dialog,
"Registration Failed",
f"Failed to register runner:\n{e}",
)
self._refresh()
def _delete_runner(self):
from PySide import QtGui
row = self._table.currentRow()
if row < 0 or row >= len(self._runners):
return
runner = self._runners[row]
runner_name = runner.get("name", "")
runner_id = runner.get("id", "")
reply = QtGui.QMessageBox.question(
self.dialog,
"Delete Runner",
f"Delete runner <b>{runner_name}</b>?\n\nThis will invalidate its token.",
QtGui.QMessageBox.Yes | QtGui.QMessageBox.No,
)
if reply != QtGui.QMessageBox.Yes:
return
try:
_client.delete_runner(runner_id)
FreeCAD.Console.PrintMessage(f"Silo: Runner {runner_name} deleted\n")
except Exception as e:
QtGui.QMessageBox.warning(
self.dialog,
"Delete Failed",
f"Failed to delete runner:\n{e}",
)
self._refresh()
def exec_(self):
self.dialog.exec_()
class Silo_Runners:
"""Manage compute runners (admin)."""
def GetResources(self):
return {
"MenuText": "Runners",
"ToolTip": "Manage compute runners (admin)",
"Pixmap": _icon("info"),
}
def Activated(self):
admin = RunnerAdminDialog(parent=FreeCADGui.getMainWindow())
admin.exec_()
def IsActive(self):
return _client.is_authenticated()
# ---------------------------------------------------------------------------
# Start panel
# ---------------------------------------------------------------------------
@@ -3305,6 +3921,8 @@ class _DiagWorker(QtCore.QThread):
addrs = socket.getaddrinfo(
hostname, None, socket.AF_UNSPEC, socket.SOCK_STREAM
)
first_ip = addrs[0][4][0] if addrs else "?"
self.result.emit("DNS", True, f"{hostname} -> {first_ip}")
except socket.gaierror as e:
self.result.emit("DNS", False, f"{hostname}: {e}")
except Exception as e:
@@ -3423,3 +4041,5 @@ FreeCADGui.addCommand("Silo_Settings", Silo_Settings())
FreeCADGui.addCommand("Silo_Auth", Silo_Auth())
FreeCADGui.addCommand("Silo_StartPanel", Silo_StartPanel())
FreeCADGui.addCommand("Silo_Diag", Silo_Diag())
FreeCADGui.addCommand("Silo_Jobs", Silo_Jobs())
FreeCADGui.addCommand("Silo_Runners", Silo_Runners())