Files
silo/docs/DAG_CLIENT_INTEGRATION.md

12 KiB

DAG Client Integration Contract

Status: Draft Last Updated: 2026-02-13

This document describes what silo-mod and Headless Create runners need to implement to integrate with the Silo dependency DAG and worker system.


1. Overview

The DAG system has two client-side integration points:

  1. silo-mod workbench (desktop) -- pushes DAG data to Silo on save or revision create.
  2. silorunner + silo-mod (headless) -- extracts DAGs, validates features, and exports geometry as compute jobs.

Both share the same Python codebase in the silo-mod repository. Desktop users call the code interactively; runners call it headlessly via create --console.


2. DAG Sync Payload

Clients push feature trees to Silo via:

PUT /api/items/{partNumber}/dag
Authorization: Bearer <user_token or runner_token>
Content-Type: application/json

2.1 Request Body

{
  "revision_number": 3,
  "nodes": [
    {
      "node_key": "Sketch001",
      "node_type": "sketch",
      "properties_hash": "a1b2c3d4e5f6...",
      "metadata": {
        "label": "Base Profile",
        "constraint_count": 12
      }
    },
    {
      "node_key": "Pad001",
      "node_type": "pad",
      "properties_hash": "f6e5d4c3b2a1...",
      "metadata": {
        "label": "Main Extrusion",
        "length": 25.0
      }
    }
  ],
  "edges": [
    {
      "source_key": "Sketch001",
      "target_key": "Pad001",
      "edge_type": "depends_on"
    }
  ]
}

2.2 Field Reference

Nodes:

Field Type Required Description
node_key string yes Unique within item+revision. Use Create's internal object name (e.g. Sketch001, Pad003).
node_type string yes One of: sketch, pad, pocket, fillet, chamfer, constraint, body, part, datum.
properties_hash string no SHA-256 hex digest of the node's parametric inputs. Used for memoization.
validation_state string no One of: clean, dirty, validating, failed. Defaults to clean.
metadata object no Arbitrary key-value pairs for display or debugging.

Edges:

Field Type Required Description
source_key string yes The node that is depended upon.
target_key string yes The node that depends on the source.
edge_type string no One of: depends_on (default), references, constrains.

Direction convention: Edges point from dependency to dependent. If Pad001 depends on Sketch001, the edge is source_key: "Sketch001", target_key: "Pad001".

2.3 Response

{
  "synced": true,
  "node_count": 15,
  "edge_count": 14
}

3. Computing properties_hash

The properties_hash enables memoization -- if a node's inputs haven't changed since the last validation, it can be skipped. Computing it:

import hashlib
import json

def compute_properties_hash(feature_obj):
    """Hash the parametric inputs of a Create feature."""
    inputs = {}

    if feature_obj.TypeId == "Sketcher::SketchObject":
        # Hash geometry + constraints
        inputs["geometry_count"] = feature_obj.GeometryCount
        inputs["constraint_count"] = feature_obj.ConstraintCount
        inputs["geometry"] = str(feature_obj.Shape.exportBrep())
    elif feature_obj.TypeId == "PartDesign::Pad":
        inputs["length"] = feature_obj.Length.Value
        inputs["type"] = str(feature_obj.Type)
        inputs["reversed"] = feature_obj.Reversed
        inputs["sketch"] = feature_obj.Profile[0].Name
    # ... other feature types

    canonical = json.dumps(inputs, sort_keys=True)
    return hashlib.sha256(canonical.encode()).hexdigest()

The exact inputs per feature type are determined by what parametric values affect the feature's geometry. Include anything that, if changed, would require recomputation.


4. Feature Tree Walking

To extract the DAG from a Create document:

import FreeCAD

def extract_dag(doc):
    """Walk a Create document and return nodes + edges."""
    nodes = []
    edges = []

    for obj in doc.Objects:
        # Skip non-feature objects
        if not hasattr(obj, "TypeId"):
            continue

        node_type = classify_type(obj.TypeId)
        if node_type is None:
            continue

        nodes.append({
            "node_key": obj.Name,
            "node_type": node_type,
            "properties_hash": compute_properties_hash(obj),
            "metadata": {
                "label": obj.Label,
                "type_id": obj.TypeId,
            }
        })

        # Walk dependencies via InList (objects this one depends on)
        for dep in obj.InList:
            if hasattr(dep, "TypeId") and classify_type(dep.TypeId):
                edges.append({
                    "source_key": dep.Name,
                    "target_key": obj.Name,
                    "edge_type": "depends_on",
                })

    return nodes, edges


def classify_type(type_id):
    """Map Create TypeIds to DAG node types."""
    mapping = {
        "Sketcher::SketchObject": "sketch",
        "PartDesign::Pad": "pad",
        "PartDesign::Pocket": "pocket",
        "PartDesign::Fillet": "fillet",
        "PartDesign::Chamfer": "chamfer",
        "PartDesign::Body": "body",
        "Part::Feature": "part",
        "Sketcher::SketchConstraint": "constraint",
    }
    return mapping.get(type_id)

5. When to Push DAG Data

