railwayapp 5.54.1

Interact with Railway via CLI
import json
import os
from pathlib import Path
import tempfile
import sqlite3
from contextlib import closing
from types import SimpleNamespace
import unittest
from unittest.mock import patch

import remote_threads as threads


class DiscoveryTest(unittest.TestCase):
    def setUp(self):
        self.tmp = tempfile.TemporaryDirectory()
        self.addCleanup(self.tmp.cleanup)
        self.root = Path(self.tmp.name)

    def grok(self, session_id, **fields):
        row = {"info": {"id": session_id, "cwd": "/app/project with spaces"},
               "session_summary": "Original title", "created_at": "2026-09-10T10:00:00Z",
               "updated_at": "2026-09-10T11:00:00Z", "num_messages": 4, **fields}
        path = self.root / "sessions" / "encoded-cwd" / session_id / "summary.json"
        path.parent.mkdir(parents=True, exist_ok=True)
        path.write_text(json.dumps(row))
        return path

    def test_primary_agent_comes_from_vm_metadata_not_user_preferences(self):
        with patch.object(threads.Path, "home", return_value=self.root):
            self.assertIsNone(threads.primary_harness())
            marker = self.root / ".railway-code-agent"
            for value, expected in [("codex\n", "codex"), ("railway-agent-tui", "railway"),
                                    ("opencode2", "opencode2"), ("unknown", None),
                                    ("codex; touch /tmp/never-run", None)]:
                marker.write_text(value)
                self.assertEqual(threads.primary_harness(), expected)

    def test_grok_history_includes_exited_threads_and_visible_forks(self):
        self.grok("renamed", generated_title="My renamed thread", title_is_manual=True)
        self.grok("hidden-child", session_kind="subagent_fork")
        self.grok("visible-child", session_kind="subagent", hidden=False)
        self.grok("empty", session_summary="", num_messages=0)
        self.grok("fork", session_summary="", num_messages=0, parent_session_id="renamed")
        self.grok("corrupt").write_text("{truncated")
        with patch.object(threads.subprocess, "run") as run:
            rows = {r["thread"]["id"]: r for r in threads.grok_threads(self.root)}
        self.assertEqual(set(rows), {"renamed", "visible-child", "fork"})
        self.assertEqual(rows["renamed"]["thread"]["title"], "My renamed thread")
        self.assertEqual(rows["renamed"]["thread"]["directory"], "/app/project with spaces")
        self.assertFalse(rows["renamed"].get("active", False))
        run.assert_not_called()

    def test_grok_stale_pids_do_not_attach_and_exact_pane_ids_are_preserved(self):
        self.grok("live")
        self.grok("exited")
        (self.root / "active_sessions.json").write_text(json.dumps([
            {"session_id": "live", "pid": 123}, {"session_id": "exited", "pid": 456}]))
        with patch.object(threads, "alive", side_effect=lambda pid: pid == 123), patch.object(
            threads, "process_environment", return_value={
                "RAILWAY_THREAD_PANE_ID": "pane-123", "RAILWAY_DURABLE_SESSION_NAME": "relay-123"}
        ):
            rows = {r["thread"]["id"]: r for r in threads.grok_threads(self.root)}
        self.assertEqual(rows["live"]["pane_id"], "pane-123")
        self.assertEqual(rows["live"]["console_name"], "relay-123")
        self.assertNotIn("console_name", rows["exited"])

    def test_claude_sdk_history_merges_status_by_uuid_not_background_id(self):
        saved = SimpleNamespace(session_id="full-uuid", custom_title="My title", summary="Auto title",
                                first_prompt="Prompt", cwd="/app/other", created_at=1000, last_modified=2000)
        def list_sessions():
            self.assertEqual(os.environ["CLAUDE_CONFIG_DIR"], str(self.root))
            return [saved]
        sdk = SimpleNamespace(list_sessions=list_sessions)
        status = [{"sessionId": "full-uuid", "id": "short-id", "kind": "background", "state": "blocked",
                   "status": "waiting", "pid": 123}]
        with patch.object(threads.shutil, "which", return_value="/bin/claude"), patch.object(
            threads.subprocess, "run", return_value=SimpleNamespace(stdout=json.dumps(status))
        ) as run, patch.object(threads, "live_identity", return_value={"active": True, "pane_id": "exact"}):
            rows = threads.claude_threads(self.root, sdk)
        self.assertEqual(run.call_args.args[0], ["claude", "agents", "--json", "--all"])
        self.assertEqual(rows[0]["thread"]["id"], "full-uuid")
        self.assertEqual(rows[0]["background_id"], "short-id")
        self.assertEqual(rows[0]["thread"]["title"], "My title")
        self.assertEqual(rows[0]["thread"]["state"], "waiting")
        self.assertEqual(rows[0]["thread"]["created_at"], "1970-01-01T00:00:01+00:00")

    def test_native_deletion_is_scoped_and_reports_failures(self):
        self.grok("target")
        self.grok("neighbor")
        def delete(args, environment, directory=None):
            self.assertEqual(args, ["grok", "sessions", "delete", "target"])
            self.assertEqual(environment["GROK_HOME"], str(self.root))
            self.grok("target").unlink()
        with patch.object(threads, "config_roots", return_value={"grok": [self.root]}), patch.object(
            threads, "run_delete_command", side_effect=delete
        ):
            threads.delete_conversation({"harness": "grok", "id": "target"})
        self.assertEqual([r["thread"]["id"] for r in threads.grok_threads(self.root)], ["neighbor"])
        with patch.object(threads, "config_roots", return_value={"grok": [self.root]}), patch.object(
            threads, "run_delete_command", side_effect=RuntimeError("Permission denied")
        ), self.assertRaisesRegex(RuntimeError, "Permission denied"):
            threads.delete_conversation({"harness": "grok", "id": "neighbor"})
        for harness in ("grok", "claude", "codex", "opencode", "opencode2"):
            with patch.object(threads, "stop_consoles") as stop, self.assertRaises(ValueError):
                threads.delete_conversation({"harness": harness, "id": "../neighbor"})
            stop.assert_not_called()

    def test_claude_deletion_uses_sdk_and_restores_config_on_failure(self):
        def delete(session_id):
            self.assertEqual(session_id, "target")
            self.assertEqual(os.environ["CLAUDE_CONFIG_DIR"], str(self.root))
            raise PermissionError("Read-only history")
        with patch.dict(os.environ, {"CLAUDE_CONFIG_DIR": "original"}), patch.object(
            threads, "config_roots", return_value={"claude": [self.root]}
        ), patch.object(threads, "claude_sdk", return_value=SimpleNamespace(delete_session=delete)):
            with self.assertRaises(PermissionError):
                threads.delete_conversation({"harness": "claude", "id": "target"})
            self.assertEqual(os.environ["CLAUDE_CONFIG_DIR"], "original")

    @unittest.skipUnless(os.name == "posix", "VM process signals require POSIX")
    def test_deletion_stops_only_exact_console_processes_and_waits_for_exit(self):
        environments = {101: {"RAILWAY_DURABLE_SESSION_NAME": "exact"},
                        102: {"RAILWAY_DURABLE_SESSION_NAME": "exact-other"},
                        103: {"RAILWAY_DURABLE_SESSION_NAME": "exact"}}
        def terminate(pid, sig):
            self.assertEqual(sig, threads.signal.SIGTERM)
            environments.pop(pid)
        with patch.object(threads.Path, "glob", return_value=[Path(str(i)) for i in environments]), patch.object(
            threads, "process_environment", side_effect=lambda pid: environments.get(pid, {})
        ), patch.object(threads.os, "kill", side_effect=terminate) as kill:
            threads.stop_consoles(["exact"])
        self.assertEqual([c.args[0] for c in kill.call_args_list], [101, 103])
        self.assertEqual(set(environments), {102})

    def test_provider_failure_preserves_other_history(self):
        self.grok("grok-thread")
        (self.root / "projects").mkdir()
        with patch.object(threads, "codex_threads", return_value=[]), patch.object(threads, "opencode_threads", return_value=[]), patch.object(threads, "config_roots", return_value={"claude": [self.root], "grok": [self.root]}), patch.object(
            threads, "claude_sdk", side_effect=RuntimeError("SDK unavailable")
        ):
            result = threads.discover()
        self.assertEqual(result["failed"], ["claude"])
        self.assertEqual(result["threads"][0]["thread"]["id"], "grok-thread")

    def test_codex_metadata_works_without_a_running_backend_or_local_snapshot(self):
        with closing(sqlite3.connect(self.root / "state_5.sqlite")) as db, db:
            db.execute("CREATE TABLE threads (id TEXT, cwd TEXT, title TEXT, name TEXT, created_at INT, updated_at INT, archived INT, source TEXT)")
            db.executemany("INSERT INTO threads VALUES (?,?,?,?,?,?,?,?)", [
                ("real-id", "/app/other", "Original question", "Generated title", 100, 200, 0, "cli"),
                ("archived", "/app", "Hidden", None, 100, 200, 1, "cli"),
                ("child", "/app", "Subagent", None, 100, 200, 0, "subagent"),
            ])
        with patch.dict(os.environ, {"CODEX_HOME": str(self.root)}):
            rows = threads.codex_threads()
        self.assertEqual(len(rows), 1)
        self.assertEqual(rows[0]["thread"]["id"], "real-id")
        self.assertEqual(rows[0]["thread"]["title"], "Generated title")

    def test_opencode_versions_are_read_only_and_keep_native_titles_and_directories(self):
        data = self.root / "opencode"
        data.mkdir()
        database = data / "opencode.db"
        with closing(sqlite3.connect(database)) as db, db:
            for table in ("session", "session_v2"):
                db.execute(f"CREATE TABLE {table} (id TEXT, title TEXT, directory TEXT, parent_id TEXT, time_created INT, time_updated INT, time_archived INT)")
                db.executemany(f"INSERT INTO {table} VALUES (?,?,?,?,?,?,?)", [
                    ("saved", "Sacramento weather", "/app/weather", None, 1000, 2000, None),
                    ("draft", "New session - 2026-09-10", "/app", None, 1000, 1000, None),
                    ("child", "Subagent", "/app", "saved", 1000, 2000, None),
                    ("archived", "Hidden", "/app", None, 1000, 2000, 3000),
                ])
        before = database.read_bytes()
        with patch.dict(os.environ, {"XDG_DATA_HOME": str(self.root), "OPENCODE_DB": ""}):
            rows = threads.opencode_threads()
        self.assertEqual(database.read_bytes(), before)
        self.assertEqual(len(rows), 4)
        self.assertEqual({row["harness"] for row in rows}, {"opencode", "opencode2"})
        for row in rows:
            self.assertEqual(row["database"], str(database))
            if row["thread"]["id"] == "saved":
                self.assertEqual(row["thread"]["title"], "Sacramento weather")
                self.assertEqual(row["thread"]["directory"], "/app/weather")
            else:
                self.assertEqual(row["thread"]["title"], "New Thread")


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