Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions src/nanodot/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
Expand Down Expand Up @@ -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)
Expand Down
45 changes: 42 additions & 3 deletions src/nanodot/core/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion tests/test_permissions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
10 changes: 5 additions & 5 deletions tests/test_scope_lifecycle_safety.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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)

Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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):
Expand Down
6 changes: 4 additions & 2 deletions tests/test_tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
150 changes: 150 additions & 0 deletions tests/test_watch_update.py
Original file line number Diff line number Diff line change
@@ -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
2 changes: 1 addition & 1 deletion tests/test_write_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Loading