diff --git a/src/nanodot/cli.py b/src/nanodot/cli.py index 6bdf7ad..716d955 100644 --- a/src/nanodot/cli.py +++ b/src/nanodot/cli.py @@ -72,6 +72,17 @@ def build_parser() -> argparse.ArgumentParser: for action in ("pause", "resume", "cancel"): cmd = watch_sub.add_parser(action, help=f"{action} a task") cmd.add_argument("task_id") + update = watch_sub.add_parser( + "update", help="adjust operational knobs in place (grants survive)" + ) + update.add_argument("task_id") + update.add_argument("--cadence", type=int, help="seconds between checks") + update.add_argument("--digest", choices=["off", "6h", "12h", "24h"], + help="scheduled heartbeat interval") + update.add_argument("--stale", choices=["off", "2d", "3d", "7d", "14d"], + help="idle alert threshold") + update.add_argument("--flaky", choices=["on", "off"], + help="pass/fail flip alerts") add.add_argument( "--digest", default="off", choices=["off", "6h", "12h", "24h"], @@ -485,6 +496,50 @@ def _run_watch(args: argparse.Namespace) -> int: print(f"created watch {created.id} ({created.target})") return 0 + if args.watch_command == "update": + knobs = { + "cadence": args.cadence, + "digest": args.digest, + "stale": args.stale, + "flaky": args.flaky, + } + if all(value is None for value in knobs.values()): + print("error: nothing to update — pass at least one of " + "--cadence/--digest/--stale/--flaky", file=sys.stderr) + return 1 + task = store.get(args.task_id) + if task is None: + print(f"error: no such task {args.task_id}", file=sys.stderr) + return 1 + digest_map = {"off": "off", "6h": 21600, "12h": 43200, "24h": 86400} + stale_map = {"off": "off", "2d": 172800, "3d": 259200, + "7d": 604800, "14d": 1209600} + try: + updated = store.update_tuning( + args.task_id, + cadence_seconds=args.cadence, + digest_interval_seconds=( + None if args.digest is None else digest_map[args.digest] + ), + stale_after_seconds=( + None if args.stale is None else stale_map[args.stale] + ), + flaky_alerts=( + None if args.flaky is None else args.flaky == "on" + ), + ) + except TaskError as error: + print(f"error: {error}", file=sys.stderr) + return 1 + def _show(value, unit, off="off"): + return off if value is None else f"{value}{unit}" + print(f"updated {updated.id} ({updated.target}) — grants unaffected") + print(f" cadence: {updated.cadence_seconds}s " + f"digest: {_show(updated.digest_interval_seconds and updated.digest_interval_seconds // 3600, 'h')} " + f"stale: {_show(updated.stale_after_seconds and updated.stale_after_seconds // 86400, 'd')} " + f"flaky: {'on' if updated.flaky_alerts else 'off'}") + return 0 + if args.watch_command in ("pause", "resume", "cancel"): try: task = getattr(store, args.watch_command)(args.task_id) diff --git a/src/nanodot/core/tasks.py b/src/nanodot/core/tasks.py index dd7940e..cf12cbd 100644 --- a/src/nanodot/core/tasks.py +++ b/src/nanodot/core/tasks.py @@ -368,8 +368,10 @@ def update(self, task: Task) -> Task: raise TaskError("task is no longer active; use an explicit lifecycle operation") if task.scope_version < current.scope_version: raise TaskError("task scope changed; reload it before updating") + # Cadence is an operational knob (#95): adjusting it must not + # invalidate grants, so it is not an authorization-scope field. scope_fields = ( - "target", "purpose", "cadence_seconds", "allowed_actions", + "target", "purpose", "allowed_actions", "notification_conditions", "stop_conditions", ) scope_changed = any( @@ -481,12 +483,49 @@ def update_scope( stop_conditions=( stop_conditions if stop_conditions is not None else task.stop_conditions ), + ) + changed.scope_version = task.scope_version + 1 + return self.update(changed) + + def update_tuning( + self, + task_id: str, + *, + cadence_seconds: int | None = None, + digest_interval_seconds: int | None | str | None = None, + stale_after_seconds: int | None | str | None = None, + flaky_alerts: bool | None = None, + ) -> Task: + """Operational knobs, not authorization scope: cadence and the + watch-kind dimensions adjust in place with NO scope_version bump, + so grants survive (#95). A string value of "off" clears a dimension + (None means "leave unchanged"); terminal watches reject. + """ + task = self._require(task_id) + if task.state.terminal: + raise TaskError("task is no longer active; use an explicit lifecycle operation") + + def tuned(current, value): + if value is None: + return current + return None if isinstance(value, str) else value + + changed = replace( + task, cadence_seconds=( cadence_seconds if cadence_seconds is not None else task.cadence_seconds ), + digest_interval_seconds=tuned( + task.digest_interval_seconds, digest_interval_seconds + ), + stale_after_seconds=tuned( + task.stale_after_seconds, stale_after_seconds + ), + flaky_alerts=( + flaky_alerts if flaky_alerts is not None else task.flaky_alerts + ), ) - changed.scope_version = task.scope_version + 1 - return self.update(changed) + return self.update(changed) # validate() enforces the enums def _require(self, task_id: str) -> Task: task = self.get(task_id) diff --git a/tests/test_permissions.py b/tests/test_permissions.py index 91cc798..d54077f 100644 --- a/tests/test_permissions.py +++ b/tests/test_permissions.py @@ -430,7 +430,7 @@ def test_task_store_scope_change_revokes_grants_and_pending_requests(home: Path) other_pending = center.request("comment", "o/r#2", "once", "other-task") before = store.get(task.id).scope_version - store.update_scope(task.id, cadence_seconds=600) + store.update_scope(task.id, purpose="scope edit probe") after = store.get(task.id).scope_version assert after == before + 1 assert not center.permits("rerun", "owner/repo#1", "failed-checks") diff --git a/tests/test_scope_lifecycle_safety.py b/tests/test_scope_lifecycle_safety.py index 88a8c8c..ef7081a 100644 --- a/tests/test_scope_lifecycle_safety.py +++ b/tests/test_scope_lifecycle_safety.py @@ -248,7 +248,7 @@ def test_lifecycle_change_during_fetch_prevents_delivery_and_stale_write(home, c class ChangingFetcher: def fetch(self, target): if change == "scope": - h.store.update_scope(h.task.id, cadence_seconds=600) + h.store.update_scope(h.task.id, purpose="scope edit probe") else: getattr(h.store, change)(h.task.id) if fetch_error: @@ -265,7 +265,7 @@ def fetch(self, target): assert h.sink.events == [] saved = h.store.get(h.task.id) if change == "scope": - assert saved.cadence_seconds == 600 and saved.scope_version == 2 + assert saved.purpose == "scope edit probe" and saved.scope_version == 2 else: assert saved.state is (TaskState.PAUSED if change == "pause" else TaskState.CANCELLED) @@ -316,7 +316,7 @@ def test_lifecycle_change_during_summary_prevents_notification_and_write(home, c class ChangingProvider: def summarize(self, change_event): if change == "scope": - h.store.update_scope(h.task.id, cadence_seconds=600) + h.store.update_scope(h.task.id, purpose="scope edit probe") else: getattr(h.store, change)(h.task.id) return "finished" @@ -346,10 +346,10 @@ def test_stale_update_cannot_undo_pause_or_scope_change(home): store.update(task) assert store.get(task.id).state is TaskState.PAUSED store.resume(task.id, now=1000) - store.update_scope(task.id, cadence_seconds=600) + store.update_scope(task.id, purpose="scope edit probe") with pytest.raises(TaskError, match="scope changed"): store.update(task) - assert store.get(task.id).cadence_seconds == 600 + assert store.get(task.id).purpose == "scope edit probe" def test_secret_target_is_rejected_on_create_update_and_execution(home): diff --git a/tests/test_tasks.py b/tests/test_tasks.py index 7812177..b7167dc 100644 --- a/tests/test_tasks.py +++ b/tests/test_tasks.py @@ -105,9 +105,11 @@ def test_scope_stored_verbatim_and_version_bumps(home: Path) -> None: assert loaded.stop_conditions == DEFAULT_STOP_CONDITIONS assert loaded.scope_version == 1 - changed = store.update_scope(task.id, cadence_seconds=600) + changed = store.update_tuning(task.id, cadence_seconds=600) assert changed.cadence_seconds == 600 - assert changed.scope_version == 2 + assert changed.scope_version == 1 # operational knob: no version bump + rescoped = store.update_scope(task.id, purpose="narrowed") + assert rescoped.scope_version == 2 # a real scope change still bumps def test_blocked_state_carries_reason(home: Path) -> None: diff --git a/tests/test_watch_update.py b/tests/test_watch_update.py new file mode 100644 index 0000000..3e3e89e --- /dev/null +++ b/tests/test_watch_update.py @@ -0,0 +1,150 @@ +"""watch update acceptance tests (issue #95): operational knobs adjust in +place without grant invalidation; scope changes still bump scope_version.""" + +from __future__ import annotations + +from pathlib import Path +from unittest import mock + +import pytest + +from nanodot.core.permissions import PermissionCenter +from nanodot.core.tasks import PRTarget, Task, TaskError, TaskStore + + +def make_store(home: Path) -> TaskStore: + return TaskStore(path=home / "nanodot.db") + + +def make_task(store: TaskStore) -> Task: + return store.create( + Task(target=PRTarget.parse("thinkflowlab/nanodot#95"), purpose="p") + ) + + +def _add_watch(home: Path, *extra: str) -> int: + from nanodot.cli import main + from nanodot.native.secrets_file import FileSecretStore + + FileSecretStore().set("github-token", "ghp_x") + with mock.patch("builtins.input", return_value="y"): + return main(["watch", "add", "thinkflowlab/nanodot#95", "--yes", *extra]) + + +# -- store semantics -------------------------------------------------------------- + + +def test_tuning_updates_without_scope_bump(home: Path) -> None: + store = make_store(home) + task = make_task(store) + updated = store.update_tuning( + task.id, + cadence_seconds=120, + digest_interval_seconds=21600, + stale_after_seconds=172800, + flaky_alerts=True, + ) + assert updated.cadence_seconds == 120 + assert updated.digest_interval_seconds == 21600 + assert updated.stale_after_seconds == 172800 + assert updated.flaky_alerts is True + assert updated.scope_version == 1 # operational, not authorization scope + assert updated.state.value == "active" + + +def test_tuning_off_clears_dimensions(home: Path) -> None: + store = make_store(home) + task = store.create( + Task( + target=PRTarget.parse("a/b#1"), + purpose="p", + digest_interval_seconds=86400, + stale_after_seconds=604800, + flaky_alerts=True, + ) + ) + updated = store.update_tuning( + task.id, digest_interval_seconds="off", stale_after_seconds="off", + flaky_alerts=False, + ) + assert updated.digest_interval_seconds is None + assert updated.stale_after_seconds is None + assert updated.flaky_alerts is False + + +def test_tuning_validates_enums(home: Path) -> None: + store = make_store(home) + task = make_task(store) + with pytest.raises(TaskError): + store.update_tuning(task.id, digest_interval_seconds=12345) + with pytest.raises(TaskError): + store.update_tuning(task.id, stale_after_seconds=99) + + +def test_tuning_rejects_terminal_allows_paused(home: Path) -> None: + store = make_store(home) + task = make_task(store) + store.pause(task.id) + assert store.update_tuning(task.id, cadence_seconds=60).cadence_seconds == 60 + store.cancel(task.id) + with pytest.raises(TaskError): + store.update_tuning(task.id, cadence_seconds=60) + + +def test_grants_survive_tuning_and_die_on_scope_change(home: Path) -> None: + store = make_store(home) + center = PermissionCenter(path=home / "nanodot.db") + task = make_task(store) + req = center.request("comment", str(task.target), "watch", task.id) + center.approve(req.id) + assert center.permits("comment", str(task.target), "watch", task.id) + + store.update_tuning(task.id, cadence_seconds=99, flaky_alerts=True) + assert center.permits("comment", str(task.target), "watch", task.id) # survive + + store.update_scope(task.id, purpose="new purpose") + assert not center.permits("comment", str(task.target), "watch", task.id) # die + + +# -- CLI surface -------------------------------------------------------------------- + + +def test_cli_update_each_knob(home: Path, capsys) -> None: + assert _add_watch(home) == 0 + task = TaskStore().list()[0] + + from nanodot.cli import main + + assert main([ + "watch", "update", task.id, + "--cadence", "120", "--digest", "24h", "--stale", "7d", "--flaky", "on", + ]) == 0 + updated = TaskStore().get(task.id) + assert updated.cadence_seconds == 120 + assert updated.digest_interval_seconds == 86400 + assert updated.stale_after_seconds == 604800 + assert updated.flaky_alerts is True + assert updated.scope_version == 1 + out = capsys.readouterr().out + assert "grants unaffected" in out and "digest: 24h" in out + + assert main(["watch", "update", task.id, "--digest", "off"]) == 0 + assert TaskStore().get(task.id).digest_interval_seconds is None + + +def test_cli_update_rejects_noop_and_bad_id(home: Path, capsys) -> None: + from nanodot.cli import main + + assert _add_watch(home) == 0 + task = TaskStore().list()[0] + assert main(["watch", "update", task.id]) == 1 + assert "nothing to update" in capsys.readouterr().err + assert main(["watch", "update", "no-such-id", "--cadence", "60"]) == 1 + assert "no such task" in capsys.readouterr().err + + +def test_cli_update_invalid_value_rejected_at_parse(home: Path) -> None: + from nanodot.cli import main + + with pytest.raises(SystemExit): + main(["watch", "update", "x", "--digest", "2h"]) # not in choices diff --git a/tests/test_write_flow.py b/tests/test_write_flow.py index c5ad2d9..c3304ec 100644 --- a/tests/test_write_flow.py +++ b/tests/test_write_flow.py @@ -372,7 +372,7 @@ def test_lifecycle_change_kills_the_capability(home: Path, change: str) -> None: h.tick() h.approve_proposal() if change == "scope": - h.store.update_scope(h.task.id, cadence_seconds=600) + h.store.update_scope(h.task.id, purpose="scope edit probe") elif change == "pause": h.store.pause(h.task.id) else: