Stage 3b of the realism migration. A TODO.md planted on Monday gets a checkbox flipped on Tuesday; a notes file grows a follow-up line; a cron log gets a fresh entry tacked on. The synthetic_files row's edit_count, last_modified, and content_hash advance. New surface: - EditAction dataclass (peer of FileAction in scheduler.py): carries decky, path, persona, content_class, previous_body, mtime, and synthetic_file_uuid for the worker's update path. - realism.bodies.next_iteration(cls, persona, prev, rng): per-class deterministic mutators. TODO flips an unchecked box and/or appends; notes/drafts/scripts append; logs are append-only (mirroring real log behaviour). Canary, cache_tmp, email raise KeyError — unsupported. - realism.planner.pick gains an edit branch: 60% create, 30% edit (when an edit_candidate is supplied), 10% leave-alone. Returns None on leave-alone — quiet ticks are realism too. - scheduler.pick_file pre-fetches a single edit candidate via repo.pick_random_synthetic_file_for_edit ~50% of ticks; the planner decides whether to use it. - SSHDriver._run_edit: turns next_iteration output into a plant_file call (mtime-bumped, mode 0o644). Stashes new_body in result.payload so the worker can hash it for synthetic_files. - worker._bump_synthetic_file_after_edit: patches edit_count + 1, last_modified=now, content_hash, last_body for the row UUID. No-op when the row was pruned mid-flight. - events.to_row / topic_for / event_type_for now recognise EditAction (kind="file", action="file:edit").
99 lines
3.1 KiB
Python
99 lines
3.1 KiB
Python
"""next_iteration mutators per content class.
|
|
|
|
Stage 3b — read-modify-write contract: each editor takes a previous
|
|
body and returns a plausible next iteration. Append-only for logs;
|
|
small in-place edits for user content.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import random
|
|
|
|
import pytest
|
|
|
|
from decnet.realism.bodies import next_iteration
|
|
from decnet.realism.taxonomy import ContentClass
|
|
|
|
|
|
def test_todo_edit_can_flip_an_unchecked_box() -> None:
|
|
prev = "- [ ] rotate keys\n- [ ] review pr\n"
|
|
seen_flip = False
|
|
for seed in range(40):
|
|
new = next_iteration(
|
|
ContentClass.TODO, "admin", prev, rand=random.Random(seed),
|
|
)
|
|
if "[x]" in new and "rotate" in new and "[x] rotate" in new:
|
|
seen_flip = True
|
|
if "[x]" in new and "[x] review" in new:
|
|
seen_flip = True
|
|
if seen_flip:
|
|
break
|
|
assert seen_flip, "no checkbox flip across 40 seeds — mutator broken"
|
|
|
|
|
|
def test_todo_edit_grows_or_holds_line_count() -> None:
|
|
prev = "- [ ] rotate keys\n"
|
|
new = next_iteration(
|
|
ContentClass.TODO, "admin", prev, rand=random.Random(0),
|
|
)
|
|
# Mutators may flip a box (same line count) or append (more lines)
|
|
# — but never shrink the file.
|
|
assert len(new.splitlines()) >= len(prev.splitlines())
|
|
|
|
|
|
def test_log_cron_edit_is_append_only() -> None:
|
|
prev = (
|
|
"Apr 27 09:00:01 hostname CRON[1234]: (root) CMD (run-parts /etc/cron.daily)\n"
|
|
)
|
|
new = next_iteration(
|
|
ContentClass.LOG_CRON, "admin", prev, rand=random.Random(0),
|
|
)
|
|
assert new.startswith(prev.rstrip())
|
|
assert len(new.splitlines()) > len(prev.splitlines())
|
|
|
|
|
|
def test_log_daemon_edit_is_append_only() -> None:
|
|
prev = "Apr 27 09:00:01 hostname systemd[1]: Started Daily apt download activities.\n"
|
|
new = next_iteration(
|
|
ContentClass.LOG_DAEMON, "admin", prev, rand=random.Random(0),
|
|
)
|
|
assert new.startswith(prev.rstrip())
|
|
|
|
|
|
def test_note_edit_grows_the_body() -> None:
|
|
prev = "remember to ping the on-call\n"
|
|
new = next_iteration(
|
|
ContentClass.NOTE, "admin", prev, rand=random.Random(0),
|
|
)
|
|
assert prev in new
|
|
assert len(new) > len(prev)
|
|
|
|
|
|
def test_draft_edit_appends_paragraph() -> None:
|
|
prev = "Hi team,\n\nQuick update.\n"
|
|
new = next_iteration(
|
|
ContentClass.DRAFT, "admin", prev, rand=random.Random(0),
|
|
)
|
|
assert new.startswith(prev.rstrip())
|
|
assert len(new) > len(prev)
|
|
|
|
|
|
def test_script_edit_appends_comment() -> None:
|
|
prev = "#!/usr/bin/env bash\nset -e\necho 'hi'\n"
|
|
new = next_iteration(
|
|
ContentClass.SCRIPT, "admin", prev, rand=random.Random(0),
|
|
)
|
|
assert new.startswith(prev.rstrip())
|
|
# New tail must be a comment (the editor's contract); never a
|
|
# silently-injected new exec line.
|
|
new_tail = new[len(prev.rstrip()):].strip()
|
|
assert new_tail.startswith("#")
|
|
|
|
|
|
@pytest.mark.parametrize("cls", [
|
|
ContentClass.CACHE_TMP, ContentClass.EMAIL,
|
|
ContentClass.CANARY_AWS_CREDS, ContentClass.CANARY_HONEYDOC,
|
|
])
|
|
def test_unsupported_classes_raise_in_edit(cls: ContentClass) -> None:
|
|
with pytest.raises(KeyError):
|
|
next_iteration(cls, "admin", "anything")
|