test_execution_log.py 13.3 KB
"""U-027 tests: c_execution_log_get + c_ws_log_tail's data layer +
p_log_retention_cleanup (REQ-13 happy/boundary/failure). Real sqlite + real
tempfile-backed log files, no mocking needed.

Run: python3 -m unittest src.test_execution_log -v
"""

import json
import os
import tempfile
import unittest
from datetime import datetime, timedelta, timezone

from src import execution_log, schema


class ExecutionLogTestBase(unittest.TestCase):
    def setUp(self):
        self.tmp = tempfile.TemporaryDirectory()
        self.addCleanup(self.tmp.cleanup)
        self.conn = schema.connect(":memory:")
        schema.create_schema(self.conn)
        self.conn.execute(
            "INSERT INTO project (project_id, name, target_folder_path, has_git_repo, status) "
            "VALUES ('proj-1', 'P', '/tmp/proj-1', 1, 'active')"
        )
        self.conn.execute(
            "INSERT INTO task (task_id, project_id, goal_text, task_type, status, assigned_org_node) "
            "VALUES ('task-1', 'proj-1', 'g', 'goal', 'coding', 'coder')"
        )
        self.conn.execute(
            "INSERT INTO work_graph_run (run_id, task_id, org_node, graph_manifest, status) "
            "VALUES ('run-1', 'task-1', 'coder', '{}', 'running')"
        )
        self.conn.execute(
            "INSERT INTO skill (skill_id, owner_org_node, file_path, current_git_commit_sha, source, status) "
            "VALUES ('skill-1', 'coder', 'skills/skill-1/', 'sha', 'new', 'active')"
        )
        self.conn.execute(
            "INSERT INTO work_node_execution (node_exec_id, run_id, node_id, subagent_type, skill_ref, "
            "input_schema_ref, output_schema_ref, input_payload, status) "
            "VALUES ('node-1', 'run-1', 'n1', 'coder', 'skill-1', 'in.json', 'out.json', '{}', 'running')"
        )
        self.conn.commit()

    def tearDown(self):
        self.conn.close()

    def _log_path(self):
        return os.path.join(self.tmp.name, "node-1.jsonl")

    def _insert_log_file(self, status="active"):
        self.conn.execute(
            "INSERT INTO execution_log_file (log_file_id, node_exec_id, project_id, file_path, format, status) "
            "VALUES ('log-1', 'node-1', 'proj-1', ?, 'jsonl', ?)",
            (self._log_path(), status),
        )
        self.conn.commit()


class AppendLogTest(ExecutionLogTestBase):
    def test_appends_content_to_file_and_bumps_size(self):
        self._insert_log_file()
        execution_log.append_log(self.conn, "node-1", "line one\n")
        execution_log.append_log(self.conn, "node-1", "line two\n")
        with open(self._log_path()) as f:
            self.assertEqual(f.read(), "line one\nline two\n")
        size = self.conn.execute("SELECT size_bytes FROM execution_log_file WHERE log_file_id = 'log-1'").fetchone()[0]
        self.assertEqual(size, len("line one\nline two\n"))

    def test_writes_log_appended_execution_event(self):
        self._insert_log_file()
        execution_log.append_log(self.conn, "node-1", "hello\n")
        row = self.conn.execute(
            "SELECT event_type, task_id, payload FROM execution_event WHERE node_exec_id = 'node-1'"
        ).fetchone()
        self.assertEqual(row[0], "log_appended")
        self.assertEqual(row[1], "task-1")
        self.assertEqual(json.loads(row[2]), {"appended_content": "hello\n"})

    def test_creates_parent_directory_if_missing(self):
        nested_path = os.path.join(self.tmp.name, "a", "b", "node-1.jsonl")
        self.conn.execute(
            "INSERT INTO execution_log_file (log_file_id, node_exec_id, project_id, file_path, format, status) "
            "VALUES ('log-1', 'node-1', 'proj-1', ?, 'jsonl', 'active')",
            (nested_path,),
        )
        self.conn.commit()
        execution_log.append_log(self.conn, "node-1", "x")
        self.assertTrue(os.path.isfile(nested_path))

    def test_nonexistent_node_exec_raises(self):
        with self.assertRaises(execution_log.LogNotFoundError):
            execution_log.append_log(self.conn, "no-such-node", "x")


