Rewrite CLI around action runtime

This commit is contained in:
2026-05-14 13:14:38 +03:00
parent 0dc90f9005
commit 3503d81b06
28 changed files with 2778 additions and 574 deletions

View File

@@ -0,0 +1,140 @@
"""Tests for the canonical action executor."""
from __future__ import annotations
import json
import sys
import pytest
from flow.actions import ActionExecutor, ActionPlan, PrimitiveAction, RollbackPolicy
from flow.core.config import AppConfig, FlowContext
from flow.core.console import Console
from flow.core.errors import FlowError
from flow.core.platform import PlatformInfo
from flow.core.runtime import SystemRuntime
def _ctx() -> FlowContext:
return FlowContext(
config=AppConfig(),
manifest={},
platform=PlatformInfo(),
console=Console(color=False),
runtime=SystemRuntime(),
)
def test_dry_run_does_not_mutate(tmp_path):
target = tmp_path / "out.txt"
plan = ActionPlan(
name="dry-run",
primitive_actions=(
PrimitiveAction(
id="write",
type="file.write",
description="Write a file",
payload={"path": target, "content": "hello"},
),
),
)
summary = ActionExecutor(_ctx(), audit_path=tmp_path / "actions.jsonl").execute(
plan,
dry_run=True,
)
assert not target.exists()
assert summary.results[0].status == "dry-run"
def test_audit_jsonl_records_success(tmp_path):
target = tmp_path / "out.txt"
audit_path = tmp_path / "actions.jsonl"
plan = ActionPlan(
name="write-plan",
primitive_actions=(
PrimitiveAction(
id="write",
type="file.write",
description="Write a file",
payload={"path": target, "content": "hello"},
),
),
)
ActionExecutor(_ctx(), audit_path=audit_path).execute(plan)
records = [json.loads(line) for line in audit_path.read_text().splitlines()]
assert [record["event"] for record in records] == [
"plan_start",
"action_start",
"action_success",
"plan_success",
]
assert target.read_text() == "hello"
def test_rollback_removes_created_symlink_after_failure(tmp_path):
source = tmp_path / "source"
target = tmp_path / "target"
source.write_text("managed")
plan = ActionPlan(
name="rollback",
primitive_actions=(
PrimitiveAction(
id="link",
type="file.create_symlink",
description="Create managed link",
payload={"source": source, "target": target},
),
PrimitiveAction(
id="copy-missing",
type="file.copy",
description="Fail on missing source",
payload={"source": tmp_path / "missing", "target": tmp_path / "dest"},
),
),
)
with pytest.raises(FlowError, match="missing"):
ActionExecutor(_ctx(), audit_path=tmp_path / "actions.jsonl").execute(plan)
assert not target.exists()
assert not target.is_symlink()
def test_barrier_prevents_rollback_across_external_boundary(tmp_path):
source = tmp_path / "source"
target = tmp_path / "target"
source.write_text("managed")
plan = ActionPlan(
name="barrier",
primitive_actions=(
PrimitiveAction(
id="link",
type="file.create_symlink",
description="Create managed link",
payload={"source": source, "target": target},
),
PrimitiveAction(
id="barrier",
type="process.argv",
description="External boundary",
payload={"argv": (sys.executable, "-c", "")},
rollback_policy=RollbackPolicy.BARRIER,
),
PrimitiveAction(
id="copy-missing",
type="file.copy",
description="Fail on missing source",
payload={"source": tmp_path / "missing", "target": tmp_path / "dest"},
),
),
)
with pytest.raises(FlowError, match="missing"):
ActionExecutor(_ctx(), audit_path=tmp_path / "actions.jsonl").execute(plan)
assert target.is_symlink()

View File

@@ -4,6 +4,19 @@ import os
import subprocess
import sys
from typer.testing import CliRunner
from flow.cli import app
runner = CliRunner()
def test_typer_runner_version():
result = runner.invoke(app, ["--version"])
assert result.exit_code == 0
assert "flow" in result.stdout
def test_version_flag():
"""Test --version flag works."""

View File

