Command Architecture
Overview
The Command Architecture provides a unified system for representing all DataManager-level operations as serializable, composable, and optionally undoable command objects. It enables multi-step workflows (such as triage pipelines) to be defined as JSON documents and executed atomically.
For the phased implementation plan, see the Command Architecture Roadmap.
Source Location
- Core command library:
src/Commands/(standalone static library)src/Commands/Core/— framework:ICommand,CommandFactory,CommandRecorder,SequenceExecution,Validation,VariableSubstitutionsrc/Commands/IO/— persistence commands:SaveData,LoadDatasrc/Commands/(root) — data mutation commands:MoveByTimeRange,CopyByTimeRange,AddInterval,ForEachKey
- Triage session (non-Qt):
src/TriageSession/ - Triage widget (Qt):
src/WhiskerToolbox/TriageSession_Widget/ - Command sequence widget (Qt):
src/WhiskerToolbox/Commands/
Problem Statement
Neuralyzer has several independent “verb systems” that each handle a category of operations:
| System | What it does | JSON-serializable? | Composable? |
|---|---|---|---|
LoaderRegistry + IFormatLoader |
Create data objects from files | Yes (nlohmann::json config) |
No |
TransformsV2 + PipelineDescriptor |
Transform data element-by-element | Yes (rfl::json) |
Yes (pipelines) |
CSVSingleFileLineSaverOptions, etc. |
Save data objects to disk | Structurally yes (reflect-cpp structs) | No unified dispatch |
moveByEntityIds / copyByEntityIds |
Move/copy entries between data objects | No — imperative C++ only | No |
DigitalIntervalSeries creation |
Track inspected regions | No — widget-driven | No |
EditorState::fromJson() / toJson() |
Widget state changes | Yes (reflect-cpp) | No |
These systems cannot be combined into multi-step workflows. A user who wants to “move 5 line objects from predicted to ground truth, save all 10 CSVs, and record the inspected frame range” must perform each step manually through different widgets. There is no way to define this workflow as a reusable, serializable unit.
Motivating Scenarios
Scenario 1 — Line triage: A deep learning algorithm produces 5 LineData objects across 200,000 video frames. The user visually inspects the video, identifies stretches of correct output, and wants to batch-move those lines to “ground truth” objects, save the CSVs, and record which frames have been triaged — all in one atomic operation they can repeat hundreds of times.
Scenario 2 — Contact labeling: The user jumps to scattered regions of a video (frames 75000–76000, 150000–151000) to label “contact” events. Frames within those manually-inspected regions that lack a contact label are implicitly “not contact.” The program must track which regions have been inspected so that unlabeled frames outside those regions are correctly treated as “unknown” rather than “negative.”
Scenario 3 — Multi-object persistence: As data moves between “predicted” and “ground truth” states, the source and destination CSVs must both be updated. Forgetting to save, or saving only one side, creates data loss risk. Serialization should be part of the same operation that moves data, not a separate manual step.
Architecture Overview
The solution is a Command Architecture — any operation on DataManager is represented as an ICommand subclass with typed parameters. Commands can be composed into sequences, resolved with runtime variables, and dispatched through a single virtual execute() call. A simple factory function maps JSON descriptors to concrete command objects.
Completeness criterion: A command exists for every user-facing action in the GUI that modifies DataManager state. The underlying systems may be arbitrarily complex (a transform pipeline chains dozens of element transforms; a data load involves format detection, parsing, and TimeFrame setup), but the command is whatever the user sees as a single action: “run this pipeline,” “load this file,” “move these frames.” Command coverage is audited by walking every widget and enumerating its buttons, menu items, keyboard shortcuts, and drag-and-drop actions.
┌─────────────────────────────────────────────────────────────┐
│ ICommand (virtual base) │
│ (JSON-serializable via reflect-cpp, optional undo) │
│ │
│ ┌──────────────┐ ┌──────────────┐ ┌───────────────────┐ │
│ │ MoveByTime │ │ SaveData │ │ RunPipeline │ │
│ │ CopyByTime │ │ LoadData │ │ (wraps │ │
│ │ │ │ │ │ TransformsV2) │ │
│ └──────────────┘ └──────────────┘ └───────────────────┘ │
│ ┌──────────────┐ ┌──────────────┐ ┌───────────────────┐ │
│ │ AddInterval │ │ DeleteData │ │ CreateData │ │
│ │ ClearAtTime │ │ │ │ (wraps Loaders) │ │
│ └──────────────┘ └──────────────┘ └───────────────────┘ │
└──────────────────────────┬──────────────────────────────────┘
│ used by
┌─────────────────┼─────────────────┐
▼ ▼ ▼
TriageSession ActionJournal Future consumers
(mark/commit) (record/replay) (CLI batch, macros,
Python bridge)
Relationship to Existing Systems
- TransformsV2 operates at the element level — transform individual data points within a single data object. The Command Architecture operates at the DataManager level — move entire objects, save files, create intervals, manage state.
RunTransformPipelineis a command that bridges the two: it takes an input key, output key, and pipeline descriptor, and delegates to TransformsV2 for execution.- EditorState is not replaced and is not part of the command architecture. EditorState manages view/UI concerns (display options, zoom, active selections) which already have their own persistence via
toJson()/fromJson()and workspace save.
Scope Boundary: Data State vs. View State
User actions fall into two categories with different infrastructure:
| DataManager operations | EditorState operations | |
|---|---|---|
| Examples | Move data, save CSV, run pipeline, add interval, delete object | Change line color, toggle grid, set zoom, pick active data key |
| Scope | Shared state — affects all consumers of that data | Local state — affects one widget instance |
| Frequency | Occasional (button clicks, triage commits) | Constant (every slider drag, color pick, checkbox) |
| Mechanism | ICommand with execute() / undo() |
EditorState signals + markDirty() |
| Persistence | Explicit (SaveData command, workspace save) |
Automatic (EditorState workspace serialization) |
| Undo | Via CommandStack (per-command inverse or memento) |
Not needed — user just changes it back |
| Composable | Yes (command sequences, triage pipelines) | No |
| Journal | Yes (every command recorded) | No |
ICommand is for data mutations only. This follows the same boundary Blender draws between its Operator system (scene data → global undo) and viewport/UI state (no undo). The rationale:
- Data mutations can be destructive (moving 10,000 entities between objects). Undo matters.
- View state changes are trivially reversible by the user (drag the slider back, re-pick the color). Undo is noise.
- Data mutations are composable (“move 5 objects, save, add interval” is a meaningful workflow). View state changes are not.
- Data mutations are worth journaling for reproducibility. View state changes are not.
If a specific widget state change is destructive enough to warrant undo (e.g., “reset all display options to defaults”), it can use the opt-in WidgetStateChangeCommand adapter. But routine interactions — the vast majority of EditorState changes — stay outside the command system entirely.
Future option: If fine-grained view-state undo becomes valuable, the natural fit is a per-EditorState memento stack — a lightweight, widget-local undo that doesn’t interact with the global CommandStack. This would be an EditorState feature, not a command architecture feature.
Where This Lives
| Component | Location | Qt dependency? |
|---|---|---|
ICommand, CommandResult, CommandContext |
src/Commands/Core/ |
No |
CommandDescriptor, CommandSequenceDescriptor |
src/Commands/Core/ |
No |
Concrete commands (MoveByTimeRange, AddInterval, etc.) |
src/Commands/ |
No |
IO commands (SaveData, LoadData) |
src/Commands/IO/ |
No |
CommandFactory, CommandInfo + getAvailableCommands() |
src/Commands/Core/ |
No |
ParameterSchema (shared library) |
src/ParameterSchema/ |
No |
AutoParamWidget (shared UI library) |
src/WhiskerToolbox/AutoParamWidget/ |
Yes |
TriageSession |
src/TriageSession/ |
No |
TriageSessionWidget |
src/WhiskerToolbox/TriageSession_Widget/ |
Yes |
CommandSequenceWidget |
src/WhiskerToolbox/Commands/ (planned) |
Yes |
The command library lives in src/Commands/ as a standalone static library above DataManager. This keeps it free from Qt, available to unit tests, Python bindings, and future CLI tools, and — crucially — allows commands to wrap libraries that sit above DataManager (such as TransformsV2 and DeepLearning) without creating circular dependencies.
Core Types
The four foundational types are defined in src/Commands/Core/:
CommandResult—{success, error_message, affected_keys}with staticok()anderror()factory methods.CommandContext— Not serialized. Holds the liveshared_ptr<DataManager>, aruntime_variablesmap for${variable}substitution, and an optional progress callback.CommandDescriptor— Serializable single-command invocation:command_name, optionalrfl::Genericparameters, and an optional human-readabledescription.CommandSequenceDescriptor— Serializable command list with optionalname,version, and staticvariablesmap for template substitution.
Full signatures and serialization examples: Core Types & Factory.
ICommand Interface & Factory
Commands use a virtual base class. Each command holds its own parameters, knows how to execute and (optionally) undo itself, and can serialize to JSON via reflect-cpp. No registry or static registration is needed.
ICommand Base Class
ICommand provides pure virtual execute(), commandName(), and toJson() methods, plus opt-in isUndoable() / undo() / mergeId() / mergeWith() (all defaulting to “not undoable” / “not mergeable”). There is one interface for both execution and undo — no separate IUndoableCommand hierarchy.
Command Factory
createCommand(name, params) is the only place that needs to know about all command types. It maps a command_name string + rfl::Generic parameters to a concrete ICommand instance via a simple if-chain in CommandFactory.cpp. Returns nullptr for unknown names or deserialization failures. For a set of ~10–15 commands that grows slowly, a centralized factory is a feature, not a limitation — you can see every command the system supports in one place.
See Core Types & Factory for the interface definition and current implementation.
Why Not a Static Registry?
The TransformsV2 ElementRegistry uses static registration because the pipeline builder must introspect transform metadata at assembly time: input/output types, chaining compatibility, reduction vs. element transform, parameter schemas. The registry answers questions the orchestrator needs before it can wire the pipeline together.
Commands have none of this complexity. A command takes a DataManager, does something, returns CommandResult. The sequence executor doesn’t need to know what a command does internally — it just calls execute() in order and stops on error.
This means:
- No
--whole-archivelinker flags. Static registration requires RAII objects in anonymous namespaces that the linker may strip. A factory in a.cppfile has no such issue. - No type-erased storage. The virtual interface provides dispatch directly.
- Undo is unified. The same
ICommandobject implements bothexecute()andundo(), holding any state needed between the two calls. - Trivial to add commands. Write a params struct, write a class, add one
ifbranch to the factory.
Sequence Execution
executeSequence() iterates a CommandSequenceDescriptor, performs two-pass ${variable} substitution on each descriptor, creates commands via the factory, calls execute() in order, and collects executed commands into a SequenceResult for undo support. Stop-on-first-error behavior is configurable. Failed sequences report partial state and failed_index.
Details, SequenceResult definition, and error policy: Variable Substitution & Sequence Execution.
Variable Substitution
Two-pass ${variable} substitution reusing the pattern from TransformsV2 pipelines:
- Static pass: Template variables from
CommandSequenceDescriptor::variablesare substituted into the JSON before deserialization. These are known at authoring time. - Runtime pass:
CommandContext::runtime_variables(e.g.,mark_frame,current_frame) are substituted after the static pass.
Both passes use the same string replacement logic. For parameters that are numeric (like frame indices), the substituted string is parsed to the expected type during reflect-cpp deserialization.
Validation
validateSequence() checks a command sequence before any commands execute, returning a ValidationResult with valid, warnings, and errors fields. Checks run in order:
- Structural: All
command_namevalues map to known commands in the factory. - Deserialization: Parameters deserialize into the expected params struct without error.
- Variable resolution: All
${variable}references resolve. - Data key existence (optional): Referenced keys exist in
DataManager. - Type compatibility (optional): Source and destination have the same
DataTypeVariant.
Checks 1–3 run without a DataManager; checks 4–5 require a live one. Full API and ForEachKey special handling: Validation.
Command Introspection
The command architecture provides introspection — any consumer (UI, CLI, Python bridge) can query what commands are available and what parameters each one requires. This enables guided pipeline editors, autocompletion, and schema-aware validation.
The Problem
Without introspection, the only way to build a command sequence is to hand-write JSON with knowledge of every command name and parameter field. The centralized factory can execute commands, but cannot answer:
- “What commands exist?” (no listing function)
- “What parameters does
MoveByTimeRangeneed?” (no schema) - “Is this command undoable? What types does it support?” (no metadata)
CommandInfo
CommandInfo provides static metadata for each command:
struct CommandInfo {
std::string name;
std::string description;
std::string category; // "data_mutation", "meta", "persistence", etc.
bool supports_undo = false;
std::vector<std::string> supported_data_types;
ParameterSchema parameter_schema; // From shared ParameterSchema library
};Factory Query Functions
Two functions are added to CommandFactory.hpp:
/// Return metadata for all registered commands
std::vector<CommandInfo> getAvailableCommands();
/// Return metadata for a single command, or std::nullopt if unknown
std::optional<CommandInfo> getCommandInfo(std::string const & name);These are implemented alongside the existing createCommand() if-chain in CommandFactory.cpp. Adding a new command means adding one if-branch to createCommand() and one entry to getAvailableCommands() — both in the same file.
Triage Widget: Guided Pipeline Editor
With introspection, the triage widget’s pipeline section evolves from a raw JSON text box to a guided editor. The raw JSON editor remains available for power users.
Command picker: An “Add Command…” button opens a picker populated by getAvailableCommands(), showing command name + description, grouped by category.
Parameter form: Selecting a command generates an AutoParamWidget from its ParameterSchema. The user fills in fields with typed widgets (spin boxes for frame numbers, line edits for data keys) with tooltips and constraints, instead of hand-typing JSON.
Editable command list: Each command in the sequence is shown as an expandable row with an AutoParamWidget pre-populated from the current parameter values. Edits update the underlying CommandSequenceDescriptor and the raw JSON view.
┌─────────────────────────────────────────┐
│ Pipeline: "Triage 5 Whiskers" │
│ [Load JSON...] [Add Command...] │
│ │
│ ┌─ 1. MoveByTimeRange ─────────── [x] ┐│
│ │ Source Key: [pred_w0 ] ││
│ │ Destination Key: [gt_w0 ] ││
│ │ Start Frame: [${mark_frame} ] ││
│ │ End Frame: [${current_frame}] ││
│ └──────────────────────────────────────┘│
│ ┌─ 2. AddInterval ─────────────── [x] ┐│
│ │ Interval Key: [tracked_regions] ││
│ │ Start Frame: [${mark_frame} ] ││
│ │ End Frame: [${current_frame}] ││
│ │ Create if Missing: [✓] ││
│ └──────────────────────────────────────┘│
│ │
│ [▼ Show raw JSON] │
└─────────────────────────────────────────┘
Design Rationale
This approach reuses the centralized factory without introducing a static registry:
- No
--whole-archivelinker flags. The factory file is the single source of truth for both command creation and metadata. No RAII registration objects. - Reuse proven infrastructure.
ParameterSchemaandAutoParamWidgetare battle-tested in TransformsV2. Extracting them is a clean refactor with no new template machinery. - Incremental. Commands work without introspection. The factory query functions and
CommandInfoare additive — existing code is unaffected. - UI-agnostic.
CommandInfoandParameterSchemaare non-Qt. OnlyAutoParamWidgetrequires Qt, and it’s a separate library. CLI tools and the Python bridge can usegetAvailableCommands()directly.
Commands
The command library currently has four implemented commands (Phase 1) and three planned for future phases:
| Command | Phase | Status | Description |
|---|---|---|---|
MoveByTimeRange |
1 | Done | Move entities in [start, end] between same-type data objects; undoable |
CopyByTimeRange |
1 | Done | Copy entities without modifying source; wider type support |
AddInterval |
1 | Done | Append interval to DigitalIntervalSeries, create if missing |
ForEachKey |
1 | Done | Meta-command: iterate a list, bind each to ${variable}, run sub-commands |
SaveData |
3 | Planned | Uniform save via SaverRegistry; atomic write-ahead pattern |
LoadData |
3 | Planned | Wraps LoaderRegistry for uniform dispatch |
RunTransformPipeline |
future | Planned | Bridges to TransformsV2 for element-level transforms |
MoveByTimeRange
Moves all entries in [start_frame, end_frame] from source to destination via moveByEntityIds(). Captures moved entity IDs during execute() for symmetric undo() — the command object is the undo state. Supported types: LineData, PointData, MaskData, DigitalEventSeries.
getEntityIdsInRange Free Functions
Pure query helpers that collect EntityIds in a time range. Template for RaggedTimeSeries<T> plus overloads for DigitalEventSeries and DigitalIntervalSeries. See getEntityIdsInRange Helpers.
CopyByTimeRange
Same shape as MoveByTimeRange but uses copyByEntityIds(). Source is unmodified. Wider type support: DigitalIntervalSeries supports copy but not move.
SaveData (Phase 3 — planned)
Will wrap existing savers behind a SaverRegistry with an atomic write-ahead pattern. Parameters: data_key, format, path, optional format_options.
AddInterval
Appends an interval to a DigitalIntervalSeries. When create_if_missing = true and the key does not exist, creates an empty series with the default TimeKey("time").
LoadData (Phase 3 — planned)
Will wrap LoaderRegistry for uniform dispatch. Parameters: data_key, data_type, filepath, format, optional format_options.
RunTransformPipeline (future)
Will bridge the command system to TransformsV2. Parameters: input_key, output_key, and either an inline PipelineDescriptor or a pipeline_file path.
ForEachKey (Meta-Command)
Iterates a list of string values, binds each to ${variable}, and executes sub-commands per item. Undo reverses all undoable sub-commands in reverse order. See Concrete Commands for the full parameter struct and JSON example.
Triage Session
The triage session is a consumer of the command architecture, not a bespoke system. It manages a simple state machine and, on commit, executes a user-configured CommandSequenceDescriptor.
State Machine
IDLE ──[Mark]──► MARKING ──[Commit]──► IDLE
│
└──[Recall]──► IDLE (jump back to mark frame)
Core Class
TriageSession provides setPipeline(), mark(), recall(), and commit() along with state/mark-frame accessors and lastCommitCommands() / takeLastCommitCommands() for undo support. Full API: TriageSession Core Library.
Commit Implementation
On commit(), the session populates CommandContext::runtime_variables with mark_frame and current_frame, then delegates to executeSequence(). The triage session has no knowledge of what commands are in its pipeline.
Tracked Regions
The triage session doesn’t manage tracked regions directly. Instead, the user includes an AddInterval command in their triage pipeline:
{
"command_name": "AddInterval",
"parameters": {
"interval_key": "tracked_regions",
"start_frame": "${mark_frame}",
"end_frame": "${current_frame}"
}
}The resulting DigitalIntervalSeries in DataManager is a first-class data object. It can be:
- Visualized on the timeline as a shaded overlay.
- Queried by MLCore to know which frames have been manually triaged.
- Used to compute complement regions (frames that haven’t been inspected yet).
- Saved/loaded like any other data object.
Example Triage Pipeline
A complete pipeline for triaging 5 whisker line objects:
{
"name": "Triage 5 Whiskers",
"variables": {
"gt_prefix": "gt_",
"pred_prefix": "predicted_",
"output_dir": "/data/experiment_01/ground_truth"
},
"commands": [
{
"command_name": "MoveByTimeRange",
"parameters": {
"source_key": "${pred_prefix}whisker_0",
"destination_key": "${gt_prefix}whisker_0",
"start_frame": "${mark_frame}",
"end_frame": "${current_frame}"
}
},
{
"command_name": "MoveByTimeRange",
"parameters": {
"source_key": "${pred_prefix}whisker_1",
"destination_key": "${gt_prefix}whisker_1",
"start_frame": "${mark_frame}",
"end_frame": "${current_frame}"
}
},
{
"command_name": "MoveByTimeRange",
"parameters": {
"source_key": "${pred_prefix}whisker_2",
"destination_key": "${gt_prefix}whisker_2",
"start_frame": "${mark_frame}",
"end_frame": "${current_frame}"
}
},
{
"command_name": "MoveByTimeRange",
"parameters": {
"source_key": "${pred_prefix}whisker_3",
"destination_key": "${gt_prefix}whisker_3",
"start_frame": "${mark_frame}",
"end_frame": "${current_frame}"
}
},
{
"command_name": "MoveByTimeRange",
"parameters": {
"source_key": "${pred_prefix}whisker_4",
"destination_key": "${gt_prefix}whisker_4",
"start_frame": "${mark_frame}",
"end_frame": "${current_frame}"
}
},
{
"command_name": "SaveData",
"parameters": {
"data_key": "${gt_prefix}whisker_0",
"format": "csv_single",
"path": "${output_dir}/${gt_prefix}whisker_0.csv"
}
},
{
"command_name": "SaveData",
"parameters": {
"data_key": "${gt_prefix}whisker_1",
"format": "csv_single",
"path": "${output_dir}/${gt_prefix}whisker_1.csv"
}
},
{
"command_name": "SaveData",
"parameters": {
"data_key": "${gt_prefix}whisker_2",
"format": "csv_single",
"path": "${output_dir}/${gt_prefix}whisker_2.csv"
}
},
{
"command_name": "SaveData",
"parameters": {
"data_key": "${gt_prefix}whisker_3",
"format": "csv_single",
"path": "${output_dir}/${gt_prefix}whisker_3.csv"
}
},
{
"command_name": "SaveData",
"parameters": {
"data_key": "${gt_prefix}whisker_4",
"format": "csv_single",
"path": "${output_dir}/${gt_prefix}whisker_4.csv"
}
},
{
"command_name": "AddInterval",
"parameters": {
"interval_key": "tracked_regions",
"start_frame": "${mark_frame}",
"end_frame": "${current_frame}"
}
},
{
"command_name": "SaveData",
"parameters": {
"data_key": "tracked_regions",
"format": "csv_single",
"path": "${output_dir}/tracked_regions.csv"
}
}
]
}UI Sketch
┌─────────────────────────────────────────┐
│ Triage Session │
├─────────────────────────────────────────┤
│ Status: IDLE / MARKING (from 42000) │
│ │
│ [Mark] [Commit] [Recall] │
│ │
│ Pipeline: "Triage 5 Whiskers" │
│ [Load...] [Edit JSON...] │
│ │
│ Commands (12): │
│ 1. MoveByTimeRange: pred_w0 → gt_w0 │
│ 2. MoveByTimeRange: pred_w1 → gt_w1 │
│ ... │
│ 11. AddInterval: tracked_regions │
│ 12. SaveData: tracked_regions │
│ │
│ Tracked: 3 intervals, 5,200 frames │
│ Remaining: 194,800 frames │
└─────────────────────────────────────────┘
Keyboard shortcuts (essential for the scrubbing workflow):
- M — Mark (start a triage range)
- C — Commit (execute pipeline for
[mark_frame, current_frame]) - R — Recall (abort, jump back to mark frame)
TriageSessionState (EditorState subclass)
Follows the standard EditorState pattern. TriageSessionStateData serializes the pipeline JSON and tracked-regions key; the state machine always resets to Idle on workspace load. Full EditorState class, registration, and Qt UI details: TriageSession Widget.
Tracked Regions for Machine Learning
The tracked-regions DigitalIntervalSeries created by AddInterval commands during triage has direct utility for machine learning workflows.
Implicit Negative Labeling
Tracked: [75000, 76000] ∪ [150000, 151000]
Frame 75500 + has "contact" label → ground truth POSITIVE
Frame 75700 + no "contact" label → ground truth NEGATIVE (within tracked region)
Frame 80000 + no "contact" label → UNKNOWN (outside tracked region)
Prediction Scope
When running predictions with MLCore, restrict to untracked regions:
auto tracked = dm->getData<DigitalIntervalSeries>("tracked_regions");
auto complement = tracked->complement(TimeFrameIndex(0), TimeFrameIndex(total_frames));
// Predict only within complement intervalsIncremental Training
As the user triages more data:
- Tracked regions grow.
- More ground truth positives and negatives become available.
- The model can be retrained on the expanded ground truth.
- Predictions are made on the shrinking untracked region.
- The user focuses triage on frames where the model is uncertain.
This creates a human-in-the-loop active learning pipeline, all driven by the same triage session mechanism.
Persistence Safety
Write-Ahead Pattern
For expensive data (LineData CSVs with millions of entries), the SaveData command should use atomic writes:
- Write to
path.tmp(same filesystem). fsync()the temporary file.rename()atomically over the target path (atomic on POSIX).
This prevents corruption if the application crashes mid-save.
When to Save
Saving is just another command in the sequence. The user explicitly decides what gets saved by including SaveData commands in their pipeline. Options:
- Save ground truth only — Most common. Source (predicted) CSVs are not modified.
- Save both sides — Include
SaveDatacommands for source and destination. - Save nothing — Omit
SaveDatacommands. Changes exist only in-memory.
Relationship to Workspace Save
SaveData commands save individual data objects to standalone files. This is distinct from workspace save (WorkspaceManager::save()), which captures the complete session state.
Both can happen independently:
SaveDataduring triage commits: saves ground truth data incrementally.- Workspace save: captures the full session state for later resumption.
- Workspace auto-save (crash recovery): happens on a timer, independent of triage.
Integration with Action Journal
Every command execution is automatically recorded to the Action Journal. The executeSequence() function accepts an optional journal pointer:
SequenceResult executeSequence(
CommandSequenceDescriptor const& seq,
CommandContext const& ctx,
ActionJournal* journal = nullptr,
bool stop_on_error = true)
{
// ... for each command ...
auto cmd = createCommand(desc.command_name, resolved_json);
auto result = cmd->execute(ctx);
if (result.success && journal) {
journal->record(ActionEntry{
.timestamp = now_iso8601(),
.action_type = "command",
.description = desc.description.value_or(cmd->commandName()),
.payload_json = cmd->toJson(),
.source_widget = "executeSequence"
});
}
// ...
}This means:
- Triage commit operations are automatically logged.
- The journal is a complete record of all commands executed.
- Journal entries can be replayed by feeding them back through
createCommand()+execute(). - The journal can be exported as a script for batch processing.
Integration with Undo/Redo
Undo is built directly into the ICommand interface. There is no separate IUndoableCommand hierarchy — every command has isUndoable() and undo() methods, defaulting to “not undoable.” Commands opt in by overriding these two methods.
CommandStack
The CommandStack manages the undo/redo history:
class CommandStack {
public:
void push(std::unique_ptr<ICommand> cmd);
CommandResult undo(CommandContext const& ctx);
CommandResult redo(CommandContext const& ctx);
bool canUndo() const;
bool canRedo() const;
void markClean();
bool isClean() const;
private:
std::vector<std::unique_ptr<ICommand>> _undo_stack;
std::vector<std::unique_ptr<ICommand>> _redo_stack;
int _clean_index = 0;
};Undo Strategies
| Strategy | When to Use | Example |
|---|---|---|
| Inverse command | Data mutations with clear inverses | MoveByTimeRange: execute() records moved IDs, undo() moves them back |
| Non-undoable | Destructive or external operations | File save (can’t un-save a file) |
| Memento adapter (opt-in) | Destructive widget state changes only | “Reset all display options to defaults” |
WidgetStateChangeCommand
An opt-in adapter for the rare cases where a widget action is destructive enough to warrant undo:
class WidgetStateChangeCommand : public ICommand {
std::shared_ptr<EditorState> _state;
std::string _before_json; // Captured before execution
std::string _after_json; // Captured after execution
public:
std::string commandName() const override { return "WidgetStateChange"; }
std::string toJson() const override { return _after_json; }
bool isUndoable() const override { return true; }
CommandResult execute(CommandContext const&) override {
_state->fromJson(_after_json);
return CommandResult::ok();
}
CommandResult undo(CommandContext const&) override {
_state->fromJson(_before_json);
return CommandResult::ok();
}
int mergeId() const override {
return static_cast<int>(std::hash<EditorState*>{}(_state.get()) & 0x7FFFFFFF);
}
bool mergeWith(ICommand const& other) override {
auto const* o = dynamic_cast<WidgetStateChangeCommand const*>(&other);
if (!o || o->_state != _state) return false;
_after_json = o->_after_json;
return true;
}
};CommandSequenceCommand (Triage Undo)
A triage commit executes multiple commands. For undo purposes, the entire sequence is wrapped as a single undoable group:
class CommandSequenceCommand : public ICommand {
std::vector<std::unique_ptr<ICommand>> _commands;
std::string _name;
public:
std::string commandName() const override { return _name; }
bool isUndoable() const override {
return std::any_of(_commands.begin(), _commands.end(),
[](auto const& c) { return c->isUndoable(); });
}
CommandResult execute(CommandContext const& ctx) override {
for (auto& cmd : _commands) {
auto r = cmd->execute(ctx);
if (!r.success) return r;
}
return CommandResult::ok();
}
CommandResult undo(CommandContext const& ctx) override {
for (auto it = _commands.rbegin(); it != _commands.rend(); ++it) {
if (!(*it)->isUndoable()) continue;
auto r = (*it)->undo(ctx);
if (!r.success) return r;
}
return CommandResult::ok();
}
};Non-undoable children (SaveData) are skipped during undo.
JSON Console Integration
The command architecture naturally integrates with the planned JSON Console:
{
"type": "command_sequence",
"version": "1.0",
"payload": {
"name": "My Triage Pipeline",
"commands": [...]
}
}Unified JSON Dispatch
| Type | Executor |
|---|---|
"transform_pipeline" |
TransformsV2 pipeline loader |
"table_definition" |
TableView system |
"command_sequence" |
executeSequence() |
"data_load" |
LoaderRegistry (or wrapped as LoadData command) |
Eventually, "transform_pipeline" and "data_load" could be subsumed by their command wrappers, making the command system the single execution path for all operations.
Design Principles
Commands are DataManager-level operations. Transforms are element-level (TransformsV2). Commands are whole-object operations: move, copy, save, load, create intervals. They compose, not compete. Widget/view state changes (
EditorState) are explicitly not commands.JSON-serializable everything. Every command descriptor, every parameter struct, every sequence is a plain C++ struct that reflect-cpp can round-trip. No manual JSON construction.
Virtual base class, not a static registry. A plain factory function maps command names to concrete types. No RAII registration, no
--whole-archivelinker flags.Two-tier variable substitution. Static template variables (known at authoring time) and runtime variables (resolved at execution time) use the same
${variable}syntax.Triage is just a consumer. The triage session is not special — it’s a state machine that builds a
CommandContextand callsexecuteSequence(). Any other consumer (CLI, Python, macro recorder) uses the same API.Persistence is a command, not a side effect. Saving data is an explicit
SaveDatacommand in the sequence. The user decides what gets saved and when. Atomic writes prevent corruption.Undo is opt-in and layered. Commands work without undo. Undo support is added per-command when valuable, using inverse operations.
Incremental adoption. Not every operation needs to become a command on day one. Start with the operations needed for triage, then expand as other workflows demand it.
Commands mirror GUI verbs, not internal APIs. A command’s name and parameters should match what the user sees (“MoveByTimeRange” with
source_key/destination_key), not the internal function it calls. Parameter names should be self-explanatory without knowledge of the source code.
See Also
- Command Architecture Roadmap — phased implementation plan
- Data Manager — core data management documentation
- State Management Roadmap — related state management plans (Phases 4–6 superseded by this architecture)