Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
357d25f
fix: reject job templates that declare no command to run
uberware Jul 25, 2026
396d059
fix: require the fields openjd marks mandatory on steps and environments
uberware Jul 25, 2026
6e3d7f9
fix: validate the chunk range constraint instead of assuming it
uberware Jul 25, 2026
00507d1
fix: apply host requirement structural checks when limits are disabled
uberware Jul 25, 2026
6586846
feat: support the openjd file and directory chooser controls
uberware Jul 25, 2026
71f4e53
feat: scope job parameter controls to the parameter type
uberware Jul 25, 2026
9a8a7eb
feat: carry openjd file filters on path parameters
uberware Jul 25, 2026
3408b19
fix: mark product parameter file_filters nullable
uberware Jul 25, 2026
5084592
fix: apply the openjd label length limits
uberware Jul 25, 2026
9a82c48
feat!: remove the non-standard CHIP_INPUT parameter control
uberware Jul 25, 2026
9de248f
refactor: drop the unused optional-bool parser from the python client
uberware Jul 25, 2026
8847027
docs: correct the openjd submission examples and conformance claims
uberware Jul 25, 2026
b414f5f
docs: correct the submitter guide for the openjd chooser controls
uberware Jul 25, 2026
80da5ed
fix: point the file-filter error at the field that was declared
uberware Jul 25, 2026
04534fb
fix: enforce the openjd rules for path parameter file filters
uberware Jul 25, 2026
3976700
feat: mirror path parameter file filters in the python client
uberware Jul 25, 2026
d096ab2
refactor: drop the unreachable chips form widget
uberware Jul 25, 2026
6df75b1
fix: validate the format of file filter patterns
uberware Jul 25, 2026
b5ed71d
fix: apply reserved capability value checks when limits are disabled
uberware Jul 25, 2026
5347859
test: name the reserved-name test for the behaviour it proves
uberware Jul 25, 2026
01963c8
test: close the deferred fixture and boundary-coverage gaps
uberware Jul 25, 2026
3623d4a
docs: fix the openapi reference and list the conformance page in the nav
uberware Jul 25, 2026
abc86c5
docs: make heading anchors github-compatible and complete the site nav
uberware Jul 25, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ The render farm management space is in an awkward moment. Legacy on-premises sys

**Not tied to any cloud provider.** Workers run on Linux, macOS, and Windows — bare metal, VMs, or containers. Cloud compute locations are supported across AWS, GCP, Azure, and any provider that can run a container or a binary. Your control plane runs where you want it.

