mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-12 23:12:23 -06:00
164f74dead
Operator-context system turns (watch results, output-guard findings, idle children, user interjections) carried their kind (_source) and a flattened text content, but the structured per-kind fields were dropped at every persist/deliver boundary — so the UI rendered every kind as one generic operator bubble and the structured watch-result card was lost. Wire the structured meta through as the single source of truth: - Storage: new conversations.meta JSON column (migration 060); threaded through save_message/save_messages_bulk (facade + protocol + both backends) and rehydrated in reconstruct_turns onto Turn.meta.extra["source_meta"]. - Canonical: make_system_turn carries meta as one _source_meta dict; turn_from_dict/turn_to_dict bridge it to/from Turn.meta.extra. - Live + history: widen on_system_turn(content, source, meta) across all impls + the SSE payload; surface _source_meta -> meta in the /history projection. SDK HistoryEvent docs note the field. - Producers derive both the model-facing content text AND the card from one meta dict, so they cannot drift: render_output_guard_text, build_watch_ reminder carrying output, idle_children and user_interjection metadata. - Frontend: addSystemContext / renderSystemTurn dispatch by source to the watch-result, guard-finding, idle-children, and queued-message cards in both the interactive and coordinator panes; every untrusted field renders via textContent. The meta is a leading-underscore key, stripped before the wire (sanitize_ messages and the native mid-conversation path copy only role+content), so the per-provider wire payloads stay byte-identical. Additive column, no backfill: operator turns predating it reload as plain text bubbles.
569 lines
19 KiB
Python
569 lines
19 KiB
Python
"""Tests for the watch module — duration parsing, condition evaluation, WatchRunner."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import UTC, datetime
|
|
from unittest.mock import MagicMock
|
|
|
|
import pytest
|
|
|
|
from turnstone.core.watch import (
|
|
WatchRunner,
|
|
build_watch_reminder,
|
|
evaluate_condition,
|
|
format_interval,
|
|
format_watch_message,
|
|
parse_duration,
|
|
validate_condition,
|
|
)
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# parse_duration
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestParseDuration:
|
|
def test_seconds(self):
|
|
assert parse_duration("30s") == 30.0
|
|
|
|
def test_minutes(self):
|
|
assert parse_duration("5m") == 300.0
|
|
|
|
def test_hours(self):
|
|
assert parse_duration("1h") == 3600.0
|
|
|
|
def test_compound(self):
|
|
assert parse_duration("2h30m") == 9000.0
|
|
|
|
def test_bare_number(self):
|
|
assert parse_duration("90") == 90.0
|
|
|
|
def test_bare_float(self):
|
|
assert parse_duration("10.5") == 10.5
|
|
|
|
def test_whitespace(self):
|
|
assert parse_duration(" 5m ") == 300.0
|
|
|
|
def test_case_insensitive(self):
|
|
assert parse_duration("1H30M") == 5400.0
|
|
|
|
def test_empty_raises(self):
|
|
with pytest.raises(ValueError, match="empty"):
|
|
parse_duration("")
|
|
|
|
def test_invalid_raises(self):
|
|
with pytest.raises(ValueError, match="invalid duration"):
|
|
parse_duration("abc")
|
|
|
|
def test_negative_raises(self):
|
|
with pytest.raises(ValueError, match="positive"):
|
|
parse_duration("-5")
|
|
|
|
def test_zero_raises(self):
|
|
with pytest.raises(ValueError, match="positive"):
|
|
parse_duration("0")
|
|
|
|
def test_zero_duration_raises(self):
|
|
with pytest.raises(ValueError, match="positive"):
|
|
parse_duration("0s")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# validate_condition
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestValidateCondition:
|
|
def test_valid_expression(self):
|
|
assert validate_condition('data["state"] == "MERGED"') is None
|
|
|
|
def test_valid_simple(self):
|
|
assert validate_condition('"error" in output') is None
|
|
|
|
def test_valid_compound(self):
|
|
assert validate_condition('changed and "ready" in output.lower()') is None
|
|
|
|
def test_syntax_error(self):
|
|
result = validate_condition("if True:")
|
|
assert result is not None
|
|
assert "syntax" in result.lower()
|
|
|
|
def test_incomplete_expression(self):
|
|
result = validate_condition("==")
|
|
assert result is not None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# evaluate_condition
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestEvaluateCondition:
|
|
def test_none_first_poll_no_fire(self):
|
|
"""With stop_on=None, first poll (prev_output=None) should not fire."""
|
|
fired, reason = evaluate_condition(None, "hello", 0, None)
|
|
assert not fired
|
|
|
|
def test_none_change_detected(self):
|
|
fired, reason = evaluate_condition(None, "world", 0, "hello")
|
|
assert fired
|
|
assert "changed" in reason
|
|
|
|
def test_none_no_change(self):
|
|
fired, reason = evaluate_condition(None, "same", 0, "same")
|
|
assert not fired
|
|
|
|
def test_string_match(self):
|
|
fired, reason = evaluate_condition('"error" in output', "has error here", 0, None)
|
|
assert fired
|
|
|
|
def test_string_no_match(self):
|
|
fired, reason = evaluate_condition('"error" in output', "all good", 0, None)
|
|
assert not fired
|
|
|
|
def test_exit_code(self):
|
|
fired, reason = evaluate_condition("exit_code != 0", "fail", 1, None)
|
|
assert fired
|
|
|
|
def test_exit_code_zero(self):
|
|
fired, reason = evaluate_condition("exit_code != 0", "ok", 0, None)
|
|
assert not fired
|
|
|
|
def test_json_data(self):
|
|
output = '{"state": "MERGED"}'
|
|
fired, reason = evaluate_condition('data["state"] == "MERGED"', output, 0, None)
|
|
assert fired
|
|
|
|
def test_json_data_no_match(self):
|
|
output = '{"state": "OPEN"}'
|
|
fired, reason = evaluate_condition('data["state"] == "MERGED"', output, 0, None)
|
|
assert not fired
|
|
|
|
def test_json_data_none_for_non_json(self):
|
|
"""Non-JSON output should have data=None."""
|
|
fired, reason = evaluate_condition("data is None", "plain text", 0, None)
|
|
assert fired
|
|
|
|
def test_changed_variable(self):
|
|
fired, reason = evaluate_condition("changed", "new", 0, "old")
|
|
assert fired
|
|
|
|
def test_changed_false(self):
|
|
fired, reason = evaluate_condition("changed", "same", 0, "same")
|
|
assert not fired
|
|
|
|
def test_compound_condition(self):
|
|
fired, reason = evaluate_condition(
|
|
'changed and "ready" in output.lower()',
|
|
"System Ready",
|
|
0,
|
|
"System Starting",
|
|
)
|
|
assert fired
|
|
|
|
def test_invalid_expression_no_crash(self):
|
|
fired, reason = evaluate_condition("1/0", "hello", 0, None)
|
|
assert not fired
|
|
assert "error" in reason.lower()
|
|
|
|
def test_no_import_builtin(self):
|
|
"""__import__ should not be accessible."""
|
|
fired, reason = evaluate_condition("__import__('os')", "hello", 0, None)
|
|
assert not fired
|
|
assert "error" in reason.lower()
|
|
|
|
def test_no_open_builtin(self):
|
|
fired, reason = evaluate_condition("open('/etc/passwd')", "hello", 0, None)
|
|
assert not fired
|
|
assert "error" in reason.lower()
|
|
|
|
def test_no_exec_builtin(self):
|
|
fired, reason = evaluate_condition("exec('print(1)')", "hello", 0, None)
|
|
assert not fired
|
|
assert "error" in reason.lower()
|
|
|
|
def test_no_eval_builtin(self):
|
|
fired, reason = evaluate_condition("eval('1+1')", "hello", 0, None)
|
|
assert not fired
|
|
assert "error" in reason.lower()
|
|
|
|
def test_no_compile_builtin(self):
|
|
fired, reason = evaluate_condition("compile('1','','eval')", "hello", 0, None)
|
|
assert not fired
|
|
assert "error" in reason.lower()
|
|
|
|
def test_safe_len(self):
|
|
fired, reason = evaluate_condition("len(output) > 0", "hello", 0, None)
|
|
assert fired
|
|
|
|
def test_safe_sorted(self):
|
|
fired, reason = evaluate_condition("sorted([3,1,2]) == [1,2,3]", "x", 0, None)
|
|
assert fired
|
|
|
|
def test_data_get_method(self):
|
|
output = '{"mergedAt": "2024-01-15"}'
|
|
fired, reason = evaluate_condition('data.get("mergedAt") is not None', output, 0, None)
|
|
assert fired
|
|
|
|
def test_prev_output_available(self):
|
|
fired, reason = evaluate_condition(
|
|
"prev_output is not None and output != prev_output",
|
|
"new",
|
|
0,
|
|
"old",
|
|
)
|
|
assert fired
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# format_interval
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestFormatInterval:
|
|
def test_seconds(self):
|
|
assert format_interval(30) == "30s"
|
|
|
|
def test_exactly_60(self):
|
|
assert format_interval(60) == "1m"
|
|
|
|
def test_minutes(self):
|
|
assert format_interval(300) == "5m"
|
|
|
|
def test_exactly_3600(self):
|
|
assert format_interval(3600) == "1h"
|
|
|
|
def test_hours_and_minutes(self):
|
|
assert format_interval(5400) == "1h30m"
|
|
|
|
def test_hours_only(self):
|
|
assert format_interval(7200) == "2h"
|
|
|
|
def test_large_value(self):
|
|
assert format_interval(86400) == "24h"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# format_watch_message
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestFormatWatchMessage:
|
|
def test_basic(self):
|
|
msg = format_watch_message(
|
|
name="pr-review",
|
|
command="gh pr view --json state",
|
|
output='{"state": "MERGED"}',
|
|
poll_count=5,
|
|
max_polls=100,
|
|
elapsed_secs=1500,
|
|
stop_on='data["state"] == "MERGED"',
|
|
is_final=True,
|
|
reason='condition met: data["state"] == "MERGED"',
|
|
)
|
|
assert "pr-review" in msg
|
|
assert "poll #5/100" in msg
|
|
assert "25m" in msg
|
|
assert "gh pr view --json state" in msg
|
|
assert "MERGED" in msg
|
|
assert "auto-cancelled" in msg.lower()
|
|
# Model should see the condition it was waiting for
|
|
assert "condition:" in msg.lower()
|
|
|
|
def test_non_final(self):
|
|
msg = format_watch_message(
|
|
name="deploy",
|
|
command="curl -s http://localhost/health",
|
|
output="ok",
|
|
poll_count=3,
|
|
max_polls=50,
|
|
elapsed_secs=90,
|
|
stop_on=None,
|
|
is_final=False,
|
|
reason="",
|
|
)
|
|
assert "deploy" in msg
|
|
assert "auto-cancelled" not in msg.lower()
|
|
# Change-detection mode should be indicated
|
|
assert "output change" in msg.lower()
|
|
|
|
def test_max_polls_final(self):
|
|
msg = format_watch_message(
|
|
name="test",
|
|
command="echo hello",
|
|
output="hello",
|
|
poll_count=100,
|
|
max_polls=100,
|
|
elapsed_secs=6000,
|
|
stop_on=None,
|
|
is_final=True,
|
|
reason="",
|
|
)
|
|
assert "max polls" in msg.lower()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# build_watch_reminder
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestBuildWatchReminder:
|
|
"""The structured-reminder builder lifts ``format_watch_message``'s
|
|
args into a dict the dispatch closure can pass to
|
|
``WatchRunner._dispatch_result``. ``text`` matches the formatter's
|
|
output verbatim (so compaction / channel adapters / wire splice
|
|
keep their behaviour), and the optional fields ride alongside for
|
|
the frontend's ``.msg.watch-result`` card.
|
|
"""
|
|
|
|
def test_emits_text_body_and_fields(self):
|
|
kwargs = dict(
|
|
name="pr-review",
|
|
command="gh pr view --json state",
|
|
output='{"state": "MERGED"}',
|
|
poll_count=5,
|
|
max_polls=100,
|
|
elapsed_secs=1500,
|
|
stop_on='data["state"] == "MERGED"',
|
|
is_final=True,
|
|
reason='condition met: data["state"] == "MERGED"',
|
|
)
|
|
reminder = build_watch_reminder(**kwargs)
|
|
# Round-trip with format_watch_message — text is the same body
|
|
# the wire splice + channel adapters have always seen.
|
|
assert reminder["text"] == format_watch_message(**kwargs)
|
|
# Optional fields ride alongside.
|
|
assert reminder["type"] == "watch_triggered"
|
|
assert reminder["watch_name"] == "pr-review"
|
|
assert reminder["command"] == "gh pr view --json state"
|
|
# The raw shell output rides as its own field so the FE card body shows
|
|
# it alone (no header / command repeat); the wire ``text`` keeps the
|
|
# full prose for the model.
|
|
assert reminder["output"] == '{"state": "MERGED"}'
|
|
assert reminder["poll_count"] == 5
|
|
assert reminder["max_polls"] == 100
|
|
assert reminder["is_final"] is True
|
|
|
|
def test_non_final_carries_is_final_false(self):
|
|
reminder = build_watch_reminder(
|
|
name="deploy",
|
|
command="curl -s http://localhost/health",
|
|
output="ok",
|
|
poll_count=3,
|
|
max_polls=50,
|
|
elapsed_secs=90,
|
|
stop_on=None,
|
|
is_final=False,
|
|
reason="",
|
|
)
|
|
assert reminder["is_final"] is False
|
|
assert reminder["poll_count"] == 3
|
|
# No "auto-cancelled" body for non-final fires.
|
|
assert "auto-cancelled" not in reminder["text"].lower()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# WatchRunner
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestWatchRunner:
|
|
def _make_runner(self, storage=None, **kwargs):
|
|
if storage is None:
|
|
storage = MagicMock()
|
|
storage.list_due_watches.return_value = []
|
|
return WatchRunner(
|
|
storage=storage,
|
|
node_id="test-node",
|
|
check_interval=0.1,
|
|
tool_timeout=5,
|
|
**kwargs,
|
|
)
|
|
|
|
def test_start_stop(self):
|
|
runner = self._make_runner()
|
|
runner.start()
|
|
assert runner._thread is not None
|
|
assert runner._thread.is_alive()
|
|
runner.stop()
|
|
assert runner._thread is None
|
|
|
|
def test_tick_calls_list_due(self):
|
|
storage = MagicMock()
|
|
storage.list_due_watches.return_value = []
|
|
runner = self._make_runner(storage=storage)
|
|
runner._tick()
|
|
storage.list_due_watches.assert_called_once()
|
|
|
|
def test_poll_watch_runs_command(self):
|
|
storage = MagicMock()
|
|
storage.update_watch.return_value = True
|
|
runner = self._make_runner(storage=storage)
|
|
dispatch_fn = MagicMock()
|
|
runner.set_dispatch_fn("ws-1", dispatch_fn)
|
|
|
|
watch_row = {
|
|
"watch_id": "abc123",
|
|
"ws_id": "ws-1",
|
|
"name": "test-watch",
|
|
"command": "echo hello",
|
|
"stop_on": '"hello" in output',
|
|
"max_polls": 100,
|
|
"poll_count": 0,
|
|
"last_output": None,
|
|
"interval_secs": 60,
|
|
"created": datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S"),
|
|
}
|
|
runner._poll_watch(watch_row)
|
|
|
|
# Should update the watch in storage
|
|
storage.update_watch.assert_called_once()
|
|
call_kwargs = storage.update_watch.call_args
|
|
assert call_kwargs[0][0] == "abc123" # watch_id
|
|
assert call_kwargs[1]["poll_count"] == 1
|
|
# Condition should fire (output contains "hello")
|
|
assert call_kwargs[1]["active"] is False # deactivated
|
|
# Should dispatch result
|
|
dispatch_fn.assert_called_once()
|
|
|
|
def test_poll_watch_no_fire_on_first_change_detection(self):
|
|
storage = MagicMock()
|
|
storage.update_watch.return_value = True
|
|
runner = self._make_runner(storage=storage)
|
|
dispatch_fn = MagicMock()
|
|
runner.set_dispatch_fn("ws-1", dispatch_fn)
|
|
|
|
watch_row = {
|
|
"watch_id": "abc123",
|
|
"ws_id": "ws-1",
|
|
"name": "test-watch",
|
|
"command": "echo hello",
|
|
"stop_on": None, # change detection
|
|
"max_polls": 100,
|
|
"poll_count": 0,
|
|
"last_output": None, # first poll
|
|
"interval_secs": 60,
|
|
"created": datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S"),
|
|
}
|
|
runner._poll_watch(watch_row)
|
|
|
|
# First poll with change detection should not fire
|
|
dispatch_fn.assert_not_called()
|
|
call_kwargs = storage.update_watch.call_args
|
|
# Watch should remain active
|
|
assert "active" not in call_kwargs[1] or call_kwargs[1].get("active") is not False
|
|
|
|
def test_max_polls_deactivates(self):
|
|
storage = MagicMock()
|
|
storage.update_watch.return_value = True
|
|
runner = self._make_runner(storage=storage)
|
|
dispatch_fn = MagicMock()
|
|
runner.set_dispatch_fn("ws-1", dispatch_fn)
|
|
|
|
watch_row = {
|
|
"watch_id": "abc123",
|
|
"ws_id": "ws-1",
|
|
"name": "test-watch",
|
|
"command": "echo hello",
|
|
"stop_on": '"never" in output', # won't fire
|
|
"max_polls": 5,
|
|
"poll_count": 4, # next is #5 = max
|
|
"last_output": "hello\n",
|
|
"interval_secs": 60,
|
|
"created": datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S"),
|
|
}
|
|
runner._poll_watch(watch_row)
|
|
|
|
call_kwargs = storage.update_watch.call_args
|
|
assert call_kwargs[1]["active"] is False
|
|
assert call_kwargs[1]["poll_count"] == 5
|
|
dispatch_fn.assert_called_once()
|
|
|
|
def test_blocked_command_deactivates(self):
|
|
storage = MagicMock()
|
|
storage.update_watch.return_value = True
|
|
runner = self._make_runner(storage=storage)
|
|
|
|
watch_row = {
|
|
"watch_id": "abc123",
|
|
"ws_id": "ws-1",
|
|
"name": "test-watch",
|
|
"command": "rm -rf /",
|
|
"stop_on": None,
|
|
"max_polls": 100,
|
|
"poll_count": 0,
|
|
"last_output": None,
|
|
"interval_secs": 60,
|
|
"created": datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S"),
|
|
}
|
|
runner._poll_watch(watch_row)
|
|
|
|
storage.update_watch.assert_called_once()
|
|
call_kwargs = storage.update_watch.call_args
|
|
assert call_kwargs[0][0] == "abc123"
|
|
assert call_kwargs[1]["active"] is False
|
|
|
|
def test_dispatch_fn_registry(self):
|
|
runner = self._make_runner()
|
|
fn1 = MagicMock()
|
|
fn2 = MagicMock()
|
|
|
|
runner.set_dispatch_fn("ws-1", fn1)
|
|
runner.set_dispatch_fn("ws-2", fn2)
|
|
|
|
# ``_dispatch_result`` takes a structured reminder dict, not a
|
|
# bare string.
|
|
reminder1 = {"type": "watch_triggered", "text": "msg1"}
|
|
runner._dispatch_result("ws-1", reminder1, "watch-a")
|
|
fn1.assert_called_once_with(reminder1, "watch-a")
|
|
fn2.assert_not_called()
|
|
|
|
runner.remove_dispatch_fn("ws-1")
|
|
# After removal, dispatch should try restore_fn
|
|
reminder2 = {"type": "watch_triggered", "text": "msg2"}
|
|
runner._dispatch_result("ws-1", reminder2, "watch-b")
|
|
fn1.assert_called_once() # still just the one call
|
|
|
|
def test_restore_fn_called_for_evicted(self):
|
|
restored_fn = MagicMock()
|
|
restore_fn = MagicMock(return_value=restored_fn)
|
|
runner = self._make_runner(restore_fn=restore_fn)
|
|
|
|
reminder = {"type": "watch_triggered", "text": "hello"}
|
|
runner._dispatch_result("ws-evicted", reminder, "watch-x")
|
|
restore_fn.assert_called_once_with("ws-evicted")
|
|
restored_fn.assert_called_once_with(reminder, "watch-x")
|
|
|
|
def test_get_dispatch_fn_returns_registered_fn(self):
|
|
"""``get_dispatch_fn`` is the public accessor used by the
|
|
server-side restore path to retrieve the per-ws closure that
|
|
``set_watch_runner`` constructed during workstream rehydrate.
|
|
"""
|
|
runner = self._make_runner()
|
|
fn = MagicMock()
|
|
runner.set_dispatch_fn("ws-1", fn)
|
|
assert runner.get_dispatch_fn("ws-1") is fn
|
|
# Unknown ws → None.
|
|
assert runner.get_dispatch_fn("ws-missing") is None
|
|
# After removal → None.
|
|
runner.remove_dispatch_fn("ws-1")
|
|
assert runner.get_dispatch_fn("ws-1") is None
|
|
|
|
def test_run_command_success(self):
|
|
runner = self._make_runner()
|
|
output, code = runner._run_command("echo hello")
|
|
assert "hello" in output
|
|
assert code == 0
|
|
|
|
def test_run_command_failure(self):
|
|
runner = self._make_runner()
|
|
output, code = runner._run_command("exit 42")
|
|
assert code == 42
|
|
|
|
def test_run_command_timeout(self):
|
|
runner = self._make_runner()
|
|
runner._tool_timeout = 1
|
|
output, code = runner._run_command("sleep 30")
|
|
assert "timed out" in output.lower()
|
|
assert code == -1
|