rig-cli 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. rig/__init__.py +6 -0
  2. rig/__main__.py +8 -0
  3. rig/cli.py +107 -0
  4. rig/commands/__init__.py +23 -0
  5. rig/commands/check.py +125 -0
  6. rig/commands/common.py +149 -0
  7. rig/commands/dispatch.py +87 -0
  8. rig/commands/down/__init__.py +139 -0
  9. rig/commands/down/runner.py +136 -0
  10. rig/commands/init.py +148 -0
  11. rig/commands/logs.py +146 -0
  12. rig/commands/prune.py +142 -0
  13. rig/commands/ps.py +149 -0
  14. rig/commands/status.py +149 -0
  15. rig/commands/up/__init__.py +111 -0
  16. rig/commands/up/context.py +36 -0
  17. rig/commands/up/loop.py +150 -0
  18. rig/commands/up/relink.py +88 -0
  19. rig/commands/up/rollback.py +49 -0
  20. rig/commands/up/runner.py +118 -0
  21. rig/commands/up/service.py +136 -0
  22. rig/compose/__init__.py +1 -0
  23. rig/compose/client.py +144 -0
  24. rig/compose/context.py +56 -0
  25. rig/compose/discovery.py +121 -0
  26. rig/compose/docker.py +117 -0
  27. rig/compose/starter.py +145 -0
  28. rig/compose/stopper.py +71 -0
  29. rig/compose/supervisor.py +78 -0
  30. rig/core/__init__.py +1 -0
  31. rig/core/constants.py +65 -0
  32. rig/core/env.py +83 -0
  33. rig/core/errors.py +74 -0
  34. rig/core/identity.py +141 -0
  35. rig/core/locks.py +112 -0
  36. rig/core/state.py +150 -0
  37. rig/core/terminal.py +145 -0
  38. rig/manifest/__init__.py +1 -0
  39. rig/manifest/detector.py +138 -0
  40. rig/manifest/inspect.py +18 -0
  41. rig/manifest/loader.py +146 -0
  42. rig/manifest/models.py +129 -0
  43. rig/manifest/parser.py +123 -0
  44. rig/manifest/schema.py +89 -0
  45. rig/net/__init__.py +1 -0
  46. rig/net/health.py +76 -0
  47. rig/net/ports.py +141 -0
  48. rig/net/probe.py +56 -0
  49. rig/net/registry.py +132 -0
  50. rig/parser.py +72 -0
  51. rig/proc/__init__.py +1 -0
  52. rig/proc/process.py +133 -0
  53. rig/proc/record.py +54 -0
  54. rig/proc/spawn.py +130 -0
  55. rig/proc/teardown.py +139 -0
  56. rig_cli-1.0.0.dist-info/METADATA +503 -0
  57. rig_cli-1.0.0.dist-info/RECORD +59 -0
  58. rig_cli-1.0.0.dist-info/WHEEL +4 -0
  59. rig_cli-1.0.0.dist-info/entry_points.txt +3 -0