**OpenJD compatible.** `sqi` adopts the [Open Job Description](https://github.com/OpenJobDescription/openjd-specifications) format as its native job execution layer. Jobs authored for OpenJD-compatible tools work with `sqi` without reformatting. This is a real standard designed for portability, not a proprietary format.
**OpenJD compatible.** `sqi` adopts the [Open Job Description](https://github.com/OpenJobDescription/openjd-specifications) format as its native job execution layer. Jobs authored for OpenJD-compatible tools work with `sqi` without reformatting. This is a real standard designed for portability, not a proprietary format. The one caveat: a template that opts into an OpenJD extension `sqi` does not implement — such as `EXPR` — is rejected by design, rather than being accepted and misinterpreted. See [`docs/openjd-conformance.md`](docs/openjd-conformance.md).

**General purpose.** Rendering is the primary use case and the domain `sqi` is designed around, but the job model is general. Any workload expressible as a command with defined inputs, outputs, and environment is a valid `sqi` job — simulation, transcoding, machine learning pipelines, data processing, software development, or anything else a studio runs at scale.

Expand Down
34 changes: 27 additions & 7 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ Configuration cascades: farm defaults → queue overrides, with retry policy (ma
`sqi` adopts the [Open Job Description](https://github.com/OpenJobDescription/openjd-specifications) (OpenJD) format as its native job execution format.

**Benefits:**
- Studios authoring jobs for other OpenJD-compatible systems can submit to `sqi` unchanged
- Studios authoring jobs for other OpenJD-compatible systems can submit to `sqi` unchanged, provided the template does not opt into an extension `sqi` has not implemented (e.g. `EXPR`) — those are rejected by design rather than accepted and misinterpreted; see [`docs/openjd-conformance.md`](docs/openjd-conformance.md)
- Standardized path mapping, parameter spaces, and execution semantics
- Clear separation between job description and job authoring (the product system)

Expand Down Expand Up @@ -161,11 +161,31 @@ S3-compatible store reachable by the operator's chosen sync tool.

### Path Translation Modes

- **OpenJD** (preferred): Standard path mapping file written into each Session. Applications that support OpenJD natively consume it directly.
- **Resolved**: All paths resolved to concrete paths before command construction. Universal for applications with no path mapping support.
- **Command arg**: Path pairs passed as explicit arguments (e.g., Maya workspace remapping).
- **Environment**: Path mappings via environment variables.
- **Staged**: Pre-job staging to worker-local storage for cloud workers without direct access to source storage.
Path translation rides the `SQI_PATH_TRANSLATION` extension and offers five
delivery mechanisms (deliveries execute in fixed order and are mutually
compatible — a product can declare all five):

- **`translation_file`** (preferred): Native OpenJD `pathmapping-1.0` file
written into each Session, served via `{{Session.PathMappingRulesFile}}`.
Applications that support OpenJD path mapping natively consume it directly.
- **`swap_in_place`**: String substitution of path parameters in the template.
Universal for applications with no path-mapping support. sqi convenience,
not in the OpenJD spec.
- **`command_flags`**: Individual `src`/`dest` pairs appended as command-line
flags (e.g., Maya workspace remapping).
- **`environment`**: Path mappings delivered via an environment variable.
- **`stage_locally`**: Job-level PATH parameters staged to worker-local
scratch before the run and copied back after, for cloud workers without
direct access to source storage. Works with no worker configuration — an
unconfigured worker falls back to a TEMP scratch directory and sqi's own
built-in copy — but a farm spanning multiple compute locations needs an
explicit `staging.scratch_dir` and `staging.sync_command`
(`rsync`/`aws-cli`/etc.) for real remote transfer.

`swap_in_place` and `translation_file` are the default when no
`SQI_PATH_TRANSLATION` extension is declared. Full reference:
[`docs/products.md`](docs/products.md#path-translation) and
[`docs/openjd-extensions/path-translation.md`](docs/openjd-extensions/path-translation.md).

### What `sqi` does not do

Expand Down Expand Up @@ -221,7 +241,7 @@ NATS can run embedded within `sqi-server` (simple mode) or as a separate cluster
- Product/preset definition system (YAML/JSON) — a thin catalog over OpenJD templates, with embedded Script/Python/Container built-ins
- Preset library integration — static JSON index at a configurable URL (default: official community library on GitHub Pages); browse presets in the Admin hub with per-preset status (not installed / installed / update available); preview the definition and install as a product (`source: installed`) in one click; SHA-256 integrity and update-detection check on install; read-only installed products, uninstallable, with Duplicate-to-custom available on every product
- Web UI product management editor and a product-driven submission form (parameter form generated from the selected product)
- Additional path translation modes (resolved, command-arg, environment, staged) as the `SQI_PATH_TRANSLATION` vendor extension
- Path translation deliveries (`swap_in_place`, `translation_file`, `command_flags`, `environment`, `stage_locally`) as the `SQI_PATH_TRANSLATION` vendor extension
- S3-compatible storage support (thin layer: derived type, root validation, path staging via operator sync tool)
- DCC submitter framework — in-application submitters for Maya, Houdini, Nuke, and Blender (the `sqi-submitter` Python package), built on the Python client
- Compute location registry and step-level affinity (native OpenJD `attr.worker.computelocation`)
Expand Down
42 changes: 36 additions & 6 deletions clients/python/src/sqi_client/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
"LogPage",
"Page",
"ParameterUserInterface",
"PathFileFilter",
"Principal",
"Product",
"ProductParameter",
Expand Down Expand Up @@ -227,10 +228,6 @@ def _as_bool(value: Any) -> bool:
return value if isinstance(value, bool) else False


def _opt_bool(value: Any) -> bool | None:
return value if isinstance(value, bool) else None


def _str_dict(value: Any) -> dict[str, str]:
if not isinstance(value, dict):
return {}
Expand Down Expand Up @@ -1222,7 +1219,6 @@ class ParameterUserInterface:
label: str = ""
group_label: str = ""
decimals: int | None = None
single_step_removal: bool | None = None

@classmethod
def from_dict(cls, data: Mapping[str, Any]) -> ParameterUserInterface:
Expand All @@ -1237,7 +1233,27 @@ def from_dict(cls, data: Mapping[str, Any]) -> ParameterUserInterface:
label=_as_str(data.get("label")),
group_label=_as_str(data.get("group_label")),
decimals=_opt_int(data.get("decimals")),
single_step_removal=_opt_bool(data.get("single_step_removal")),
)


@dataclass(frozen=True)
class PathFileFilter:
"""One named file type offered by a PATH parameter's chooser dialog."""

label: str = ""
patterns: list[str] = field(default_factory=list)

@classmethod
def from_dict(cls, data: Mapping[str, Any]) -> PathFileFilter:
"""Build an instance from a decoded JSON response object.

Unknown fields are ignored and missing or mistyped fields fall back to
type-appropriate defaults; see the module docstring for the full
tolerant-parsing contract.
"""
return cls(
label=_as_str(data.get("label")),
patterns=_str_list(data.get("patterns")),
)


Expand All @@ -1257,6 +1273,8 @@ class ProductParameter:
object_type: str = ""
data_flow: str = ""
user_interface: ParameterUserInterface | None = None
file_filters: list[PathFileFilter] | None = None
file_filter_default: PathFileFilter | None = None

@classmethod
def from_dict(cls, data: Mapping[str, Any]) -> ProductParameter:
Expand All @@ -1267,6 +1285,8 @@ def from_dict(cls, data: Mapping[str, Any]) -> ProductParameter:
tolerant-parsing contract.
"""
ui = data.get("user_interface")
raw_filters = data.get("file_filters")
default_filter = data.get("file_filter_default")
return cls(
name=_as_str(data.get("name")),
type=_as_str(data.get("type")),
Expand All @@ -1286,6 +1306,16 @@ def from_dict(cls, data: Mapping[str, Any]) -> ProductParameter:
user_interface=(
ParameterUserInterface.from_dict(ui) if isinstance(ui, Mapping) else None
),
file_filters=(
[PathFileFilter.from_dict(f) for f in raw_filters if isinstance(f, dict)]
if isinstance(raw_filters, list)
else None
),
file_filter_default=(
PathFileFilter.from_dict(default_filter)
if isinstance(default_filter, Mapping)
else None
),
)


Expand Down
42 changes: 42 additions & 0 deletions clients/python/tests/test_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -688,3 +688,45 @@ def test_product_parameter_from_dict_without_user_interface() -> None:
param = ProductParameter.from_dict({"name": "Frames", "type": "INT"})
assert param.user_interface is None
assert param.default is None


def test_product_parameter_from_dict_with_file_filters() -> None:
from sqi_client.models import ProductParameter

param = ProductParameter.from_dict(
{
"name": "SceneFile",
"type": "PATH",
"file_filters": [
{"label": "Maya Scene", "patterns": ["*.ma", "*.mb"]},
{"label": "All Files", "patterns": ["*"]},
],
"file_filter_default": {"label": "Maya Scene", "patterns": ["*.ma", "*.mb"]},
}
)
assert param.file_filters is not None
assert [f.label for f in param.file_filters] == ["Maya Scene", "All Files"]
assert param.file_filters[0].patterns == ["*.ma", "*.mb"]
assert param.file_filter_default is not None
assert param.file_filter_default.label == "Maya Scene"
assert param.file_filter_default.patterns == ["*.ma", "*.mb"]


def test_product_parameter_from_dict_with_null_file_filters() -> None:
"""The server marshals a nil slice/pointer as JSON null, not an absent key."""
from sqi_client.models import ProductParameter

param = ProductParameter.from_dict(
{"name": "SceneFile", "type": "PATH", "file_filters": None, "file_filter_default": None}
)
assert param.file_filters is None
assert param.file_filter_default is None


def test_path_file_filter_from_dict() -> None:
from sqi_client.models import PathFileFilter

f = PathFileFilter.from_dict({"label": "Maya Scene", "patterns": ["*.ma", "*.mb"], "extra": 1})
assert f == PathFileFilter(label="Maya Scene", patterns=["*.ma", "*.mb"])
# Missing fields fall back to type defaults.
assert PathFileFilter.from_dict({}) == PathFileFilter(label="", patterns=[])
12 changes: 11 additions & 1 deletion clients/python/tests/test_products.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,13 @@ def test_get_product_parameters(make_client: ClientFactory) -> None:
"type": "STRING",
"default": "final",
"user_interface": {"control": "DROPDOWN_LIST", "label": "Quality"},
}
},
{
"name": "SceneFile",
"type": "PATH",
"file_filters": [{"label": "Blender Scene", "patterns": ["*.blend"]}],
"file_filter_default": {"label": "Blender Scene", "patterns": ["*.blend"]},
},
],
)
)
Expand All @@ -83,6 +89,10 @@ def test_get_product_parameters(make_client: ClientFactory) -> None:
assert params[0].name == "Quality"
assert params[0].user_interface is not None
assert params[0].user_interface.control == "DROPDOWN_LIST"
assert params[1].file_filters is not None
assert params[1].file_filters[0].label == "Blender Scene"
assert params[1].file_filter_default is not None
assert params[1].file_filter_default.patterns == ["*.blend"]


@respx.mock
Expand Down
10 changes: 6 additions & 4 deletions clients/submitter/src/sqi_submitter/core/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,10 +29,12 @@ def widget(self) -> str:
ui = self.parameter.user_interface
control = ui.control if (ui is not None and ui.control) else ""
t = self.parameter.type
# PATH is type-first: OpenJD has no picker control, so LINE_EDIT (the
# only legal control that can carry a label on a path) must not suppress
# the derived picker. An explicit non-LINE_EDIT control (e.g. HIDDEN)
# still wins. See docs/dcc-submitters.md.
# PATH is type-first: a PATH parameter with no control (or a stale
# LINE_EDIT — no longer a legal control on PATH server-side, but
# tolerated here defensively) derives the picker instead of falling
# back to a plain text field. An explicit CHOOSE_* control falls
# through to the `if control` branch below and yields the same
# result; an explicit HIDDEN still wins. See docs/dcc-submitters.md.
if t == "PATH" and control in ("", "LINE_EDIT"):
if self.parameter.object_type == "DIRECTORY":
return "CHOOSE_DIRECTORY"
Expand Down
10 changes: 7 additions & 3 deletions clients/submitter/tests/test_schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -118,8 +118,9 @@ def test_form_field_is_scene_path() -> None:
@pytest.mark.parametrize(
("param", "widget"),
[
# PATH is type-first: a LINE_EDIT control (the only legal way to carry a
# label on a path) must not suppress the derived picker.
# PATH is type-first: an absent control, or a stale LINE_EDIT (no
# longer a legal control on PATH server-side, but tolerated here
# defensively), must not suppress the derived picker.
(_p(type_="PATH", ui={"control": "LINE_EDIT"}), "CHOOSE_INPUT_FILE"),
(
_p(type_="PATH", object_type="DIRECTORY", ui={"control": "LINE_EDIT"}),
Expand All @@ -139,11 +140,14 @@ def test_path_is_type_first(param: ProductParameter, widget: str) -> None:


def test_labeled_path_keeps_label() -> None:
# CHOOSE_DIRECTORY is the server-valid control for a labeled directory
# PATH parameter (LINE_EDIT is not legal on PATH); it falls through to
# `if control: return control` and keeps the label alongside it.
param = _p(
name="OutputDir",
type_="PATH",
object_type="DIRECTORY",
ui={"control": "LINE_EDIT", "label": "Output Directory"},
ui={"control": "CHOOSE_DIRECTORY", "label": "Output Directory"},
)
field = FormModel.from_parameters([param]).fields[0]
assert field.widget == "CHOOSE_DIRECTORY"
Expand Down
4 changes: 2 additions & 2 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -238,7 +238,7 @@ never changes scheduling, only an annotation. A task that has waited longer
than `scheduler.unschedulable_grace` with no eligible online worker is flagged
with a human-readable `unschedulable_reason`, cleared automatically once a
matching worker appears or the task leaves `ready`. See
[`docs/observability.md`](observability.md#why-isnt-my-job-running-unschedulable-tasks)
[`docs/observability.md`](observability.md#why-isnt-my-job-running--unschedulable-tasks)
for the operator-facing view and
[`scheduler.unschedulable_grace`](configuration.md#schedulerunschedulable_grace)
for the config knob.
Expand Down Expand Up @@ -442,7 +442,7 @@ sweeps); none of them route through `UpdateTaskStatus`.

A worker-reported `failed` status no longer routes straight to a terminal
state. The scheduler (`internal/scheduler/failure.go`) resolves the effective
[retry policy](configuration.md#retry-failure-limits) (Job → Queue → Farm →
[retry policy](configuration.md#retry--failure-limits) (Job → Queue → Farm →
server default) and records the genuine failure via
`store.RecordTaskFailure`, then picks one of three outcomes:

Expand Down
4 changes: 2 additions & 2 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -408,7 +408,7 @@ scheduler:
```

See
[Why isn't my job running? — Unschedulable tasks](observability.md#why-isnt-my-job-running-unschedulable-tasks)
[Why isn't my job running? — Unschedulable tasks](observability.md#why-isnt-my-job-running--unschedulable-tasks)
for what the flag means, where it surfaces (task `unschedulable_reason`, job
`task_counts.unschedulable`, the job-detail badge), and how it clears.

Expand Down Expand Up @@ -1169,7 +1169,7 @@ staging:

Full detail, including the built-in copy's local/dev caveat and the
`staging.defaults` behavior change, is in
[Worker configuration → `staging`](worker-configuration.md#staging-local-path-staging-stage_locally-delivery).
[Worker configuration → `staging`](worker-configuration.md#staging--local-path-staging-stage_locally-delivery).

### Diagnostics (`diagnostics.enabled`)

Expand Down
Loading