From b1222e4c1a14eaf023ed4a89bfad4ccfeaa39384 Mon Sep 17 00:00:00 2001 From: kartsan03 Date: Sat, 17 Oct 2026 10:38:08 +0000 Subject: [PATCH] fix(workflows): keep integer number inputs exact instead of rounding them `type: number` inputs went through float(), so integers above 2**53 (64-bit ids, nanosecond timestamps) were rounded before any step saw them, and an enum default holding such an integer failed validation against its own rounded value. Try int() first for strings and ints, and keep the float path for decimal and exponent forms. Assisted-by: Codex CLI (model: gpt-6.1-sol, autonomous) Assisted-by: Claude Code (model: claude-opus-5-5, autonomous) --- docs/reference/workflows.md | 6 ++ src/specify_cli/workflows/engine.py | 10 +++- .../specify_cli/workflows/test_command_run.py | 59 +++++++++++++++++++ tests/test_workflows.py | 46 +++++++++++++++ 4 files changed, 120 insertions(+), 1 deletion(-) diff --git a/docs/reference/workflows.md b/docs/reference/workflows.md index 8b95c6918e..a24b700a2d 100644 --- a/docs/reference/workflows.md +++ b/docs/reference/workflows.md @@ -21,6 +21,12 @@ Example: specify workflow run speckit -i spec="Build a kanban board with drag-and-drop task management" ``` +Inputs declared as `type: number` preserve integer values and integer strings +exactly, including 64-bit identifiers and nanosecond timestamps above `2**53`. +Decimal and exponent strings continue to use floating-point conversion; +whole-valued results are stored as integers. Booleans, non-numeric values, and +non-finite numbers are rejected. + With `--json`, a single machine-readable object is printed instead of formatted text (the default output is unchanged when the flag is omitted): ```bash diff --git a/src/specify_cli/workflows/engine.py b/src/specify_cli/workflows/engine.py index 662d0d87b2..c29a68dbaf 100644 --- a/src/specify_cli/workflows/engine.py +++ b/src/specify_cli/workflows/engine.py @@ -1443,7 +1443,15 @@ def _coerce_input( msg = f"Input {name!r} expected a number, got {value!r}." raise ValueError(msg) try: - value = float(value) + # Preserve integers (including CLI strings) before float can + # round identifiers or timestamps above 2**53. + if isinstance(value, (str, int)): + try: + value = int(value) + except ValueError: + value = float(value) + else: + value = float(value) if value == int(value): value = int(value) except (ValueError, TypeError, OverflowError): diff --git a/tests/specify_cli/workflows/test_command_run.py b/tests/specify_cli/workflows/test_command_run.py index aac8ac6230..316af276b5 100644 --- a/tests/specify_cli/workflows/test_command_run.py +++ b/tests/specify_cli/workflows/test_command_run.py @@ -13,6 +13,65 @@ from tests.specify_cli.workflows.helpers import force_gate_stdin as _force_gate_stdin +class TestWorkflowRunNumberInputs: + @pytest.mark.parametrize("source", ["cli", "default", "enum-default"]) + def test_run_preserves_integer_input_in_file_and_state( + self, tmp_path, monkeypatch, source + ): + from typer.testing import CliRunner + + from specify_cli import app + + number = 1792234567890123456 + input_definition = {"type": "number"} + arguments = [] + if source == "cli": + input_definition["required"] = True + arguments = ["--input", f"record_id={number}"] + else: + input_definition["default"] = number + if source == "enum-default": + input_definition["enum"] = [number] + workflow = tmp_path / "workflow.yml" + workflow.write_text( + yaml.safe_dump( + { + "schema_version": "1.0", + "workflow": { + "id": "number-input", + "name": "Number input", + "version": "1.0.0", + }, + "inputs": {"record_id": input_definition}, + "steps": [ + { + "id": "write", + "type": "shell", + "run": "echo {{ inputs.record_id }} > record-id.txt", + } + ], + } + ), + encoding="utf-8", + ) + monkeypatch.chdir(tmp_path) + + result = CliRunner().invoke( + app, ["workflow", "run", str(workflow), "--json", *arguments] + ) + + assert result.exit_code == 0, result.output + payload = json.loads(result.stdout) + assert payload["status"] == "completed" + assert (tmp_path / "record-id.txt").read_text().strip() == str(number) + state_path = ( + tmp_path / ".specify" / "workflows" / "runs" / payload["run_id"] / "state.json" + ) + state = json.loads(state_path.read_text()) + assert state["inputs"]["record_id"] == number + assert isinstance(state["inputs"]["record_id"], int) + + class TestWorkflowRunGateOutcomeJson: """CLI-level tests: the --json payload surfaces gate pauses.""" diff --git a/tests/test_workflows.py b/tests/test_workflows.py index 04298dc640..410faa13d8 100644 --- a/tests/test_workflows.py +++ b/tests/test_workflows.py @@ -7072,6 +7072,52 @@ def test_validate_workflow_rejects_bool_default_for_number_type(self): errors = validate_workflow(definition) assert any("invalid default" in e for e in errors), errors + @pytest.mark.parametrize( + "number", [2**53 + 1, 1792234567890123456, 2**63 - 1, -(2**63 - 1)] + ) + @pytest.mark.parametrize("as_string", [False, True]) + def test_number_input_preserves_integer_precision(self, number, as_string): + from specify_cli.workflows.engine import WorkflowEngine + + value = str(number) if as_string else number + resolved = WorkflowEngine._coerce_input("record_id", value, {"type": "number"}) + + assert resolved == number + assert isinstance(resolved, int) + + @pytest.mark.parametrize( + ("value", "expected"), + [ + ("0", 0), (" +42 ", 42), ("-42", -42), + ("3.5", 3.5), ("5.0", 5), ("1e3", 1000), + ], + ) + def test_number_input_retains_existing_numeric_forms(self, value, expected): + from specify_cli.workflows.engine import WorkflowEngine + + resolved = WorkflowEngine._coerce_input("count", value, {"type": "number"}) + + assert resolved == expected + assert type(resolved) is type(expected) + + @pytest.mark.parametrize( + "value", [True, False, None, [], "invalid", "nan", "inf", "-inf"] + ) + def test_number_input_rejects_non_numbers_and_non_finite_values(self, value): + from specify_cli.workflows.engine import WorkflowEngine + + with pytest.raises(ValueError, match="expected a number"): + WorkflowEngine._coerce_input("count", value, {"type": "number"}) + + def test_number_input_checks_enum_without_rounding(self): + from specify_cli.workflows.engine import WorkflowEngine + + number = 2**53 + 1 + definition = {"type": "number", "enum": [number]} + assert WorkflowEngine._coerce_input("record_id", str(number), definition) == number + with pytest.raises(ValueError, match="not in allowed values"): + WorkflowEngine._coerce_input("record_id", str(number - 1), definition) + def test_coerce_number_input_rejects_infinity_cleanly(self): """An infinite float must surface as a clean ValueError (like NaN), not let ``int(inf)``'s OverflowError escape: ``int()`` of an infinity raises