class GetLogTest(ExecutionLogTestBase):
    def test_returns_full_content_and_size(self):
        self._insert_log_file(status="closed")
        with open(self._log_path(), "w") as f:
            f.write("all the log content")
        result = execution_log.get_log(self.conn, "node-1")
        self.assertEqual(result["content"], "all the log content")
        self.assertEqual(result["total_size_bytes"], len("all the log content"))
        self.assertEqual(result["status"], "closed")

    def test_pagination_offset_and_limit(self):
        self._insert_log_file(status="closed")
        with open(self._log_path(), "w") as f:
            f.write("0123456789")
        result = execution_log.get_log(self.conn, "node-1", offset=3, limit=4)
        self.assertEqual(result["content"], "3456")
        self.assertEqual(result["total_size_bytes"], 10)

    def test_missing_physical_file_returns_empty_not_error(self):
        self._insert_log_file(status="closed")
        result = execution_log.get_log(self.conn, "node-1")
        self.assertEqual(result["content"], "")
        self.assertEqual(result["total_size_bytes"], 0)

    def test_nonexistent_node_exec_raises_log_not_found(self):
        with self.assertRaises(execution_log.LogNotFoundError):
            execution_log.get_log(self.conn, "no-such-node")

    def test_purged_log_raises(self):
        self._insert_log_file(status="purged")
        with self.assertRaises(execution_log.LogPurgedError):
            execution_log.get_log(self.conn, "node-1")


class NodeExecStatusTest(ExecutionLogTestBase):
    def test_returns_status(self):
        self.assertEqual(execution_log.node_exec_status(self.conn, "node-1"), "running")

    def test_nonexistent_raises(self):
        with self.assertRaises(execution_log.NodeExecNotFoundError):
            execution_log.node_exec_status(self.conn, "no-such-node")

    def test_is_terminal_status(self):
        for status in ("done", "blocked", "failed"):
            self.assertTrue(execution_log.is_terminal_status(status))
        for status in ("pending", "running", "reflecting", "retrying", "waiting_for_quota"):
            self.assertFalse(execution_log.is_terminal_status(status))


class PollLogEventsTest(ExecutionLogTestBase):
    def test_polls_only_new_log_appended_events_for_this_node(self):
        self._insert_log_file()
        after = execution_log.latest_log_event_rowid(self.conn, "node-1")
        execution_log.append_log(self.conn, "node-1", "chunk-1")
        events, new_after = execution_log.poll_log_events(self.conn, "node-1", after)
        self.assertEqual(len(events), 1)
        self.assertEqual(events[0]["appended_content"], "chunk-1")
        self.assertGreater(new_after, after)

        events_again, after2 = execution_log.poll_log_events(self.conn, "node-1", new_after)
        self.assertEqual(events_again, [])
        self.assertEqual(after2, new_after)

    def test_other_node_events_excluded(self):
        self.conn.execute(
            "INSERT INTO work_node_execution (node_exec_id, run_id, node_id, subagent_type, skill_ref, "
            "input_schema_ref, output_schema_ref, input_payload, status) "
            "VALUES ('node-2', 'run-1', 'n2', 'coder', 'skill-1', 'in.json', 'out.json', '{}', 'running')"
        )
        self.conn.execute(
            "INSERT INTO execution_log_file (log_file_id, node_exec_id, project_id, file_path, format, status) "
            "VALUES ('log-2', 'node-2', 'proj-1', ?, 'jsonl', 'active')",
            (os.path.join(self.tmp.name, "node-2.jsonl"),),
        )
        self._insert_log_file()
        self.conn.commit()
        execution_log.append_log(self.conn, "node-2", "not mine")
        events, _ = execution_log.poll_log_events(self.conn, "node-1", 0)
        self.assertEqual(events, [])


