From 9e99b8309107aa46344b175fb23902286441dcc0 Mon Sep 17 00:00:00 2001 From: Zoe Forbes Date: Sat, 14 Feb 2026 12:50:01 -0600 Subject: [PATCH 1/8] 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. --- freecad/silo_commands.py | 182 ++++++++++++++++++++++++++++----------- 1 file changed, 132 insertions(+), 50 deletions(-) diff --git a/freecad/silo_commands.py b/freecad/silo_commands.py index bea0aa6..452f0f9 100644 --- a/freecad/silo_commands.py +++ b/freecad/silo_commands.py @@ -26,7 +26,9 @@ from silo_client import ( _PREF_GROUP = "User parameter:BaseApp/Preferences/Mod/KindredSilo" # Configuration - preferences take priority over env vars -SILO_PROJECTS_DIR = os.environ.get("SILO_PROJECTS_DIR", os.path.expanduser("~/projects")) +SILO_PROJECTS_DIR = os.environ.get( + "SILO_PROJECTS_DIR", os.path.expanduser("~/projects") +) # --------------------------------------------------------------------------- @@ -64,7 +66,9 @@ class FreeCADSiloSettings(SiloSettings): param = FreeCAD.ParamGet(_PREF_GROUP) return param.GetString("SslCertPath", "") - def save_auth(self, username: str, role: str = "", source: str = "", token: str = ""): + def save_auth( + self, username: str, role: str = "", source: str = "", token: str = "" + ): param = FreeCAD.ParamGet(_PREF_GROUP) param.SetString("AuthUsername", username) param.SetString("AuthRole", role) @@ -122,7 +126,9 @@ def _get_ssl_verify() -> bool: def _get_ssl_context(): from silo_client._ssl import build_ssl_context - return build_ssl_context(_fc_settings.get_ssl_verify(), _fc_settings.get_ssl_cert_path()) + return build_ssl_context( + _fc_settings.get_ssl_verify(), _fc_settings.get_ssl_cert_path() + ) def _get_auth_headers() -> Dict[str, str]: @@ -179,7 +185,9 @@ def _fetch_server_mode() -> str: # Icon helper # --------------------------------------------------------------------------- -_ICON_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "resources", "icons") +_ICON_DIR = os.path.join( + os.path.dirname(os.path.abspath(__file__)), "resources", "icons" +) def _icon(name): @@ -580,7 +588,9 @@ def handle_kindred_url(url: str): parts = [parsed.netloc] + [p for p in parsed.path.split("/") if p] if len(parts) >= 2 and parts[0] == "item": part_number = parts[1] - FreeCAD.Console.PrintMessage(f"Silo: Opening item {part_number} from kindred:// URL\n") + FreeCAD.Console.PrintMessage( + f"Silo: Opening item {part_number} from kindred:// URL\n" + ) _sync.open_item(part_number) @@ -600,9 +610,8 @@ class Silo_Open: } def Activated(self): - from PySide import QtGui, QtWidgets - from open_search import OpenItemWidget + from PySide import QtGui, QtWidgets mw = FreeCADGui.getMainWindow() mdi = mw.findChild(QtWidgets.QMdiArea) @@ -649,7 +658,6 @@ class Silo_New: def Activated(self): from PySide import QtGui, QtWidgets - from schema_form import SchemaFormWidget mw = FreeCADGui.getMainWindow() @@ -689,7 +697,9 @@ class Silo_New: }, ) obj.Label = part_number - _sync.save_to_canonical_path(FreeCAD.ActiveDocument, force_rename=True) + _sync.save_to_canonical_path( + FreeCAD.ActiveDocument, force_rename=True + ) else: _sync.create_document_for_item(result, save=True) @@ -770,7 +780,9 @@ class Silo_Save: # Try to upload to MinIO try: - result = _client._upload_file(part_number, str(file_path), properties, "Auto-save") + result = _client._upload_file( + part_number, str(file_path), properties, "Auto-save" + ) new_rev = result["revision_number"] FreeCAD.Console.PrintMessage(f"Uploaded as revision {new_rev}\n") @@ -803,7 +815,9 @@ class Silo_Commit: obj = get_tracked_object(doc) if not obj: - FreeCAD.Console.PrintError("No tracked object. Use 'New' to register first.\n") + FreeCAD.Console.PrintError( + "No tracked object. Use 'New' to register first.\n" + ) return part_number = obj.SiloPartNumber @@ -820,7 +834,9 @@ class Silo_Commit: if not file_path: return - result = _client._upload_file(part_number, str(file_path), properties, comment) + result = _client._upload_file( + part_number, str(file_path), properties, comment + ) new_rev = result["revision_number"] FreeCAD.Console.PrintMessage(f"Committed revision {new_rev}: {comment}\n") @@ -869,7 +885,9 @@ def _check_pull_conflicts(part_number, local_path, doc=None): server_updated = item.get("updated_at", "") if server_updated: # Parse ISO format timestamp - server_dt = datetime.datetime.fromisoformat(server_updated.replace("Z", "+00:00")) + server_dt = datetime.datetime.fromisoformat( + server_updated.replace("Z", "+00:00") + ) if server_dt > local_mtime: conflicts.append("Server version is newer than local file.") except Exception: @@ -899,7 +917,9 @@ class SiloPullDialog: # Revision table self._table = QtGui.QTableWidget() self._table.setColumnCount(5) - self._table.setHorizontalHeaderLabels(["Rev", "Date", "Comment", "Status", "File"]) + self._table.setHorizontalHeaderLabels( + ["Rev", "Date", "Comment", "Status", "File"] + ) self._table.setSelectionBehavior(QtGui.QAbstractItemView.SelectRows) self._table.setSelectionMode(QtGui.QAbstractItemView.SingleSelection) self._table.setEditTriggers(QtGui.QAbstractItemView.NoEditTriggers) @@ -992,7 +1012,9 @@ def _pull_dependencies(part_number, progress_callback=None): # Skip if already exists locally existing = find_file_by_part_number(child_pn) if existing and existing.exists(): - FreeCAD.Console.PrintMessage(f" {child_pn}: already exists at {existing}\n") + FreeCAD.Console.PrintMessage( + f" {child_pn}: already exists at {existing}\n" + ) # Still recurse — this child may itself be an assembly with missing deps _pull_dependencies(child_pn, progress_callback) continue @@ -1072,14 +1094,18 @@ class Silo_Pull: if not has_any_file: if existing_local: - FreeCAD.Console.PrintMessage(f"Opening existing local file: {existing_local}\n") + FreeCAD.Console.PrintMessage( + f"Opening existing local file: {existing_local}\n" + ) FreeCAD.openDocument(str(existing_local)) else: try: item = _client.get_item(part_number) new_doc = _sync.create_document_for_item(item, save=True) if new_doc: - FreeCAD.Console.PrintMessage(f"Created local file for {part_number}\n") + FreeCAD.Console.PrintMessage( + f"Created local file for {part_number}\n" + ) else: QtGui.QMessageBox.warning( None, @@ -1166,7 +1192,9 @@ class Silo_Pull: progress.setValue(100) progress.close() if dep_pulled: - FreeCAD.Console.PrintMessage(f"Pulled {len(dep_pulled)} dependency file(s)\n") + FreeCAD.Console.PrintMessage( + f"Pulled {len(dep_pulled)} dependency file(s)\n" + ) # Close existing document if open, then reopen if doc and doc.FileName == str(dest_path): @@ -1221,7 +1249,9 @@ class Silo_Push: server_dt = datetime.fromisoformat( server_time_str.replace("Z", "+00:00") ) - local_dt = datetime.fromtimestamp(local_mtime, tz=timezone.utc) + local_dt = datetime.fromtimestamp( + local_mtime, tz=timezone.utc + ) if local_dt > server_dt: unuploaded.append(lf) else: @@ -1234,7 +1264,9 @@ class Silo_Push: pass # Not in DB, skip if not unuploaded: - QtGui.QMessageBox.information(None, "Push", "All local files are already uploaded.") + QtGui.QMessageBox.information( + None, "Push", "All local files are already uploaded." + ) return msg = f"Found {len(unuploaded)} files to upload:\n\n" @@ -1252,7 +1284,9 @@ class Silo_Push: uploaded = 0 for item in unuploaded: - result = _sync.upload_file(item["part_number"], item["path"], "Synced from local") + result = _sync.upload_file( + item["part_number"], item["path"], "Synced from local" + ) if result: uploaded += 1 @@ -1301,9 +1335,7 @@ class Silo_Info: msg = f"

{part_number}

" msg += f"

Type: {item.get('item_type', '-')}

" msg += f"

Description: {item.get('description', '-')}

" - msg += ( - f"

Projects: {', '.join(project_codes) if project_codes else 'None'}

" - ) + msg += f"

Projects: {', '.join(project_codes) if project_codes else 'None'}

" msg += f"

Current Revision: {item.get('current_revision', 1)}

" msg += f"

Local Revision: {getattr(obj, 'SiloRevision', '-')}

" @@ -1369,7 +1401,9 @@ class Silo_TagProjects: try: # Get current projects for item current_projects = _client.get_item_projects(part_number) - current_codes = {p.get("code", "") for p in current_projects if p.get("code")} + current_codes = { + p.get("code", "") for p in current_projects if p.get("code") + } # Get all available projects all_projects = _client.get_projects() @@ -1480,7 +1514,9 @@ class Silo_Rollback: dialog.setMinimumHeight(300) layout = QtGui.QVBoxLayout(dialog) - label = QtGui.QLabel(f"Select a revision to rollback to (current: Rev {current_rev}):") + label = QtGui.QLabel( + f"Select a revision to rollback to (current: Rev {current_rev}):" + ) layout.addWidget(label) # Revision table @@ -1495,8 +1531,12 @@ class Silo_Rollback: for i, rev in enumerate(prev_revisions): table.setItem(i, 0, QtGui.QTableWidgetItem(str(rev["revision_number"]))) table.setItem(i, 1, QtGui.QTableWidgetItem(rev.get("status", "draft"))) - table.setItem(i, 2, QtGui.QTableWidgetItem(rev.get("created_at", "")[:10])) - table.setItem(i, 3, QtGui.QTableWidgetItem(rev.get("comment", "") or "")) + table.setItem( + i, 2, QtGui.QTableWidgetItem(rev.get("created_at", "")[:10]) + ) + table.setItem( + i, 3, QtGui.QTableWidgetItem(rev.get("comment", "") or "") + ) table.resizeColumnsToContents() layout.addWidget(table) @@ -1522,7 +1562,9 @@ class Silo_Rollback: def on_rollback(): selected = table.selectedItems() if not selected: - QtGui.QMessageBox.warning(dialog, "Rollback", "Please select a revision") + QtGui.QMessageBox.warning( + dialog, "Rollback", "Please select a revision" + ) return selected_rev[0] = int(table.item(selected[0].row(), 0).text()) dialog.accept() @@ -1620,7 +1662,9 @@ class Silo_SetStatus: # Update status _client.update_revision(part_number, rev_num, status=status) - FreeCAD.Console.PrintMessage(f"Updated Rev {rev_num} status to '{status}'\n") + FreeCAD.Console.PrintMessage( + f"Updated Rev {rev_num} status to '{status}'\n" + ) QtGui.QMessageBox.information( None, "Status Updated", f"Revision {rev_num} status set to '{status}'" ) @@ -1684,7 +1728,9 @@ class Silo_Settings: ssl_checkbox.setChecked(param.GetBool("SslVerify", True)) layout.addWidget(ssl_checkbox) - ssl_hint = QtGui.QLabel("Disable only for internal servers with self-signed certificates.") + ssl_hint = QtGui.QLabel( + "Disable only for internal servers with self-signed certificates." + ) ssl_hint.setWordWrap(True) ssl_hint.setStyleSheet("color: #888; font-size: 11px;") layout.addWidget(ssl_hint) @@ -1717,7 +1763,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: @@ -1961,7 +2007,9 @@ class Silo_BOM: wu_table = QtGui.QTableWidget() wu_table.setColumnCount(5) - wu_table.setHorizontalHeaderLabels(["Parent Part Number", "Type", "Qty", "Unit", "Ref Des"]) + wu_table.setHorizontalHeaderLabels( + ["Parent Part Number", "Type", "Qty", "Unit", "Ref Des"] + ) wu_table.setSelectionBehavior(QtGui.QAbstractItemView.SelectRows) wu_table.setSelectionMode(QtGui.QAbstractItemView.SingleSelection) wu_table.setEditTriggers(QtGui.QAbstractItemView.NoEditTriggers) @@ -1990,12 +2038,16 @@ class Silo_BOM: bom_table.setItem( row, 1, QtGui.QTableWidgetItem(entry.get("child_description", "")) ) - bom_table.setItem(row, 2, QtGui.QTableWidgetItem(entry.get("rel_type", ""))) + bom_table.setItem( + row, 2, QtGui.QTableWidgetItem(entry.get("rel_type", "")) + ) qty = entry.get("quantity") bom_table.setItem( row, 3, QtGui.QTableWidgetItem(str(qty) if qty is not None else "") ) - bom_table.setItem(row, 4, QtGui.QTableWidgetItem(entry.get("unit") or "")) + bom_table.setItem( + row, 4, QtGui.QTableWidgetItem(entry.get("unit") or "") + ) ref_des = entry.get("reference_designators") or [] bom_table.setItem(row, 5, QtGui.QTableWidgetItem(", ".join(ref_des))) bom_table.setItem( @@ -2017,12 +2069,16 @@ class Silo_BOM: wu_table.setItem( row, 0, QtGui.QTableWidgetItem(entry.get("parent_part_number", "")) ) - wu_table.setItem(row, 1, QtGui.QTableWidgetItem(entry.get("rel_type", ""))) + wu_table.setItem( + row, 1, QtGui.QTableWidgetItem(entry.get("rel_type", "")) + ) qty = entry.get("quantity") wu_table.setItem( row, 2, QtGui.QTableWidgetItem(str(qty) if qty is not None else "") ) - wu_table.setItem(row, 3, QtGui.QTableWidgetItem(entry.get("unit") or "")) + wu_table.setItem( + row, 3, QtGui.QTableWidgetItem(entry.get("unit") or "") + ) ref_des = entry.get("reference_designators") or [] wu_table.setItem(row, 4, QtGui.QTableWidgetItem(", ".join(ref_des))) wu_table.resizeColumnsToContents() @@ -2075,7 +2131,9 @@ class Silo_BOM: try: qty = float(qty_text) except ValueError: - QtGui.QMessageBox.warning(dialog, "BOM", "Quantity must be a number.") + QtGui.QMessageBox.warning( + dialog, "BOM", "Quantity must be a number." + ) return unit = unit_input.text().strip() or None @@ -2154,7 +2212,9 @@ class Silo_BOM: try: new_qty = float(qty_text) except ValueError: - QtGui.QMessageBox.warning(dialog, "BOM", "Quantity must be a number.") + QtGui.QMessageBox.warning( + dialog, "BOM", "Quantity must be a number." + ) return new_unit = unit_input.text().strip() or None @@ -2178,7 +2238,9 @@ class Silo_BOM: ) load_bom() except Exception as exc: - QtGui.QMessageBox.warning(dialog, "BOM", f"Failed to update entry:\n{exc}") + QtGui.QMessageBox.warning( + dialog, "BOM", f"Failed to update entry:\n{exc}" + ) def on_remove(): selected = bom_table.selectedItems() @@ -2204,7 +2266,9 @@ class Silo_BOM: _client.delete_bom_entry(part_number, child_pn) load_bom() except Exception as exc: - QtGui.QMessageBox.warning(dialog, "BOM", f"Failed to remove entry:\n{exc}") + QtGui.QMessageBox.warning( + dialog, "BOM", f"Failed to remove entry:\n{exc}" + ) add_btn.clicked.connect(on_add) edit_btn.clicked.connect(on_edit) @@ -2243,7 +2307,9 @@ class SiloEventListener(QtCore.QThread): item_updated = QtCore.Signal(str) # part_number revision_created = QtCore.Signal(str, int) # part_number, revision - connection_status = QtCore.Signal(str, int, str) # (status, retry_count, error_message) + connection_status = QtCore.Signal( + str, int, str + ) # (status, retry_count, error_message) server_mode_changed = QtCore.Signal(str) # "normal" / "read-only" / "degraded" _MAX_RETRIES = 10 @@ -2317,7 +2383,9 @@ class SiloEventListener(QtCore.QThread): req = urllib.request.Request(url, headers=headers, method="GET") try: - self._response = urllib.request.urlopen(req, context=_get_ssl_context(), timeout=90) + self._response = urllib.request.urlopen( + req, context=_get_ssl_context(), timeout=90 + ) except urllib.error.HTTPError as e: if e.code in (404, 501): raise _SSEUnsupported() @@ -2598,7 +2666,9 @@ class SiloAuthDockWidget: self._sse_label.setToolTip("") FreeCAD.Console.PrintMessage("Silo: SSE connected\n") elif status == "disconnected": - self._sse_label.setText(f"Reconnecting ({retry}/{SiloEventListener._MAX_RETRIES})...") + self._sse_label.setText( + f"Reconnecting ({retry}/{SiloEventListener._MAX_RETRIES})..." + ) self._sse_label.setStyleSheet("font-size: 11px; color: #FF9800;") self._sse_label.setToolTip(error or "Connection lost") FreeCAD.Console.PrintWarning( @@ -2608,7 +2678,9 @@ class SiloAuthDockWidget: self._sse_label.setText("Disconnected") self._sse_label.setStyleSheet("font-size: 11px; color: #F44336;") self._sse_label.setToolTip(error or "Max retries reached") - FreeCAD.Console.PrintError(f"Silo: SSE gave up after {retry} retries: {error}\n") + FreeCAD.Console.PrintError( + f"Silo: SSE gave up after {retry} retries: {error}\n" + ) elif status == "unsupported": self._sse_label.setText("Not available") self._sse_label.setStyleSheet("font-size: 11px; color: #888;") @@ -2650,10 +2722,14 @@ class SiloAuthDockWidget: self._refresh_activity_panel() def _on_remote_revision(self, part_number, revision): - FreeCAD.Console.PrintMessage(f"Silo: New revision {revision} for {part_number}\n") + FreeCAD.Console.PrintMessage( + f"Silo: New revision {revision} for {part_number}\n" + ) mw = FreeCADGui.getMainWindow() if mw is not None: - mw.statusBar().showMessage(f"Silo: {part_number} rev {revision} available", 5000) + mw.statusBar().showMessage( + f"Silo: {part_number} rev {revision} available", 5000 + ) self._refresh_activity_panel() def _refresh_activity_panel(self): @@ -2719,7 +2795,9 @@ class SiloAuthDockWidget: rev_part = f" \u2013 Rev {rev_num}" if rev_num else "" date_part = f" \u2013 {updated}" if updated else "" local_badge = " \u25cf local" if pn in local_pns else "" - line1 = f"{pn} \u2013 {desc_display}{rev_part}{date_part}{local_badge}" + line1 = ( + f"{pn} \u2013 {desc_display}{rev_part}{date_part}{local_badge}" + ) if comment: line1 += f'\n "{comment}"' @@ -3175,7 +3253,9 @@ class Silo_StartPanel: dock = QtGui.QDockWidget("Silo", mw) dock.setObjectName("SiloStartPanel") dock.setWidget(content.widget) - dock.setAllowedAreas(QtCore.Qt.LeftDockWidgetArea | QtCore.Qt.RightDockWidgetArea) + dock.setAllowedAreas( + QtCore.Qt.LeftDockWidgetArea | QtCore.Qt.RightDockWidgetArea + ) mw.addDockWidget(QtCore.Qt.LeftDockWidgetArea, dock) def IsActive(self): @@ -3209,7 +3289,9 @@ class _DiagWorker(QtCore.QThread): self.result.emit("DNS", False, "no hostname in URL") return try: - addrs = socket.getaddrinfo(hostname, None, socket.AF_UNSPEC, socket.SOCK_STREAM) + 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: From 3a9fe6aed89cc36f68999feae8e327e64b8c1885 Mon Sep 17 00:00:00 2001 From: Zoe Forbes Date: Sat, 14 Feb 2026 14:41:35 -0600 Subject: [PATCH 2/8] 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 --- freecad/dag.py | 463 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 463 insertions(+) create mode 100644 freecad/dag.py diff --git a/freecad/dag.py b/freecad/dag.py new file mode 100644 index 0000000..8d0af67 --- /dev/null +++ b/freecad/dag.py @@ -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..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.``, 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 From 492109529632fedc9be90e1a0eb91fbe665b5d97 Mon Sep 17 00:00:00 2001 From: Zoe Forbes Date: Sat, 14 Feb 2026 15:06:31 -0600 Subject: [PATCH 3/8] =?UTF-8?q?feat:=20update=20silo-client=20=E2=80=94=20?= =?UTF-8?q?add=20DAG=20API=20methods?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Points silo-client to feat/dag-api-methods with push_dag/get_dag. Closes kindred/create#215 --- silo-client | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/silo-client b/silo-client index 68a4139..fb658c5 160000 --- a/silo-client +++ b/silo-client @@ -1 +1 @@ -Subproject commit 68a41392516f2aea7c93ff6ecf20a62404b03df6 +Subproject commit fb658c5a249275700eab5a5e863e46038f613950 From 3dd0da39648d756cc5df0fab757a85d01c3bae6e Mon Sep 17 00:00:00 2001 From: Zoe Forbes Date: Sat, 14 Feb 2026 15:10:50 -0600 Subject: [PATCH 4/8] 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 --- freecad/silo_commands.py | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/freecad/silo_commands.py b/freecad/silo_commands.py index 452f0f9..b3c7442 100644 --- a/freecad/silo_commands.py +++ b/freecad/silo_commands.py @@ -717,6 +717,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.""" @@ -787,6 +809,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") @@ -841,6 +865,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") From da2a360c566edd88d6c66c4f7270505d225270e8 Mon Sep 17 00:00:00 2001 From: Zoe Forbes Date: Sat, 14 Feb 2026 15:17:30 -0600 Subject: [PATCH 5/8] 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 --- freecad/runner.py | 156 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 156 insertions(+) create mode 100644 freecad/runner.py diff --git a/freecad/runner.py b/freecad/runner.py new file mode 100644 index 0000000..5a7514b --- /dev/null +++ b/freecad/runner.py @@ -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) From 3d38e4b4c376237d4a0c34d091d0379dd31c803c Mon Sep 17 00:00:00 2001 From: Zoe Forbes Date: Sat, 14 Feb 2026 15:22:29 -0600 Subject: [PATCH 6/8] 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 --- freecad/silo_commands.py | 53 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/freecad/silo_commands.py b/freecad/silo_commands.py index b3c7442..04b8985 100644 --- a/freecad/silo_commands.py +++ b/freecad/silo_commands.py @@ -2338,6 +2338,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 @@ -2454,6 +2466,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 @@ -2463,6 +2504,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): From dc64a66f0f397d2f9ba58377d88f4cbafebd408e Mon Sep 17 00:00:00 2001 From: Zoe Forbes Date: Sat, 14 Feb 2026 15:28:40 -0600 Subject: [PATCH 7/8] 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 --- freecad/silo_commands.py | 69 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 69 insertions(+) diff --git a/freecad/silo_commands.py b/freecad/silo_commands.py index 04b8985..91960ba 100644 --- a/freecad/silo_commands.py +++ b/freecad/silo_commands.py @@ -2732,6 +2732,11 @@ 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_completed.connect(self._on_job_completed) + self._event_listener.job_failed.connect(self._on_job_failed) self._event_listener.start() else: if self._event_listener is not None and self._event_listener.isRunning(): @@ -2811,6 +2816,70 @@ class SiloAuthDockWidget: ) self._refresh_activity_panel() + 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._add_activity_entry( + 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._add_activity_entry(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._add_activity_entry( + 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._refresh_activity_panel() + + def _on_job_failed(self, job_id, error): + FreeCAD.Console.PrintError(f"Silo: Job {job_id} failed: {error}\n") + self._add_activity_entry(f"\u2717 Job {job_id[:8]} failed: {error}", None) + + def _add_activity_entry(self, text, part_number): + """Insert a live event entry at the top of the Activity panel.""" + from PySide import QtCore, QtGui, QtWidgets + + mw = FreeCADGui.getMainWindow() + if mw is None: + return + panel = mw.findChild(QtWidgets.QDockWidget, "SiloDatabaseActivity") + if panel is None: + return + activity_list = panel.findChild(QtWidgets.QListWidget) + if activity_list is None: + return + + item = QtWidgets.QListWidgetItem(text) + if part_number: + item.setData(QtCore.Qt.UserRole, part_number) + item.setForeground(QtGui.QColor("#89b4fa")) + activity_list.insertItem(0, item) + + # Cap the list at 50 entries + while activity_list.count() > 50: + activity_list.takeItem(activity_list.count() - 1) + def _refresh_activity_panel(self): """Refresh the Database Activity panel if it exists.""" from PySide import QtCore, QtGui, QtWidgets From e31321ac951483f9c16bfd3dadfdfcd1770c5b3e Mon Sep 17 00:00:00 2001 From: Zoe Forbes Date: Sun, 15 Feb 2026 05:07:33 -0600 Subject: [PATCH 8/8] 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 --- freecad/InitGui.py | 3 + freecad/silo_commands.py | 484 +++++++++++++++++++++++++++++++++++++++ silo-client | 2 +- 3 files changed, 488 insertions(+), 1 deletion(-) diff --git a/freecad/InitGui.py b/freecad/InitGui.py index 6687c52..aae3b2a 100644 --- a/freecad/InitGui.py +++ b/freecad/InitGui.py @@ -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", ] diff --git a/freecad/silo_commands.py b/freecad/silo_commands.py index 91960ba..b57f48a 100644 --- a/freecad/silo_commands.py +++ b/freecad/silo_commands.py @@ -2735,8 +2735,11 @@ class SiloAuthDockWidget: 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(): @@ -2856,6 +2859,20 @@ class SiloAuthDockWidget: FreeCAD.Console.PrintError(f"Silo: Job {job_id} failed: {error}\n") self._add_activity_entry(f"\u2717 Job {job_id[:8]} failed: {error}", None) + 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._add_activity_entry(f"\u2718 Job {job_id[:8]} cancelled", None) + def _add_activity_entry(self, text, part_number): """Insert a live event entry at the top of the Activity panel.""" from PySide import QtCore, QtGui, QtWidgets @@ -3159,6 +3176,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"{defn} \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 {name} registered.\n\n" + f"Token (copy now — shown only once):\n\n" + f"{token}", + ) + 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 {runner_name}?\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 # --------------------------------------------------------------------------- @@ -3560,3 +4042,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()) diff --git a/silo-client b/silo-client index fb658c5..9b71cf0 160000 --- a/silo-client +++ b/silo-client @@ -1 +1 @@ -Subproject commit fb658c5a249275700eab5a5e863e46038f613950 +Subproject commit 9b71cf037555b111bb1345c9d93743693d40e68d