rig/net/ports.py ADDED
@@ -0,0 +1,141 @@
1
+ """Loopback socket allocation, port reservation, and sticky lease computation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import contextlib
6
+ import socket
7
+ import time
8
+ from collections.abc import Mapping, Sequence
9
+ from typing import Any
10
+
11
+ from rig.core.constants import PORT_MAX, PORT_MIN, PORT_RELEASE_TIMEOUT_SECS
12
+ from rig.net.registry import (
13
+ get_allocated_ports_for_others,
14
+ get_or_allocate_port,
15
+ port_is_free,
16
+ )
17
+
18
+ DEFAULT_PORT_WINDOW = 50
19
+
20
+ _BASE_PORT_RULES = (
21
+ (("front", "web", "ui", "client", "next", "vite"), 3000),
22
+ (("back", "api", "server", "app", "worker"), 8000),
23
+ (("doc", "storybook", "admin"), 4000),
24
+ )
25
+
26
+
27
+ def _bind_candidate_port(port: int) -> tuple[socket.socket, int] | None:
28
+ if port < PORT_MIN or port > PORT_MAX:
29
+ return None
30
+ listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
31
+ try:
32
+ listener.bind(("127.0.0.1", port))
33
+ listener.listen(socket.SOMAXCONN)
34
+ except OSError:
35
+ listener.close()
36
+ return None
37
+ else:
38
+ return listener, port
39
+
40
+
41
+ def _find_candidate_listener(
42
+ candidate_ports: Sequence[int],
43
+ ) -> tuple[socket.socket, int] | None:
44
+ return next((r for p in candidate_ports if (r := _bind_candidate_port(p)) is not None), None)
45
+
46
+
47
+ def allocate_listener(candidate_ports: Sequence[int] | None = None) -> tuple[socket.socket, int]:
48
+ """Bind and listen on a loopback port, keeping ownership of it."""
49
+ if candidate_ports and (candidate := _find_candidate_listener(candidate_ports)) is not None:
50
+ return candidate
51
+ listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
52
+ try:
53
+ listener.bind(("127.0.0.1", 0))
54
+ listener.listen(socket.SOMAXCONN)
55
+ except BaseException:
56
+ listener.close()
57
+ raise
58
+ return listener, listener.getsockname()[1]
59
+
60
+
61
+ def _safe_allocate_listener(
62
+ candidate_ports: Sequence[int] | None = None,
63
+ ) -> tuple[socket.socket, int]:
64
+ try:
65
+ return allocate_listener(candidate_ports) if candidate_ports else allocate_listener()
66
+ except TypeError:
67
+ return allocate_listener()
68
+
69
+
70
+ def reserve_port(candidate_ports: Sequence[int] | None = None) -> int:
71
+ """Return a currently free loopback port for a service that cannot inherit a socket."""
72
+ listener, port = _safe_allocate_listener(candidate_ports)
73
+ listener.close()
74
+ return port
75
+
76
+
77
+ def _safe_reserve_port(candidate_ports: Sequence[int] | None = None) -> int:
78
+ try:
79
+ return reserve_port(candidate_ports) if candidate_ports else reserve_port()
80
+ except TypeError:
81
+ return reserve_port()
82
+
83
+
84
+ def wait_for_port_release(port: int, timeout: float = PORT_RELEASE_TIMEOUT_SECS) -> bool:
85
+ deadline = time.monotonic() + timeout
86
+ while True:
87
+ if port_is_free(port):
88
+ return True
89
+ if time.monotonic() >= deadline:
90
+ return False
91
+ time.sleep(0.05)
92
+
93
+
94
+ def default_base_port_for_service(name: str) -> int:
95
+ """Return a human-friendly default base port based on service name conventions."""
96
+ slug = name.lower()
97
+ return next((port for keys, port in _BASE_PORT_RULES if any(k in slug for k in keys)), 5000)
98
+
99
+
100
+ def _collect_excluded_ports(
101
+ state: Mapping[str, Any],
102
+ avoid: set[int] | None = None,
103
+ target: tuple[str, str] | None = None,
104
+ ) -> set[int]:
105
+ excluded = set(avoid or ())
106
+ for srec in state.get("services", {}).values():
107
+ if isinstance(srec, dict) and srec.get("port"):
108
+ with contextlib.suppress(ValueError, TypeError):
109
+ excluded.add(int(srec["port"]))
110
+ if target is not None:
111
+ excluded.update(get_allocated_ports_for_others(target))
112
+ return excluded
113
+
114
+
115
+ def _resolve_base_port(service: Any, state: Mapping[str, Any]) -> int:
116
+ pref = getattr(service, "preferred_port", None)
117
+ if pref is not None and PORT_MIN <= pref <= PORT_MAX:
118
+ return pref
119
+ project = state.get("project") or getattr(service, "project", None)
120
+ if project:
121
+ base = default_base_port_for_service(service.name)
122
+ return get_or_allocate_port((str(project), service.name), base)
123
+ leased = state.get("ports", {}).get(service.name)
124
+ if leased is not None and isinstance(leased, int) and PORT_MIN <= leased <= PORT_MAX:
125
+ return leased
126
+ return default_base_port_for_service(service.name)
127
+
128
+
129
+ def compute_candidate_ports(
130
+ service: Any,
131
+ state: Mapping[str, Any],
132
+ avoid: set[int] | None = None,
133
+ ) -> list[int]:
134
+ """Compute an ordered list of candidate ports for a service."""
135
+ project = state.get("project") or getattr(service, "project", None)
136
+ sname = getattr(service, "name", None)
137
+ target = (str(project), sname) if project and sname else None
138
+ excluded = _collect_excluded_ports(state, avoid, target)
139
+ base_port = _resolve_base_port(service, state)
140
+ limit = min(base_port + DEFAULT_PORT_WINDOW, PORT_MAX + 1)
141
+ return [port for port in range(base_port, limit) if port not in excluded]
rig/net/probe.py ADDED
@@ -0,0 +1,56 @@
1
+ """Port listener inspection and process group querying."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import shutil
7
+ import subprocess
8
+ from collections.abc import Sequence
9
+
10
+
11
+ def run_query_command(cmd: Sequence[str], timeout: float = 1.0) -> str | None:
12
+ """Run a query subprocess safely, returning stdout if successful."""
13
+ try:
14
+ proc = subprocess.run(
15
+ cmd,
16
+ capture_output=True,
17
+ text=True,
18
+ check=False,
19
+ timeout=timeout,
20
+ )
21
+ except (subprocess.SubprocessError, OSError):
22
+ return None
23
+ if proc.returncode != 0 or not proc.stdout.strip():
24
+ return None
25
+ return proc.stdout.strip()
26
+
27
+
28
+ def _matches_pgid(p: int, pgid: int) -> bool:
29
+ try:
30
+ return os.getpgid(p) == pgid
31
+ except (ProcessLookupError, PermissionError, OSError):
32
+ return False
33
+
34
+
35
+ def _is_pid_owned(p: int, pid: int | None, pgid: int | None) -> bool:
36
+ if pid is not None and p == pid:
37
+ return True
38
+ return bool(pgid is not None and _matches_pgid(p, pgid))
39
+
40
+
41
+ def _get_listener_pids(port: int) -> list[int]:
42
+ lsof_bin = shutil.which("lsof")
43
+ if not lsof_bin:
44
+ return []
45
+ output = run_query_command([lsof_bin, "-nP", f"-iTCP:{port}", "-sTCP:LISTEN", "-t"])
46
+ if not output:
47
+ return []
48
+ return [int(line.strip()) for line in output.splitlines() if line.strip().isdigit()]
49
+
50
+
51
+ def port_listener_matches(port: int, pgid: int | None = None, pid: int | None = None) -> bool:
52
+ """Return ``True`` only when a listener on ``port`` belongs to ``pid`` or ``pgid``."""
53
+ if pid is None and pgid is None:
54
+ return False
55
+ pids = _get_listener_pids(port)
56
+ return bool(pids) and all(_is_pid_owned(p, pid, pgid) for p in pids)
rig/net/registry.py ADDED
@@ -0,0 +1,132 @@
1
+ """Centralized machine-wide port registry and reservation management."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import socket
8
+ import tempfile
9
+ from collections.abc import Mapping
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ from rig.core.constants import DIR_MODE_PRIVATE, FILE_MODE_PRIVATE, PORT_MAX, PORT_MIN
14
+ from rig.core.identity import get_state_home
15
+ from rig.core.locks import exclusive_lock
16
+
17
+ PORT_REGISTRY_FILE = "ports.json"
18
+ PORT_LOCK_FILE = "ports.lock"
19
+
20
+
21
+ def port_is_free(port: int) -> bool:
22
+ """Return True when nothing is listening on port on loopback."""
23
+ probe = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
24
+ with probe:
25
+ probe.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
26
+ try:
27
+ probe.bind(("127.0.0.1", port))
28
+ except OSError:
29
+ return False
30
+ return True
31
+
32
+
33
+ def _ensure_dir(filename: str) -> Path:
34
+ home = get_state_home()
35
+ home.mkdir(parents=True, mode=DIR_MODE_PRIVATE, exist_ok=True)
36
+ return home / filename
37
+
38
+
39
+ def read_port_registry(path: Path | None = None) -> dict[str, Any]:
40
+ """Read central port registry, returning empty schema on error."""
41
+ target = path or _ensure_dir(PORT_REGISTRY_FILE)
42
+ try:
43
+ data = json.loads(target.read_text())
44
+ if isinstance(data, dict):
45
+ allocs = data.get("allocations")
46
+ data["allocations"] = allocs if isinstance(allocs, dict) else {}
47
+ return data
48
+ except (OSError, json.JSONDecodeError):
49
+ pass
50
+ return {"version": 1, "allocations": {}}
51
+
52
+
53
+ def _write_port_registry(target: Path, data: Mapping[str, Any]) -> None:
54
+ target.parent.mkdir(parents=True, mode=DIR_MODE_PRIVATE, exist_ok=True)
55
+ with tempfile.NamedTemporaryFile(
56
+ mode="w", dir=target.parent, delete=False, prefix=".ports.tmp."
57
+ ) as handle:
58
+ json.dump(data, handle, indent=2, sort_keys=True)
59
+ handle.write("\n")
60
+ handle.flush()
61
+ os.fsync(handle.fileno())
62
+ tmp = handle.name
63
+ os.chmod(tmp, FILE_MODE_PRIVATE)
64
+ os.replace(tmp, target)
65
+
66
+
67
+ def allocation_key(project: str, service_name: str) -> str:
68
+ return f"{project}:{service_name}"
69
+
70
+
71
+ def _find_free_port(base_port: int, window: int, used_ports: set[int]) -> int:
72
+ limit = min(base_port + window, PORT_MAX + 1)
73
+ for cand in range(base_port, limit):
74
+ if cand not in used_ports and port_is_free(cand):
75
+ return cand
76
+ for cand in range(limit, PORT_MAX + 1):
77
+ if cand not in used_ports and port_is_free(cand):
78
+ return cand
79
+ return base_port
80
+
81
+
82
+ def get_or_allocate_port(target: tuple[str, str], base_port: int, window: int = 50) -> int:
83
+ """Return assigned port from central registry, allocating next free if absent."""
84
+ project, service_name = target
85
+ key = allocation_key(project, service_name)
86
+ reg_path, lock_path = _ensure_dir(PORT_REGISTRY_FILE), _ensure_dir(PORT_LOCK_FILE)
87
+ with exclusive_lock(lock_path):
88
+ data = read_port_registry(reg_path)
89
+ allocs = data["allocations"]
90
+ if key in allocs:
91
+ assigned = allocs[key]
92
+ if isinstance(assigned, int) and PORT_MIN <= assigned <= PORT_MAX:
93
+ return assigned
94
+ used = {int(p) for p in allocs.values() if isinstance(p, int) and PORT_MIN <= p <= PORT_MAX}
95
+ port = _find_free_port(base_port, window, used)
96
+ allocs[key] = port
97
+ _write_port_registry(reg_path, data)
98
+ return port
99
+
100
+
101
+ def get_allocated_ports_for_others(target: tuple[str, str]) -> set[int]:
102
+ """Return set of ports allocated to other projects or services."""
103
+ project, service_name = target
104
+ key = allocation_key(project, service_name)
105
+ data = read_port_registry(_ensure_dir(PORT_REGISTRY_FILE))
106
+ allocs = data.get("allocations", {})
107
+ return {
108
+ int(p)
109
+ for k, p in allocs.items()
110
+ if k != key and isinstance(p, int) and PORT_MIN <= p <= PORT_MAX
111
+ }
112
+
113
+
114
+ def release_port_allocation(project: str, service_name: str | None = None) -> list[int]:
115
+ """Release port allocation for a project or specific service."""
116
+ reg_path, lock_path = _ensure_dir(PORT_REGISTRY_FILE), _ensure_dir(PORT_LOCK_FILE)
117
+ with exclusive_lock(lock_path):
118
+ data = read_port_registry(reg_path)
119
+ allocs = data.get("allocations", {})
120
+ prefix = f"{project}:" if service_name is None else f"{project}:{service_name}"
121
+ released = [
122
+ allocs.pop(k)
123
+ for k in list(allocs.keys())
124
+ if k == prefix or (service_name is None and k.startswith(prefix))
125
+ ]
126
+ if released:
127
+ _write_port_registry(reg_path, data)
128
+ return released
129
+
130
+
131
+ def list_port_allocations() -> dict[str, int]:
132
+ return dict(read_port_registry(_ensure_dir(PORT_REGISTRY_FILE)).get("allocations", {}))
rig/parser.py ADDED
@@ -0,0 +1,72 @@
1
+ """CLI argument parser setup and error handling for rig."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import sys
7
+ from typing import Any
8
+
9
+ from rig.core.constants import EXIT_USAGE, __version__
10
+ from rig.core.errors import RigError, print_json_error
11
+
12
+
13
+ class RigArgumentParser(argparse.ArgumentParser):
14
+ """ArgumentParser that outputs structured JSON errors when requested."""
15
+
16
+ def __init__(self, *args: Any, as_json: bool = False, **kwargs: Any) -> None:
17
+ super().__init__(*args, **kwargs)
18
+ self.as_json = as_json
19
+
20
+ def error(self, message: str) -> None:
21
+ if self.as_json:
22
+ err = RigError(message, code="E_USAGE", exit_code=EXIT_USAGE)
23
+ print_json_error(err, command="cli")
24
+ sys.exit(EXIT_USAGE)
25
+ super().error(message)
26
+
27
+
28
+ def _add_service_parsers(sub: Any) -> None:
29
+ p_up = sub.add_parser("up", help="start services")
30
+ p_up.add_argument("--scope", default="full")
31
+ p_up.add_argument("--mode", default=None)
32
+ p_up.add_argument("--switch", action="store_true")
33
+
34
+ p_down = sub.add_parser("down", help="stop services")
35
+ p_down.add_argument("target", nargs="?", default=None)
36
+ p_down.add_argument("--all", action="store_true", dest="all_instances")
37
+ p_down.add_argument("--scope", default="full")
38
+
39
+
40
+ def _add_admin_parsers(sub: Any) -> None:
41
+ sub.add_parser("status", help="status")
42
+ p_ps = sub.add_parser("ps", aliases=["ls", "list"], help="ps")
43
+ p_ps.add_argument("--health", action="store_true")
44
+ p_ps.add_argument("-w", "--wide", action="store_true")
45
+ sub.add_parser("prune", help="prune").add_argument("--force", action="store_true")
46
+ sub.add_parser("check", help="check").add_argument("--mode", default=None)
47
+ p_init = sub.add_parser("init", help="init")
48
+ p_init.add_argument("--dry-run", action="store_true")
49
+ p_init.add_argument("--force", action="store_true")
50
+ p_init.add_argument("--up", action="store_true")
51
+ sub.add_parser("schema", help="schema")
52
+ p_logs = sub.add_parser("logs", help="logs")
53
+ p_logs.add_argument("service", nargs="?", default=None, help="service name")
54
+ p_logs.add_argument("-n", "--tail", type=int, default=50, help="number of lines")
55
+ p_logs.add_argument("--mode", default=None, help="mode overlay")
56
+
57
+
58
+ def build_parser(as_json: bool = False) -> argparse.ArgumentParser:
59
+ """Construct the command line parser for rig."""
60
+ parser = RigArgumentParser(prog="rig", as_json=as_json)
61
+ parser.add_argument("-v", "--version", action="version", version=f"%(prog)s {__version__}")
62
+ parser.add_argument("--root", default=None)
63
+ parser.add_argument("--manifest", default=None)
64
+ parser.add_argument("--json", action="store_true")
65
+ sub = parser.add_subparsers(
66
+ dest="command",
67
+ required=True,
68
+ parser_class=lambda **kw: RigArgumentParser(as_json=as_json, **kw),
69
+ )
70
+ _add_service_parsers(sub)
71
+ _add_admin_parsers(sub)
72
+ return parser
rig/proc/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """Process lifecycle, spawning, inspection, and teardown for rig."""
rig/proc/process.py ADDED
@@ -0,0 +1,133 @@
1
+ """Process identity inspection and liveness verification."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import shutil
7
+ import subprocess
8
+ import time
9
+ from collections.abc import Mapping
10
+ from typing import Any
11
+
12
+ _OWN_CHILDREN: dict[int, subprocess.Popen] = {}
13
+
14
+
15
+ def _ps(pid: int, fields: str) -> str | None:
16
+ try:
17
+ completed = subprocess.run(
18
+ ["ps", "-ww", "-p", str(pid), "-o", fields],
19
+ capture_output=True,
20
+ text=True,
21
+ timeout=10,
22
+ )
23
+ except (OSError, subprocess.SubprocessError):
24
+ return None
25
+ if completed.returncode != 0:
26
+ return None
27
+ line = completed.stdout.strip()
28
+ return line or None
29
+
30
+
31
+ def process_start_time(pid: int) -> str | None:
32
+ """Return the kernel-reported start time of ``pid``."""
33
+ value = _ps(pid, "lstart=")
34
+ return " ".join(value.split()) if value else None
35
+
36
+
37
+ def process_args(pid: int) -> str | None:
38
+ value = _ps(pid, "args=")
39
+ return " ".join(value.split()) if value else None
40
+
41
+
42
+ def stable_process_args(pid: int, settle: float = 1.0, interval: float = 0.06) -> str | None:
43
+ """Return the command line once two consecutive reads agree."""
44
+ previous = process_args(pid)
45
+ deadline = time.monotonic() + max(settle, 0.0)
46
+ while time.monotonic() < deadline:
47
+ time.sleep(interval)
48
+ current = process_args(pid)
49
+ if current is None or current == previous:
50
+ return current if current is not None else previous
51
+ previous = current
52
+ return previous
53
+
54
+
55
+ def _is_zombie(pid: int) -> bool:
56
+ state = _ps(pid, "state=")
57
+ return bool(state) and state.lstrip().upper().startswith("Z")
58
+
59
+
60
+ def _collect(pid: int) -> bool:
61
+ """Reap a child this process started. Returns ``True`` when it has exited."""
62
+ child = _OWN_CHILDREN.get(pid)
63
+ if child is None or child.poll() is None:
64
+ return False
65
+ _OWN_CHILDREN.pop(pid, None)
66
+ return True
67
+
68
+
69
+ def _is_live_signal(pid: int) -> bool:
70
+ try:
71
+ os.kill(pid, 0)
72
+ except ProcessLookupError:
73
+ return False
74
+ except PermissionError:
75
+ return True
76
+ else:
77
+ return True
78
+
79
+
80
+ def pid_alive(pid: int) -> bool:
81
+ """Return ``True`` when ``pid`` names a live, non-zombie process."""
82
+ if _collect(pid) or not _is_live_signal(pid):
83
+ return False
84
+ if _is_zombie(pid):
85
+ _collect(pid)
86
+ return False
87
+ return True
88
+
89
+
90
+ def resolve_binary(argv0: str) -> str:
91
+ """Return the canonical executable path for ``argv0``."""
92
+ if os.sep in argv0:
93
+ return os.path.realpath(argv0)
94
+ found = shutil.which(argv0)
95
+ return os.path.realpath(found) if found else argv0
96
+
97
+
98
+ def _binary_matches(recorded: str, observed_argv0: str) -> bool:
99
+ if os.sep in observed_argv0:
100
+ return os.path.realpath(observed_argv0) == recorded
101
+ return os.path.basename(recorded).startswith(observed_argv0)
102
+
103
+
104
+ def identity_baseline(record: Mapping[str, Any]) -> str:
105
+ """Return the command line that ``pid`` must still report to be considered ours."""
106
+ observed = record.get("identity")
107
+ if observed:
108
+ return str(observed)
109
+ argv = record.get("argv") or []
110
+ return " ".join(str(item) for item in argv)
111
+
112
+
113
+ def _record_fields_valid(record: Mapping[str, Any], baseline: str) -> bool:
114
+ pid = record.get("pid")
115
+ return (
116
+ isinstance(pid, int)
117
+ and pid > 0
118
+ and bool(record.get("start_time") and baseline and record.get("binary"))
119
+ )
120
+
121
+
122
+ def identity_matches(record: Mapping[str, Any]) -> bool:
123
+ """Return ``True`` only when ``pid`` still runs exactly the recorded program."""
124
+ baseline = identity_baseline(record)
125
+ if not _record_fields_valid(record, baseline):
126
+ return False
127
+ pid = int(record["pid"])
128
+ if not pid_alive(pid) or process_start_time(pid) != record.get("start_time"):
129
+ return False
130
+ observed = process_args(pid)
131
+ if not observed or observed != baseline:
132
+ return False
133
+ return _binary_matches(str(record.get("binary")), observed.split()[0])
rig/proc/record.py ADDED
@@ -0,0 +1,54 @@
1
+ """Process record construction and metadata formatting."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import subprocess
7
+ import time
8
+ from collections.abc import Mapping
9
+ from typing import Any
10
+
11
+ from rig.proc.process import process_start_time, resolve_binary, stable_process_args
12
+
13
+
14
+ def build_record(
15
+ proc: subprocess.Popen,
16
+ name: str,
17
+ meta: tuple[str, list[str], int | None, Mapping[str, Any] | None],
18
+ ) -> dict[str, Any]:
19
+ """Construct structured process record dictionary."""
20
+ kind, resolved, port, extra = meta
21
+ observed = stable_process_args(proc.pid)
22
+ argv0 = observed.split()[0] if observed else str(resolved[0])
23
+ try:
24
+ pgid = os.getpgid(proc.pid)
25
+ except ProcessLookupError:
26
+ pgid = proc.pid
27
+ rec = {
28
+ "name": name,
29
+ "type": kind,
30
+ "pid": proc.pid,
31
+ "pgid": pgid,
32
+ "binary": resolve_binary(argv0),
33
+ "argv": list(resolved),
34
+ "identity": observed,
35
+ "start_time": process_start_time(proc.pid),
36
+ "port": port,
37
+ "url": f"http://127.0.0.1:{port}" if port else None,
38
+ "started_at": time.strftime("%Y-%m-%dT%H:%M:%S%z"),
39
+ }
40
+ return {**rec, **(extra or {})}
41
+
42
+
43
+ def _record(
44
+ name: str,
45
+ kind: str,
46
+ proc: subprocess.Popen,
47
+ *args: Any,
48
+ **kwargs: Any,
49
+ ) -> dict[str, Any]:
50
+ argv = list(args)
51
+ resolved = kwargs.get("argv") or (argv.pop(0) if argv else [])
52
+ port = kwargs.get("port") if "port" in kwargs else (argv.pop(0) if argv else None)
53
+ extra = kwargs.get("extra") if "extra" in kwargs else (argv.pop(0) if argv else None)
54
+ return build_record(proc, name, (kind, resolved, port, extra))