Every user-facing action in FloraStack is a named command in florastack-engine’s registry, such as align.run or
stack.pyramid: buttons, menu items, shortcuts, the command-line tool’s subcommands, the command palette, the desktop
control channel and MCP tools all dispatch them. The registry is the only way the project changes, which is what makes
every action scriptable, undoable and testable headless. The engine commands page lists
them all, generated from the registry.
What a command is
A command has:
- a name,
area.verbObject(project.addFrames,develop.set): a lower-case area and a lower camel-case action, at most 64 characters. Names are an interface for scripts and MCP clients: a renamed command keeps its old name as an alias (commands::ALIASES), andcrates/engine/tests/commands.txtpins the list; - a kind: a query reads (
CommandSpec::query), an instant command runs on the engine’s thread (CommandSpec::instant), a job does long work on a worker (CommandSpec::job, on a lane); - a
Meta: a title for menus and the palette, a one-sentence summary, help text, examples, and flags: whether it isundoable(), what itrequires(nothing, an open project, a writable one) and what iteffects(nothing, the project, files outside it); - parameters, a struct with
#[serde(deny_unknown_fields, rename_all = "camelCase")]andJsonSchema, a doc comment on every field (it becomes the schema’s description) andParams::checkfor what types can’t say: ranges, lengths, paths without NUL; - a result, a struct with
SerializeandJsonSchema.
Commands live in crates/engine/src/commands/<area>.rs; crates/engine/src/registry.rs documents the details.
An example: renaming an output
output.rename, in crates/engine/src/commands/output.rs, is a whole command:
CommandSpec::instant(
Meta::new("output.rename", "Rename output", "Renames an output.")
.undoable()
.examples(&[r#"{ "output": 4, "name": "Pyramid, grit 0.5" }"#]),
rename,
),
#[derive(Debug, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
struct RenameParams {
/// The output's id, as `output.list` lists it.
output: OutputId,
/// Its new name: at most 200 characters, no control characters.
name: String,
}
impl Params for RenameParams {
fn check(&self) -> Result<(), Invalid> {
check_name("name", &self.name)
}
}
/// An output's name now.
#[derive(Debug, Serialize, JsonSchema)]
struct Renamed {
output: OutputId,
name: String,
}
fn rename(session: &mut Session, p: RenameParams) -> Result<Renamed, CommandError> {
if find(session, p.output)?.name != p.name {
session.apply(Edit::RenameOutput { output: p.output, name: p.name.clone() })?;
}
Ok(Renamed { output: p.output, name: p.name })
}
The project changes only through Session::apply with an Edit (florastack-project): a command’s edits are one undo
step, undone by their inverses. A new kind of change is a new Edit variant, with a test that its inverse undoes it.
Jobs
A job (CommandSpec::job(meta, lane, plan)) does long work without blocking the engine:
- Its plan runs on the engine’s thread, from a snapshot of what it reads, and locks the aspects of the project it
depends on (
JobPlan::locks), so that no other command changes them meanwhile. - Its work runs on a worker. It never touches the session; it calls
Monitor::stepafter each unit (a frame, a tile), which reports progress and stops withCancelledwhen the job is cancelled. - It returns a commit, which the engine runs on its thread to apply the result as edits.
Something irreversible outside the project, such as writing the user’s file, comes after JobContext::finalize, past
the last point where it can be cancelled. Previews that a newer request makes obsolete use Meta::supersede.
Tests
A new command comes with:
- a dispatch from JSON in
crates/engine/tests/dispatch.rs(a test fails when a registered command is never dispatched there); - for an undoable command, a case in
crates/engine/tests/undo.rs, which checks that undo and redo restore the project; - its hostile parameters in
crates/engine/tests/hostile.rs: out of range, too long, missing files, ids that don’t exist, each an error, never a panic; - its name in
crates/engine/tests/commands.txt.
crates/engine/tests/registry.rs checks every command’s texts, schemas (draft 2020-12, inlined, every parameter
described, unknown parameters refused) and that its examples fit them. And the book’s reference page must be written
again: cargo xtask book --update-reference (see Writing the documentation).
Using a command from the apps
The desktop app runs commands through its Shell (app.shell.run("output.rename", json!({ ... }))) and shows their
progress from the engine’s events; the command-line tool’s subcommands and run dispatch them the same way. When a
screen needs a computation, it becomes a command or a query in the engine, with tests, and the screen calls it: no
logic lives only in a front end.