Push the DAG to Silo in these scenarios:

Event Trigger Who
User saves in silo-mod On save callback Desktop silo-mod workbench
User creates a revision After POST /api/items/{pn}/revisions succeeds Desktop silo-mod workbench
Runner extracts DAG After create-dag-extract job completes silorunner via PUT /api/runner/jobs/{id}/dag
Runner validates After create-validate job, push updated validation states silorunner via PUT /api/runner/jobs/{id}/dag

6. Runner Entry Points

silo-mod must provide these Python entry points for headless invocation:

6.1 silo.runner.dag_extract

Extracts the feature DAG from a Create file and writes it as JSON.

# silo/runner.py

def dag_extract(input_path, output_path):
    """
    Extract feature DAG from a Create file.

    Args:
        input_path: Path to the .kc (Kindred Create) file.
        output_path: Path to write the JSON output.

    Output JSON format:
    {
        "nodes": [...],  // Same format as DAG sync payload
        "edges": [...]
    }
    """
    doc = FreeCAD.openDocument(input_path)
    nodes, edges = extract_dag(doc)

    with open(output_path, 'w') as f:
        json.dump({"nodes": nodes, "edges": edges}, f)

    FreeCAD.closeDocument(doc.Name)

6.2 silo.runner.validate

Rebuilds all features and reports pass/fail per node.

def validate(input_path, output_path):
    """
    Validate a Create file by rebuilding all features.

    Output JSON format:
    {
        "valid": true/false,
        "nodes": [
            {
                "node_key": "Pad001",
                "state": "clean",       // or "failed"
                "message": null,        // error message if failed
                "properties_hash": "..."
            }
        ]
    }
    """
    doc = FreeCAD.openDocument(input_path)
    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)

    FreeCAD.closeDocument(doc.Name)

6.3 silo.runner.export

Exports geometry to STEP, IGES, or other formats.

def export(input_path, output_path, format="step"):
    """
    Export a Create file to an external format.

    Args:
        input_path: Path to the .kc file.
        output_path: Path to write the exported file.
        format: Export format ("step", "iges", "stl", "obj").
    """
    doc = FreeCAD.openDocument(input_path)

    import Part
    shapes = [obj.Shape for obj in doc.Objects if hasattr(obj, "Shape")]
    compound = Part.makeCompound(shapes)

    format_map = {
        "step": "STEP",
        "iges": "IGES",
        "stl": "STL",
        "obj": "OBJ",
    }

    Part.export([compound], output_path)
    FreeCAD.closeDocument(doc.Name)

7. Headless Invocation

The silorunner binary shells out to Create (with silo-mod installed):

# DAG extraction
create --console -e "from silo.runner import dag_extract; dag_extract('/tmp/job/part.kc', '/tmp/job/dag.json')"

# Validation
create --console -e "from silo.runner import validate; validate('/tmp/job/part.kc', '/tmp/job/result.json')"

# Export
create --console -e "from silo.runner import export; export('/tmp/job/part.kc', '/tmp/job/output.step', 'step')"

Prerequisites: The runner host must have:

  • Headless Create installed (Kindred's fork of FreeCAD)
  • silo-mod installed as a Create addon (so from silo.runner import ... works)
  • No display server required -- --console mode is headless

8. Validation Result Handling

After a runner completes a create-validate job, it should:

  1. Read the result JSON.
  2. Push updated validation states via PUT /api/runner/jobs/{jobID}/dag:
{
  "revision_number": 3,
  "nodes": [
    {"node_key": "Sketch001", "node_type": "sketch", "validation_state": "clean", "properties_hash": "abc..."},
    {"node_key": "Pad001", "node_type": "pad", "validation_state": "failed", "properties_hash": "def..."}
  ],
  "edges": [
    {"source_key": "Sketch001", "target_key": "Pad001"}
  ]
}
  1. Complete the job via POST /api/runner/jobs/{jobID}/complete with the summary result.

9. SSE Events

Clients should listen for these events on GET /api/events:

Event Payload When
dag.updated {item_id, part_number, revision_number, node_count, edge_count} After any DAG sync
dag.validated {item_id, part_number, valid, failed_count} After validation completes
job.created {job_id, definition_name, trigger, item_id} Job auto-triggered or manually created
job.claimed {job_id, runner_id, runner} Runner claims a job
job.progress {job_id, progress, message} Runner reports progress
job.completed {job_id, runner_id} Job finishes successfully
job.failed {job_id, runner_id, error} Job fails
job.cancelled {job_id, cancelled_by} Job cancelled by user

10. Cross-Item Edges

For assembly constraints that reference geometry in child parts (e.g. a mate constraint between two parts), use the dag_cross_edges table. These edges bridge the BOM DAG and the feature DAG.

Cross-item edges are not included in the standard PUT /dag sync. They will be managed through a dedicated endpoint in a future iteration once the assembly constraint model in Create/silo-mod is finalized.

For now, the DAG sync covers intra-item dependencies only. Assembly-level interference detection uses the BOM DAG (relationships table) combined with per-item feature DAGs.