class LogRetentionCleanupTest(ExecutionLogTestBase):
    def setUp(self):
        super().setUp()
        self.conn.execute(
            "INSERT INTO log_retention_config (config_id, retention_days, updated_by) "
            "VALUES ('default', 1, 'test')"
        )
        self.conn.commit()

    def _insert_closed_log(self, log_file_id, created_at, size_bytes=100, file_content="x"):
        node_exec_id = log_file_id + "-node"
        self.conn.execute(
            "INSERT INTO work_node_execution (node_exec_id, run_id, node_id, subagent_type, skill_ref, "
            "input_schema_ref, output_schema_ref, input_payload, status) "
            "VALUES (?, 'run-1', ?, 'coder', 'skill-1', 'in.json', 'out.json', '{}', 'done')",
            (node_exec_id, log_file_id + "-n"),
        )
        path = os.path.join(self.tmp.name, log_file_id + ".jsonl")
        with open(path, "w") as f:
            f.write(file_content)
        self.conn.execute(
            "INSERT INTO execution_log_file (log_file_id, node_exec_id, project_id, file_path, format, "
            "status, size_bytes, created_at) VALUES (?, ?, 'proj-1', ?, 'jsonl', 'closed', ?, ?)",
            (log_file_id, node_exec_id, path, size_bytes, created_at),
        )
        self.conn.commit()
        return path

    def test_expired_closed_log_purged_and_file_deleted(self):
        old_ts = (datetime.now(timezone.utc) - timedelta(days=2)).strftime("%Y-%m-%d %H:%M:%S")
        path = self._insert_closed_log("log-old", old_ts)
        result = execution_log.run_log_retention_cleanup(self.conn)
        self.assertEqual(result["purged"], ["log-old"])
        status, purged_at = self.conn.execute(
            "SELECT status, purged_at FROM execution_log_file WHERE log_file_id = 'log-old'"
        ).fetchone()
        self.assertEqual(status, "purged")
        self.assertIsNotNone(purged_at)
        self.assertFalse(os.path.isfile(path))

    def test_not_yet_expired_log_untouched(self):
        recent_ts = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S")
        path = self._insert_closed_log("log-fresh", recent_ts)
        result = execution_log.run_log_retention_cleanup(self.conn)
        self.assertEqual(result["purged"], [])
        status = self.conn.execute(
            "SELECT status FROM execution_log_file WHERE log_file_id = 'log-fresh'"
        ).fetchone()[0]
        self.assertEqual(status, "closed")
        self.assertTrue(os.path.isfile(path))

    def test_viewing_purged_log_raises_purged_not_error(self):
        old_ts = (datetime.now(timezone.utc) - timedelta(days=2)).strftime("%Y-%m-%d %H:%M:%S")
        self.conn.execute(
            "INSERT INTO work_node_execution (node_exec_id, run_id, node_id, subagent_type, skill_ref, "
            "input_schema_ref, output_schema_ref, input_payload, status) "
            "VALUES ('node-old', 'run-1', 'n-old', 'coder', 'skill-1', 'in.json', 'out.json', '{}', 'done')"
        )
        path = os.path.join(self.tmp.name, "log-old2.jsonl")
        with open(path, "w") as f:
            f.write("x")
        self.conn.execute(
            "INSERT INTO execution_log_file (log_file_id, node_exec_id, project_id, file_path, format, "
            "status, created_at) VALUES ('log-old2', 'node-old', 'proj-1', ?, 'jsonl', 'closed', ?)",
            (path, old_ts),
        )
        self.conn.commit()
        execution_log.run_log_retention_cleanup(self.conn)
        with self.assertRaises(execution_log.LogPurgedError):
            execution_log.get_log(self.conn, "node-old")

    def test_max_total_size_pulls_in_oldest_non_expired_candidates(self):
        self.conn.execute(
            "UPDATE log_retention_config SET retention_days = 30, max_total_size_mb = 1 WHERE config_id = 'default'"
        )
        self.conn.commit()
        now = datetime.now(timezone.utc)
        one_mb = 1024 * 1024
        # both within retention_days=30, but combined over the 1MB cap --
        # the older of the two should get pulled in as a candidate too
        self._insert_closed_log("log-a", (now - timedelta(days=2)).strftime("%Y-%m-%d %H:%M:%S"), size_bytes=int(0.6 * one_mb))
        self._insert_closed_log("log-b", (now - timedelta(days=1)).strftime("%Y-%m-%d %H:%M:%S"), size_bytes=int(0.6 * one_mb))
        result = execution_log.run_log_retention_cleanup(self.conn, now=now)
        self.assertEqual(result["purged"], ["log-a"])
        status_b = self.conn.execute("SELECT status FROM execution_log_file WHERE log_file_id = 'log-b'").fetchone()[0]
        self.assertEqual(status_b, "closed")

    def test_delete_failure_recorded_and_others_still_purged(self):
        old_ts = (datetime.now(timezone.utc) - timedelta(days=2)).strftime("%Y-%m-%d %H:%M:%S")
        self._insert_closed_log("log-ok", old_ts)
        bad_path = self._insert_closed_log("log-bad", old_ts)
        os.remove(bad_path)  # simulate "file missing/inaccessible" -- os.remove will raise
        os.mkdir(bad_path)   # replace it with a directory so os.remove() fails with OSError
        self.addCleanup(lambda: os.path.isdir(bad_path) and os.rmdir(bad_path))

        result = execution_log.run_log_retention_cleanup(self.conn)
        self.assertEqual(result["purged"], ["log-ok"])
        self.assertEqual(result["failed"], ["log-bad"])
        status = self.conn.execute("SELECT status FROM execution_log_file WHERE log_file_id = 'log-bad'").fetchone()[0]
        self.assertEqual(status, "closed")

    def test_missing_config_raises(self):
        self.conn.execute("DELETE FROM log_retention_config")
        self.conn.commit()
        with self.assertRaises(ValueError):
            execution_log.run_log_retention_cleanup(self.conn)


if __name__ == "__main__":
    unittest.main()