@@ -9,7 +9,7 @@ import yaml
from flow.core.config import AppConfig, FlowContext
from flow.core.console import Console
from flow.core.errors import FlowError
from flow.core.errors import FlowError, PlanConflict
from flow.core.platform import PlatformInfo
from flow.core.runtime import SystemRuntime
from flow.core import paths
@@ -187,7 +187,7 @@ class TestDotfilesServiceLink:
output = capsys.readouterr().out
assert "zsh" in output
def test_relink_does_not_remove_unmanaged_file(self, tmp_path, monkeypatch):
def test_relink_fails_on_unmanaged_file(self, tmp_path, monkeypatch):
home = tmp_path / "home"
home.mkdir()
@@ -208,7 +208,8 @@ class TestDotfilesServiceLink:
target.unlink()
target.write_text("user managed file")
svc.link()
with pytest.raises(PlanConflict):
svc.link()
assert target.read_text() == "user managed file"
assert not target.is_symlink()
@@ -619,12 +620,10 @@ class TestUnclonedModuleWarning:
), console.warnings
class TestOrphanAdoption:
"""After a partial-failure rerun the planner adopts pre-existing matching
symlinks. We simulate a failure during _apply_plan by replacing
create_symlink mid-flight, then re-running link()."""
class TestActionRollback:
"""A mid-execution failure rolls back created links and leaves state absent."""
def test_partial_apply_failure_recoverable_via_rerun(self, tmp_path, monkeypatch):
def test_partial_apply_failure_rolls_back_then_rerun_succeeds(self, tmp_path, monkeypatch):
home = tmp_path / "home"
home.mkdir()
dotfiles = _setup_dotfiles(tmp_path, {
@@ -659,13 +658,13 @@ class TestOrphanAdoption:
# State file should NOT have been written (atomic semantics: we
# only persist when _apply_plan completes).
assert not state_path.exists()
# First symlink landed on disk.
# The first symlink was rolled back.
existing_links = sorted(
p.name for p in home.rglob("*") if p.is_symlink()
)
assert len(existing_links) == 1
assert existing_links == []
# Restore real implementation and re-run: orphan adoption kicks in.
# Restore real implementation and re-run.
monkeypatch.setattr(ctx.runtime.fs, "create_symlink", real_create)
svc.link()

View File

@@ -57,8 +57,8 @@ class TestPackageService:
monkeypatch.setattr(paths, "INSTALLED_STATE", tmp_path / "installed.json")
ctx = _make_ctx(tmp_path)
svc = PackageService(ctx)
svc.remove(["missing"])
assert "No matching" in capsys.readouterr().out
with pytest.raises(FlowError, match="not installed"):
svc.remove(["missing"])
def test_install_requires_args(self, tmp_path, monkeypatch):
monkeypatch.setattr(paths, "INSTALLED_STATE", tmp_path / "installed.json")
@@ -106,7 +106,7 @@ class TestPackageService:
def read(self):
return archive_bytes
monkeypatch.setattr("flow.services.packages.urllib.request.urlopen", lambda *args, **kwargs: FakeResponse())
monkeypatch.setattr("flow.adapters.download.urllib.request.urlopen", lambda *args, **kwargs: FakeResponse())
manifest = {
"packages": [{
@@ -176,7 +176,7 @@ class TestPackageService:
raise urllib.error.URLError("Network unreachable")
monkeypatch.setattr(
"flow.services.packages.urllib.request.urlopen", _raise,
"flow.adapters.download.urllib.request.urlopen", _raise,
)
manifest = {

View File

@@ -0,0 +1,45 @@
"""Static guard for direct filesystem mutation outside adapters/actions."""
from __future__ import annotations
import re
from pathlib import Path
MUTATING_PATTERNS = (
re.compile(r"\.(mkdir|unlink|write_text|write_bytes|symlink_to|chmod)\("),
re.compile(r"\b(os\.replace|shutil\.rmtree|shutil\.copy2|shutil\.copytree)\("),
re.compile(r"runtime\.fs\.(create_symlink|remove_symlink|write_json|write_text|write_bytes|copy_file|copy_tree|remove_file|remove_tree)\("),
)
ALLOWED_PREFIXES = (
Path("src/flow/adapters"),
Path("src/flow/actions"),
Path("tests"),
)
ALLOWED_FILES = {
Path("src/flow/core/paths.py"),
}
SKIPPED_LEGACY_COMMANDS = {
Path("src/flow/commands/completion.py"),
}
def test_no_direct_filesystem_mutation_outside_action_boundary():
root = Path(__file__).resolve().parents[1]
offenders: list[str] = []
for path in sorted((root / "src" / "flow").rglob("*.py")):
rel = path.relative_to(root)
if rel in ALLOWED_FILES or rel in SKIPPED_LEGACY_COMMANDS:
continue
if any(rel.is_relative_to(prefix) for prefix in ALLOWED_PREFIXES):
continue
for line_no, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1):
if "Service(" in line:
continue
if any(pattern.search(line) for pattern in MUTATING_PATTERNS):
offenders.append(f"{rel}:{line_no}: {line.strip()}")
assert offenders == []