feat(bec_widgets): add command-job builder to the tomo queue GUI

First iteration of Step 3 (TOMO_QUEUE_COMMAND_JOBS_PLAN.md section 6)
on top of the existing TomoParamsWidget/TomoQueueDialog baseline:

- CommandJobBuilderDialog: a structured step-by-step form (action
  dropdown, per-action fields) built entirely from the live
  tomo_queue_actions global var, so it tracks flomni.py's action
  registry and device allow-list with no hardcoded mirror. Produces a
  job dict identical to what tomo_queue_add_command() writes.
- TomoQueueDialog's queue table gained a "Details" column and renders
  command jobs (CMD + step summary + idempotency marker) the same way
  flomni.tomo_queue_show() does.
- "Add current params to queue" now stamps kind="tomo" and a fresh id
  on the job, matching the CLI's own self-describing format.
- Delete/clear now refuse when a "running" job is involved instead of
  silently allowing it (plan section 6.3).

Mid-run drag-reorder of queue jobs (plan section 6.4), multi-device
single move steps, and live device-limit validation are deliberately
not in this iteration - see TOMO_QUEUE_GUI_TESTING.md.
This commit is contained in:
Mirko Holler
2026-07-13 09:26:04 +02:00
parent 78f4757c80
commit 3887a5ffbc
@@ -41,6 +41,7 @@ explicit hint for executing it from the CLI.
from __future__ import annotations
import datetime
import uuid
from typing import Any, Optional
from bec_lib import bec_logger
@@ -51,6 +52,7 @@ from qtpy.QtWidgets import (
QCheckBox,
QComboBox,
QDialog,
QDialogButtonBox,
QDoubleSpinBox,
QFormLayout,
QFrame,
@@ -60,6 +62,7 @@ from qtpy.QtWidgets import (
QInputDialog,
QLabel,
QLineEdit,
QListWidget,
QMessageBox,
QPushButton,
QScrollArea,
@@ -997,9 +1000,19 @@ class TomoQueueDialog(QDialog):
vbox = QVBoxLayout(self)
vbox.setSpacing(6)
self._table = QTableWidget(0, 8)
self._table = QTableWidget(0, 9)
self._table.setHorizontalHeaderLabels(
["#", "Label", "Status", "Type", "Projections", "Exp (s)", "Step (µm)", "Added at"]
[
"#",
"Label",
"Status",
"Type",
"Projections",
"Exp (s)",
"Step (µm)",
"Details",
"Added at",
]
)
self._table.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Stretch)
self._table.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows)
@@ -1008,14 +1021,17 @@ class TomoQueueDialog(QDialog):
btn_row = QHBoxLayout()
btn_add = QPushButton("Add current params to queue")
btn_add_cmd = QPushButton("Add command…")
btn_del = QPushButton("Delete selected")
btn_clr = QPushButton("Clear all")
btn_exe = QPushButton("Execute queue…")
btn_add.clicked.connect(self.add_to_queue)
btn_add_cmd.clicked.connect(self.add_command_to_queue)
btn_del.clicked.connect(self._delete_selected)
btn_clr.clicked.connect(self._clear_queue)
btn_exe.clicked.connect(self._show_execute_hint)
btn_row.addWidget(btn_add)
btn_row.addWidget(btn_add_cmd)
btn_row.addWidget(btn_del)
btn_row.addWidget(btn_clr)
btn_row.addStretch()
@@ -1028,19 +1044,33 @@ class TomoQueueDialog(QDialog):
jobs = self._load_queue()
self._table.setRowCount(len(jobs))
for row, job in enumerate(jobs):
params = job.get("params", {})
status = job.get("status", "pending")
color = STATUS_COLORS.get(status, "#888888")
cells = [
str(row),
job.get("label", f"job_{row}"),
status,
str(params.get("tomo_type", "?")),
_format_projections(params),
_fmt_num(params.get("tomo_countingtime")),
_fmt_num(params.get("tomo_shellstep")),
job.get("added_at", ""),
]
if _job_kind(job) == "command":
cells = [
str(row),
job.get("label", f"job_{row}"),
status,
"CMD",
"",
"",
"",
_format_command_summary(job),
job.get("added_at", ""),
]
else:
params = job.get("params", {})
cells = [
str(row),
job.get("label", f"job_{row}"),
status,
str(params.get("tomo_type", "?")),
_format_projections(params),
_fmt_num(params.get("tomo_countingtime")),
_fmt_num(params.get("tomo_shellstep")),
"",
job.get("added_at", ""),
]
for col, text in enumerate(cells):
item = QTableWidgetItem(text)
item.setTextAlignment(Qt.AlignmentFlag.AlignCenter)
@@ -1060,6 +1090,8 @@ class TomoQueueDialog(QDialog):
label = f"job_{len(jobs)}"
jobs.append(
{
"kind": "tomo",
"id": uuid.uuid4().hex,
"label": label.strip(),
"params": params,
"status": "pending",
@@ -1069,10 +1101,32 @@ class TomoQueueDialog(QDialog):
self._save_queue(jobs)
self._refresh()
def add_command_to_queue(self) -> None:
"""Open the structured command-job builder and, on confirm, append
the resulting job -- in exactly the format the CLI writes, so CLI
and GUI stay interchangeable (see TOMO_QUEUE_COMMAND_JOBS_PLAN.md
section 6.5).
"""
dlg = CommandJobBuilderDialog(self._client, parent=self)
if dlg.exec() == QDialog.DialogCode.Accepted and dlg.job is not None:
jobs = self._load_queue()
jobs.append(dlg.job)
self._save_queue(jobs)
self._refresh()
def _delete_selected(self) -> None:
rows = sorted({idx.row() for idx in self._table.selectedIndexes()}, reverse=True)
if not rows:
return
jobs = self._load_queue()
if any(row < len(jobs) and jobs[row].get("status") == "running" for row in rows):
QMessageBox.warning(
self,
"Cannot delete",
"A running job can't be deleted from here. Stop the current "
"measurement first (it becomes 'incomplete'), then delete it.",
)
return
reply = QMessageBox.question(
self,
"Delete jobs",
@@ -1081,7 +1135,6 @@ class TomoQueueDialog(QDialog):
)
if reply != QMessageBox.StandardButton.Yes:
return
jobs = self._load_queue()
for row in rows:
if row < len(jobs):
jobs.pop(row)
@@ -1089,6 +1142,15 @@ class TomoQueueDialog(QDialog):
self._refresh()
def _clear_queue(self) -> None:
jobs = self._load_queue()
if any(job.get("status") == "running" for job in jobs):
QMessageBox.warning(
self,
"Cannot clear",
"A job is currently running. Stop it first (it becomes "
"'incomplete'), then clear the queue.",
)
return
reply = QMessageBox.question(
self,
"Clear queue",
@@ -1124,6 +1186,249 @@ class TomoQueueDialog(QDialog):
super().showEvent(event)
# ── CommandJobBuilderDialog ──────────────────────────────────────────────────
class CommandJobBuilderDialog(QDialog):
"""Structured builder for a tomo-queue "command" job: an ordered list of
named actions, each with only the fields its own params schema declares
-- there is nothing free-text to type wrong (TOMO_QUEUE_COMMAND_JOBS_PLAN.md
section 0's rejected alternative was a code box precisely because
syntax-valid is a false safety signal; this builder is the safer,
friendlier alternative it settled on).
Built entirely from the live ``tomo_queue_actions`` global var (published
by ``Flomni.__init__``, see ``flomni.py``'s ``_describe_tomo_queue_actions()``)
-- no hardcoded mirror of the action registry or device allow-list to
drift out of sync with flomni.py, the same problem QUEUE_PARAM_NAMES
already has.
On accept, ``self.job`` holds a job dict in exactly the format the CLI's
``tomo_queue_add_command()`` writes (``kind="command"``, a fresh id, the
assembled ``steps`` list) so CLI and GUI stay interchangeable. The caller
is responsible for appending it to the queue -- this dialog only builds
the dict, it never touches the queue global var itself.
"""
def __init__(self, client, parent=None):
super().__init__(parent)
self._client = client
self.setWindowTitle("Add command job")
self.setMinimumSize(520, 480)
self.job: Optional[dict] = None
registry = self._client.get_global_var("tomo_queue_actions") or {}
self._actions: dict = registry.get("actions", {})
self._move_devices: dict = registry.get("move_devices", {})
self._steps: list[dict] = []
self._step_field_widgets: dict[str, tuple[QWidget, dict]] = {}
self._idem_user_overridden = False
self._build_ui()
if not self._actions:
QMessageBox.warning(
self,
"No actions available",
"The tomo_queue_actions registry is empty or unavailable -- "
"is a Flomni CLI session running? Command jobs can't be "
"built without it.",
)
def _build_ui(self):
vbox = QVBoxLayout(self)
vbox.addWidget(QLabel("Steps (run in order):"))
self._step_list = QListWidget()
vbox.addWidget(self._step_list)
step_btn_row = QHBoxLayout()
btn_remove = QPushButton("Remove step")
btn_up = QPushButton("Move up")
btn_down = QPushButton("Move down")
btn_remove.clicked.connect(self._on_remove_step)
btn_up.clicked.connect(lambda: self._on_move_step(-1))
btn_down.clicked.connect(lambda: self._on_move_step(1))
step_btn_row.addWidget(btn_remove)
step_btn_row.addWidget(btn_up)
step_btn_row.addWidget(btn_down)
step_btn_row.addStretch()
vbox.addLayout(step_btn_row)
vbox.addWidget(_hline())
add_box = QGroupBox("Add a step")
add_vbox = QVBoxLayout(add_box)
action_row = QHBoxLayout()
action_row.addWidget(QLabel("Action:"))
self._action_combo = QComboBox()
for name, spec in self._actions.items():
self._action_combo.addItem(name, name)
idx = self._action_combo.count() - 1
self._action_combo.setItemData(idx, spec.get("help", ""), Qt.ItemDataRole.ToolTipRole)
self._action_combo.currentIndexChanged.connect(self._on_action_changed)
action_row.addWidget(self._action_combo, stretch=1)
add_vbox.addLayout(action_row)
self._field_form = QFormLayout()
add_vbox.addLayout(self._field_form)
btn_add_step = QPushButton("Add step")
btn_add_step.clicked.connect(self._on_add_step)
add_vbox.addWidget(btn_add_step)
vbox.addWidget(add_box)
meta_form = QFormLayout()
self._label_edit = QLineEdit()
meta_form.addRow("Label:", self._label_edit)
self._idem_check = QCheckBox("Idempotent (safe to silently re-run after a crash)")
self._idem_check.stateChanged.connect(self._on_idem_toggled_by_user)
meta_form.addRow("", self._idem_check)
vbox.addLayout(meta_form)
buttons = QDialogButtonBox(
QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel
)
buttons.accepted.connect(self._on_accept)
buttons.rejected.connect(self.reject)
vbox.addWidget(buttons)
if self._actions:
self._on_action_changed(0)
# ── dynamic per-action fields ─────────────────────────────────────────
def _on_action_changed(self, _index: int) -> None:
while self._field_form.rowCount():
self._field_form.removeRow(0)
self._step_field_widgets = {}
action_name = self._action_combo.currentData()
spec = self._actions.get(action_name, {})
for param_name, field in spec.get("params", {}).items():
widget = self._build_field_widget(field)
self._step_field_widgets[param_name] = (widget, field)
label = field.get("label", param_name)
unit = field.get("unit")
if unit:
label = f"{label} ({unit})"
self._field_form.addRow(f"{label}:", widget)
def _build_field_widget(self, field: dict) -> QWidget:
field_type = field.get("type")
if field_type == "float":
w = QDoubleSpinBox()
w.setDecimals(4)
w.setRange(field.get("min", -1e9), field.get("max", 1e9))
w.setValue(field.get("default", 0.0))
return w
if field_type == "device_positions":
row = QWidget()
layout = QHBoxLayout(row)
layout.setContentsMargins(0, 0, 0, 0)
device_combo = QComboBox()
for name, meta in self._move_devices.items():
text = f"{name} - {meta.get('label', name)}"
if meta.get("unit"):
text += f" ({meta['unit']})"
device_combo.addItem(text, name)
target_spin = QDoubleSpinBox()
target_spin.setDecimals(4)
target_spin.setRange(-1e6, 1e6)
layout.addWidget(device_combo)
layout.addWidget(target_spin)
row._device_combo = device_combo # stashed for _read_field_value
row._target_spin = target_spin
return row
# Unknown param type -- degrade gracefully rather than crash. The
# registry lets a new *action* appear with zero widget changes (see
# the class docstring), but a genuinely new *field* type still needs
# one; this is that seam.
return QLabel(f"(unsupported param type: {field_type!r})")
def _read_field_value(self, widget: QWidget, field: dict) -> Any:
field_type = field.get("type")
if field_type == "float":
return widget.value()
if field_type == "device_positions":
device = widget._device_combo.currentData()
target = widget._target_spin.value()
return {device: target} if device else {}
return None
# ── step list management ──────────────────────────────────────────────
def _on_add_step(self) -> None:
action_name = self._action_combo.currentData()
if action_name is None:
return
kwargs = {}
for param_name, (widget, field) in self._step_field_widgets.items():
value = self._read_field_value(widget, field)
if field.get("type") == "device_positions" and not value:
QMessageBox.warning(self, "Missing device", "Select a device to move.")
return
kwargs[param_name] = value
self._steps.append({"action": action_name, "kwargs": kwargs})
summary = f"{action_name}{kwargs}" if kwargs else action_name
self._step_list.addItem(summary)
self._recompute_idempotent_default()
def _on_remove_step(self) -> None:
row = self._step_list.currentRow()
if row < 0:
return
self._step_list.takeItem(row)
del self._steps[row]
self._recompute_idempotent_default()
def _on_move_step(self, direction: int) -> None:
row = self._step_list.currentRow()
new_row = row + direction
if row < 0 or not (0 <= new_row < len(self._steps)):
return
self._steps[row], self._steps[new_row] = self._steps[new_row], self._steps[row]
item = self._step_list.takeItem(row)
self._step_list.insertItem(new_row, item)
self._step_list.setCurrentRow(new_row)
def _recompute_idempotent_default(self) -> None:
"""Idempotent checkbox defaults to "every step idempotent" (ANDed,
mirroring tomo_queue_add_command()'s own default), until the
operator explicitly overrides it -- then their choice sticks.
"""
if self._idem_user_overridden or not self._steps:
return
all_idem = all(
self._actions.get(step["action"], {}).get("idempotent", False) for step in self._steps
)
self._idem_check.blockSignals(True)
self._idem_check.setChecked(all_idem)
self._idem_check.blockSignals(False)
def _on_idem_toggled_by_user(self, _state: int) -> None:
self._idem_user_overridden = True
# ── accept ─────────────────────────────────────────────────────────────
def _on_accept(self) -> None:
if not self._steps:
QMessageBox.warning(self, "No steps", "Add at least one step before confirming.")
return
label = self._label_edit.text().strip() or f"cmd_{uuid.uuid4().hex[:6]}"
self.job = {
"kind": "command",
"id": uuid.uuid4().hex,
"label": label,
"steps": list(self._steps),
"idempotent": self._idem_check.isChecked(),
"status": "pending",
"added_at": datetime.datetime.now().isoformat(timespec="seconds"),
}
self.accept()
# ── module-level helpers ─────────────────────────────────────────────────────
@@ -1185,6 +1490,24 @@ def _format_projections(params: dict) -> str:
return "?"
def _job_kind(job: dict) -> str:
"""A job with no "kind" key predates command jobs and is a tomogram --
same back-compat default as flomni.py's tomo_queue_execute()/show()."""
return job.get("kind", "tomo")
def _format_command_summary(job: dict) -> str:
"""Mirrors flomni.py's tomo_queue_show() command-job rendering exactly,
so the CLI and this table read as the same list."""
steps = job.get("steps", [])
parts = [
f"{step['action']}{step['kwargs']}" if step.get("kwargs") else step["action"]
for step in steps
]
idem = "idem" if job.get("idempotent") else "NOT idem"
return " > ".join(parts) + f" [{idem}]"
def _fmt_num(val) -> str:
"""Compact numeric formatting for table cells; blank for None."""
if val is None: