manta-node 0.6b5.dev697__py3-none-any.whl → 0.6b5.dev702__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.
@@ -4,7 +4,7 @@ This file is auto-generated at build time by scripts/generate_version.py.
4
4
  Do not edit manually.
5
5
  """
6
6
 
7
- __version__ = "2026.09.22.8a098e97"
8
- __build_date__ = "2026.09.22"
9
- __commit_id__ = "8a098e97"
7
+ __version__ = "2026.09.24.10597326"
8
+ __build_date__ = "2026.09.24"
9
+ __commit_id__ = "10597326"
10
10
  __package_version__ = "0.6b5"
@@ -2,88 +2,64 @@
2
2
 
3
3
  import argparse
4
4
  import contextlib
5
- import json
6
- import os
7
5
  from datetime import datetime
8
- from pathlib import Path
9
6
 
10
7
  import psutil
11
8
  from rich.console import Console
12
9
  from rich.table import Table
13
10
 
11
+ from ...supervisor.records import list_instances
12
+
14
13
  console = Console()
15
14
 
16
15
 
17
16
  def find_running_instances():
18
- """Find running node instances by looking for instance JSON files."""
17
+ """Running instances for display, with the supervisor record read-through.
19
18
 
20
- instances_dir = Path.home() / ".manta" / "nodes" / "instances"
19
+ Under a supervisor the pid of record is the supervisor's, and only the
20
+ supervisor record exists (the child runs with ``--subprocess``); without one
21
+ it is the node's own instance record. ``list_instances`` resolves both.
22
+ """
21
23
  instances = []
22
-
23
- if not instances_dir.exists():
24
- return instances
25
-
26
- for instance_file in instances_dir.glob("*.json"):
24
+ for record in list_instances():
25
+ pid = record["pid"]
26
+ # The workload is the child under a supervisor, the node itself without
27
+ # one: `status` shows its cpu/memory, while `pid` stays the pid a stop
28
+ # signals (the supervisor's, when present).
29
+ workload_pid = record.get("workload_pid") or pid
30
+ cpu_percent = None
31
+ memory_mb = None
32
+ create_time = None
33
+ status = "running"
27
34
  try:
28
- with open(instance_file, "r") as f:
29
- instance_data = json.load(f)
30
-
31
- pid = instance_data.get("pid")
32
- instance_id = instance_data.get("instance_id", instance_file.stem)
33
-
34
- # Check if process is still running
35
- try:
36
- # Send signal 0 to check if process exists
37
-
38
- os.kill(pid, 0)
39
-
40
- # Get process info if psutil is available
41
- try:
42
- process = psutil.Process(pid)
43
- cpu_percent = process.cpu_percent()
44
- memory_mb = process.memory_info().rss // (1024 * 1024)
45
- create_time = datetime.fromtimestamp(process.create_time())
46
- status = "running"
47
- except ImportError:
48
- cpu_percent = None
49
- memory_mb = None
50
- create_time = None
51
- status = "running"
52
- except psutil.NoSuchProcess:
53
- # Process disappeared
54
- instance_file.unlink(missing_ok=True)
55
- continue
56
-
57
- # Add additional info from JSON
58
- alias = instance_data.get("alias", instance_id)
59
- config_name = instance_data.get("config_name", "unknown")
60
- start_time_str = instance_data.get("start_time")
61
- if start_time_str and not create_time:
62
- with contextlib.suppress(ValueError, TypeError):
63
- create_time = datetime.fromisoformat(start_time_str)
64
-
65
- instances.append(
66
- {
67
- "instance_id": instance_id,
68
- "alias": alias,
69
- "config_name": config_name,
70
- "pid": pid,
71
- "status": status,
72
- "cpu_percent": cpu_percent,
73
- "memory_mb": memory_mb,
74
- "start_time": create_time,
75
- "instance_file": instance_file,
76
- }
77
- )
78
-
79
- except OSError:
80
- # Process no longer exists, remove stale instance file
81
- instance_file.unlink(missing_ok=True)
82
-
83
- except (ValueError, FileNotFoundError):
84
- # Invalid instance file, remove it
85
- instance_file.unlink(missing_ok=True)
86
-
35
+ process = psutil.Process(workload_pid)
36
+ cpu_percent = process.cpu_percent()
37
+ memory_mb = process.memory_info().rss // (1024 * 1024)
38
+ create_time = datetime.fromtimestamp(process.create_time())
39
+ except ImportError:
40
+ pass
41
+ except psutil.NoSuchProcess:
42
+ # Disappeared between the liveness check and here; skip it.
43
+ continue
44
+
45
+ start_time_str = record.get("start_time")
46
+ if start_time_str and not create_time:
47
+ with contextlib.suppress(ValueError, TypeError):
48
+ create_time = datetime.fromisoformat(start_time_str)
49
+
50
+ instances.append(
51
+ {
52
+ "instance_id": record["instance_id"],
53
+ "alias": record["alias"],
54
+ "config_name": record["config_name"],
55
+ "pid": pid,
56
+ "status": status,
57
+ "cpu_percent": cpu_percent,
58
+ "memory_mb": memory_mb,
59
+ "start_time": create_time,
60
+ "instance_file": record["instance_file"],
61
+ }
62
+ )
87
63
  return instances
88
64
 
89
65
 
@@ -1,14 +1,14 @@
1
1
  """Node stop command implementation."""
2
2
 
3
3
  import argparse
4
- import json
5
4
  import os
6
5
  import signal
7
6
  import time
8
- from pathlib import Path
9
7
 
10
8
  from rich.console import Console
11
9
 
10
+ from ...supervisor.records import list_instances
11
+
12
12
  console = Console()
13
13
 
14
14
 
@@ -28,45 +28,23 @@ def print_warning(message: str):
28
28
 
29
29
 
30
30
  def find_running_instances():
31
- """Find running node instances by looking for instance JSON files."""
32
-
33
- instances_dir = Path.home() / ".manta" / "nodes" / "instances"
34
- instances = []
35
-
36
- if not instances_dir.exists():
37
- return instances
38
-
39
- for instance_file in instances_dir.glob("*.json"):
40
- try:
41
- with open(instance_file, "r") as f:
42
- instance_data = json.load(f)
43
-
44
- pid = instance_data.get("pid")
45
- instance_id = instance_data.get("instance_id", instance_file.stem)
46
- alias = instance_data.get("alias", instance_id)
47
-
48
- # Check if process is still running
49
- try:
50
- # Send signal 0 to check if process exists
51
-
52
- os.kill(pid, 0)
53
- instances.append(
54
- {
55
- "instance_id": instance_id,
56
- "alias": alias,
57
- "pid": pid,
58
- "instance_file": instance_file,
59
- }
60
- )
61
- except OSError:
62
- # Process no longer exists, remove stale instance file
63
- instance_file.unlink(missing_ok=True)
64
-
65
- except (ValueError, FileNotFoundError):
66
- # Invalid instance file, remove it
67
- instance_file.unlink(missing_ok=True)
31
+ """Running instances to stop, with the supervisor record read-through.
68
32
 
69
- return instances
33
+ The pid returned for each alias is the **pid of record**: the supervisor's
34
+ when one is present, else the node's own. Signalling it stops the supervisor
35
+ (which winds the child down) or the node directly — never both.
36
+ """
37
+ return [
38
+ {
39
+ "instance_id": record["instance_id"],
40
+ "alias": record["alias"],
41
+ "pid": record["pid"],
42
+ "child_pid": record.get("child_pid"),
43
+ "source": record["source"],
44
+ "instance_file": record["instance_file"],
45
+ }
46
+ for record in list_instances()
47
+ ]
70
48
 
71
49
 
72
50
  def stop_instance_by_pid(
@@ -85,15 +63,53 @@ def stop_instance_by_pid(
85
63
  pid = instance["pid"]
86
64
  instance_id = instance["instance_id"]
87
65
  alias = instance.get("alias", instance_id)
66
+ # A supervised node's pid of record is the SUPERVISOR, and its child leads a
67
+ # SEPARATE process group (`start_new_session=True`), so `killpg` on the
68
+ # supervisor never reaches the child. A bare SIGKILL of the supervisor cannot
69
+ # be forwarded and would orphan the child. So: SIGTERM the supervisor, which
70
+ # forwards to the child and exits; to force, also SIGKILL the recorded child
71
+ # pid, then the supervisor.
72
+ owned_by_supervisor = instance.get("source") == "supervisor"
73
+ child_pid = instance.get("child_pid")
74
+
75
+ def signal_supervisor(signum: int) -> None:
76
+ _signal_pid(pid, signum)
77
+
78
+ def signal_child(signum: int) -> None:
79
+ # The child is its own session: signal the group it leads, so any task
80
+ # grandchildren it spawned go too. Fall back to the pid if the group is
81
+ # gone or not ours.
82
+ if not child_pid:
83
+ return
84
+ try:
85
+ os.killpg(os.getpgid(child_pid), signum)
86
+ except (ProcessLookupError, PermissionError):
87
+ _signal_pid(child_pid, signum)
88
+
89
+ def _signal_pid(target: int | None, signum: int) -> None:
90
+ if not target:
91
+ return
92
+ try:
93
+ os.kill(target, signum)
94
+ except ProcessLookupError:
95
+ pass
88
96
 
89
97
  try:
90
98
  if force:
91
- # Force kill with SIGKILL
92
- os.kill(pid, signal.SIGKILL)
99
+ if owned_by_supervisor:
100
+ # Force: SIGTERM the supervisor (it forwards and exits), then
101
+ # SIGKILL the child directly and the supervisor — neither can be
102
+ # orphaned. A plain SIGKILL of the supervisor alone would orphan
103
+ # the child, which leads its own session.
104
+ signal_supervisor(signal.SIGTERM)
105
+ signal_child(signal.SIGKILL)
106
+ signal_supervisor(signal.SIGKILL)
107
+ else:
108
+ _signal_pid(pid, signal.SIGKILL)
93
109
  console.print(f"Force killed node '{alias}' (PID: {pid})")
94
110
  else:
95
111
  # Graceful shutdown with SIGTERM
96
- os.kill(pid, signal.SIGTERM)
112
+ signal_supervisor(signal.SIGTERM)
97
113
  console.print(f"Sent termination signal to node '{alias}' (PID: {pid})")
98
114
 
99
115
  # Wait for process to terminate gracefully
@@ -111,21 +127,26 @@ def stop_instance_by_pid(
111
127
  console.print(f"[green]Node '{alias}' stopped gracefully[/green]")
112
128
  break
113
129
  else:
114
- # Timeout reached, process still running
130
+ # Timeout reached: force the child and the supervisor, never the
131
+ # supervisor alone (which would orphan the child).
115
132
  print_warning(
116
133
  f"Node '{alias}' did not stop gracefully, sending SIGKILL..."
117
134
  )
118
- os.kill(pid, signal.SIGKILL)
135
+ signal_child(signal.SIGKILL)
136
+ signal_supervisor(signal.SIGKILL)
119
137
  console.print(f"Force killed node '{alias}' after timeout")
120
138
 
121
- # Remove instance file
122
- instance["instance_file"].unlink(missing_ok=True)
139
+ # Only the node's own instance record is the CLI's to remove; a
140
+ # supervisor record is cleared by the supervisor as it exits.
141
+ if not owned_by_supervisor:
142
+ instance["instance_file"].unlink(missing_ok=True)
123
143
  return True
124
144
 
125
145
  except OSError as e:
126
146
  if e.errno == 3: # No such process
127
147
  # Process already dead, just remove instance file
128
- instance["instance_file"].unlink(missing_ok=True)
148
+ if not owned_by_supervisor:
149
+ instance["instance_file"].unlink(missing_ok=True)
129
150
  print_warning(f"Node '{alias}' was already stopped")
130
151
  return True
131
152
  else:
@@ -23,6 +23,12 @@ from manta_common.errors import (
23
23
  from manta_common.retry import RetryPolicy
24
24
  from manta_common.traces import Tracer
25
25
 
26
+ from ...seam import (
27
+ SeamClient,
28
+ SeamError,
29
+ SwitchOutcome,
30
+ UpdateRequest,
31
+ )
26
32
  from ...task_manager import LOG_FLUSH_INTERVAL, LogStream, TaskManager
27
33
  from ...tasks import TaskProgression
28
34
  from ..filesystem.dataset_manager import DatasetManager
@@ -128,8 +134,13 @@ class CommandHandler(Collector, MqttBase):
128
134
  ping_interval: int = TIMEOUT,
129
135
  max_concurrent_tasks: int = 2,
130
136
  task_timeout: int = 3600,
137
+ seam_client: SeamClient | None = None,
131
138
  ) -> None:
132
139
  self.tracer = Tracer(getLogger(__name__))
140
+ # The local seam to a supervisor, when one owns this node (2b). None on a
141
+ # node running without a supervisor: the relay is inert, and the handler
142
+ # behaves exactly as before (2b acceptance: unused ⇒ no change).
143
+ self._seam_client = seam_client
133
144
  self._task_manager = task_runner
134
145
  self.node_client = node_client
135
146
  self.dataset_manager = dataset_manager
@@ -669,6 +680,24 @@ class CommandHandler(Collector, MqttBase):
669
680
  """
670
681
  await self.stop_callback()
671
682
 
683
+ async def relay_update(self, request: UpdateRequest) -> SwitchOutcome | None:
684
+ """Relay an update request to the supervisor over the seam (2b).
685
+
686
+ The node is a **relay**: it holds the control channel and passes the
687
+ declaration to the supervisor, which executes. With no seam client (a
688
+ node running without a supervisor) this is inert and returns None — no
689
+ behaviour change. If the seam is unreachable, the failure is reported and
690
+ nothing is mutated (NF-3): the node does not fall back to an in-place
691
+ install.
692
+ """
693
+ if self._seam_client is None:
694
+ return None
695
+ try:
696
+ return await self._seam_client.request_update(request)
697
+ except SeamError as exc:
698
+ self.tracer.exception(f"Seam relay failed: {exc}")
699
+ raise MantaNodeError(f"Supervisor seam unavailable: {exc}") from exc
700
+
672
701
  async def on_task_setup_failed(self, task: MqttTask, exc: Exception):
673
702
  """
674
703
  Publish a FAILED task update when task setup fails.
manta_node/seam.py ADDED
@@ -0,0 +1,139 @@
1
+ """The local seam — the port between the running node and its supervisor.
2
+
3
+ The seam is the node/domain's **port** (mechanism §6.4): the request/outcome
4
+ interface the running node calls to ask its supervisor to act. The supervisor
5
+ **implements** it with a Unix-socket adapter (`manta_node/supervisor/seam.py`).
6
+ Only the node calls; nothing off-host reaches the supervisor directly.
7
+
8
+ This module is deliberately **dependency-free** (stdlib only, no `manta_node` or
9
+ `manta_common` import): it is the shared interface, and the supervisor imports it
10
+ without taking on any domain logic — the guard in
11
+ `tests/test_supervisor_is_infrastructure.py` carves it out for exactly that
12
+ reason. Keeping it total (messages and the abstract client) means both sides can
13
+ depend on it without a cycle.
14
+
15
+ **Contract** (mechanism §6.4). The request carries `request_id` (idempotency),
16
+ the `target` (a pair or set id — the node computes no ordering), `force`, and the
17
+ `declared_sequence` the Manager passed through. The supervisor answers with a
18
+ `state` and a `reason`. The switch restarts the child, so the outcome across a
19
+ switch is observed **asynchronously** after the node comes back (NF-8); the seam
20
+ carries initiation synchronously and the immediate outcome.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import json
26
+ from dataclasses import dataclass, field
27
+ from enum import Enum
28
+ from pathlib import Path
29
+ from typing import Protocol, runtime_checkable
30
+
31
+ # The socket lives in the node's state dir, never the version tree (NF-6), with
32
+ # 0600 on the socket and its parent. It is local and same-user only (NF-9/NF-10).
33
+ SEAM_SOCKET_NAME = "supervisor.sock"
34
+
35
+
36
+ def seam_socket_path() -> Path:
37
+ """The Unix-socket path for the seam (in the node's state directory)."""
38
+ return Path.home() / ".manta" / SEAM_SOCKET_NAME
39
+
40
+
41
+ class SeamState(str, Enum):
42
+ """The states the supervisor answers with (mechanism §6.4)."""
43
+
44
+ REQUESTED = "requested"
45
+ REFUSED = "refused"
46
+ FETCHING = "fetching"
47
+ VERIFYING = "verifying"
48
+ STAGED = "staged"
49
+ SWITCHING = "switching"
50
+ RESTARTING = "restarting"
51
+ REVERTING = "reverting"
52
+ REPORTING = "reporting"
53
+ REVERTED = "reverted"
54
+ FAILED = "failed"
55
+ DOWN = "down"
56
+
57
+
58
+ @dataclass(frozen=True)
59
+ class UpdateRequest:
60
+ """A request from the running node to its supervisor.
61
+
62
+ ``target`` is the pair (or a set id that resolves to one); the node computes
63
+ no ordering, it passes the declaration through. ``declared_sequence`` is the
64
+ Manager's order, opaque here. ``request_id`` de-duplicates: a repeat of a
65
+ request already in flight or completed is a no-op (NF-5).
66
+ """
67
+
68
+ request_id: str
69
+ target: str
70
+ force: bool = False
71
+ declared_sequence: int | None = None
72
+
73
+ def to_bytes(self) -> bytes:
74
+ return json.dumps(
75
+ {
76
+ "request_id": self.request_id,
77
+ "target": self.target,
78
+ "force": self.force,
79
+ "declared_sequence": self.declared_sequence,
80
+ }
81
+ ).encode()
82
+
83
+ @classmethod
84
+ def from_bytes(cls, raw: bytes) -> UpdateRequest:
85
+ data = json.loads(raw)
86
+ return cls(
87
+ request_id=data["request_id"],
88
+ target=data["target"],
89
+ force=bool(data.get("force", False)),
90
+ declared_sequence=data.get("declared_sequence"),
91
+ )
92
+
93
+
94
+ @dataclass(frozen=True)
95
+ class SwitchOutcome:
96
+ """The supervisor's answer to a request: a state and a reason."""
97
+
98
+ request_id: str
99
+ state: SeamState
100
+ reason: str = ""
101
+ detail: dict = field(default_factory=dict)
102
+
103
+ def to_bytes(self) -> bytes:
104
+ return json.dumps(
105
+ {
106
+ "request_id": self.request_id,
107
+ "state": self.state.value,
108
+ "reason": self.reason,
109
+ "detail": self.detail,
110
+ }
111
+ ).encode()
112
+
113
+ @classmethod
114
+ def from_bytes(cls, raw: bytes) -> SwitchOutcome:
115
+ data = json.loads(raw)
116
+ return cls(
117
+ request_id=data["request_id"],
118
+ state=SeamState(data["state"]),
119
+ reason=data.get("reason", ""),
120
+ detail=data.get("detail", {}),
121
+ )
122
+
123
+
124
+ class SeamError(Exception):
125
+ """The seam could not carry the request (unreachable, malformed, refused)."""
126
+
127
+
128
+ @runtime_checkable
129
+ class SeamClient(Protocol):
130
+ """The port the running node calls. Implemented by the socket adapter."""
131
+
132
+ async def request_update(self, request: UpdateRequest) -> SwitchOutcome:
133
+ """Carry a request to the supervisor and return its immediate outcome.
134
+
135
+ Raises ``SeamError`` if the seam is unreachable: the node reports the
136
+ failure and mutates nothing (NF-3); it does not fall back to an in-place
137
+ install.
138
+ """
139
+ ...
@@ -0,0 +1,11 @@
1
+ """The node supervisor — infrastructure that owns the node child (ADR-0041 B1).
2
+
3
+ The supervisor is a long-lived process that starts ``manta_node start <alias>``
4
+ as its child and owns the child's restart. It is **infrastructure, not domain**:
5
+ it makes no decision about versions, drift, fleets or outcomes, and it imports
6
+ nothing domain-related from ``manta_node`` (pinned by a guard test). Making a
7
+ version current, verifying a candidate and rolling back are later steps (2c/2d);
8
+ this package is the process manager only.
9
+ """
10
+
11
+ __all__ = []
@@ -0,0 +1,210 @@
1
+ """The supervisor process manager — starts the node child and owns its restart.
2
+
3
+ The systemd unit's ``ExecStart`` becomes ``manta-supervisor <alias>``; the
4
+ supervisor starts ``manta_node start <alias>`` as its child, records itself as the
5
+ pid of record, restarts the child under a **bounded** policy, and treats a stop
6
+ signal as **terminal**. Two restart owners never coexist: the unit restarts only
7
+ the supervisor, the supervisor restarts only the child.
8
+
9
+ Spike A (manta-node-python#483) proved the shape this implements; the prototype
10
+ is manta-node-python#484. The guarantee that a supervisor killed outright leaves
11
+ no orphan is the unit's ``KillMode=control-group`` (systemd reaps the cgroup);
12
+ ``start_new_session=True`` below is the belt-and-braces process-group path for a
13
+ supervisor stopped outside systemd.
14
+
15
+ **Placement.** At 2a the supervisor runs from the node's own venv, because the
16
+ child's interpreter *is* the supervisor's there. The migration's "supervisor
17
+ installed outside the node venv" is satisfied at 2c, when the child runs from the
18
+ version tree; ``--python`` (``child_python``) selects that interpreter, so the
19
+ supervisor can then live in its own venv without running the node code from the
20
+ wrong one.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import argparse
26
+ import os
27
+ import signal
28
+ import subprocess
29
+ import sys
30
+ import time
31
+ from pathlib import Path
32
+
33
+ from .records import (
34
+ supervisor_record,
35
+ supervisor_record_path,
36
+ write_json_atomic,
37
+ )
38
+
39
+ # A child that exits more than RESTART_LIMIT times within RESTART_WINDOW_SECONDS
40
+ # is a crash loop: the supervisor stops restarting and exits non-zero, so systemd
41
+ # sees a failed unit rather than an infinitely restarting one.
42
+ RESTART_LIMIT = 5
43
+ RESTART_WINDOW_SECONDS = 120
44
+ RESTART_BACKOFF_SECONDS = 2
45
+
46
+ # Bound on the supervisor's stdout stream. A long-lived supervisor reopens this
47
+ # on every restart, so without a cap a crash loop grows it without limit.
48
+ STDOUT_MAX_BYTES = 10 * 1024 * 1024
49
+
50
+ # How long a terminal stop waits for the child to wind down before SIGKILL.
51
+ STOP_GRACE_SECONDS = 10
52
+
53
+
54
+ def _log_dir() -> Path:
55
+ return Path.home() / ".manta" / "logs" / "nodes"
56
+
57
+
58
+ class Supervisor:
59
+ """Runs one node child, restarts it (bounded), and stops it terminally."""
60
+
61
+ def __init__(
62
+ self, alias: str, config: str, child_python: str | None = None
63
+ ) -> None:
64
+ self.alias = alias
65
+ self.config = config
66
+ # The interpreter that runs the child. Today the supervisor and the node
67
+ # code share one venv, so this defaults to the supervisor's own; at 2c
68
+ # the child runs from the version tree's interpreter, a DIFFERENT venv
69
+ # from the supervisor's, and the unit passes that interpreter here.
70
+ self.child_python = child_python or sys.executable
71
+ self._stop = False
72
+ self._child: subprocess.Popen | None = None
73
+ self._restarts: list[float] = []
74
+
75
+ def _write_record(self, child_pid: int | None, state: str) -> None:
76
+ write_json_atomic(
77
+ supervisor_record_path(self.alias),
78
+ supervisor_record(self.alias, os.getpid(), child_pid, state),
79
+ )
80
+
81
+ def _signal_child(self, signum: int) -> None:
82
+ """Signal the child's process group, falling back to the child alone.
83
+
84
+ The child is spawned with ``start_new_session=True``, so it leads its own
85
+ group; signalling the group takes any grandchildren with it.
86
+ """
87
+ if not self._child or self._child.poll() is not None:
88
+ return
89
+ try:
90
+ os.killpg(os.getpgid(self._child.pid), signum)
91
+ except (ProcessLookupError, PermissionError):
92
+ self._child.send_signal(signum)
93
+
94
+ def _on_signal(self, signum: int, _frame) -> None:
95
+ # A stop is terminal: remember it and forward it. The main loop must not
96
+ # restart once this is set.
97
+ self._stop = True
98
+ self._signal_child(signal.SIGTERM)
99
+
100
+ def _spawn(self) -> subprocess.Popen:
101
+ # `--subprocess` is the existing hidden flag: the child does not write an
102
+ # instance file, so exactly one writer (the supervisor) owns the records.
103
+ # `--foreground` keeps it attached to this process rather than detaching.
104
+ # `--alias` is passed explicitly: `--config` names the TOML to load, and
105
+ # the alias the operator sees must be the supervisor's, not whatever
106
+ # `identity.alias` the TOML happens to carry.
107
+ cmd = [
108
+ self.child_python,
109
+ "-m",
110
+ "manta_node",
111
+ "start",
112
+ self.config,
113
+ "--foreground",
114
+ "--subprocess",
115
+ "--alias",
116
+ self.alias,
117
+ ]
118
+ log_dir = _log_dir()
119
+ log_dir.mkdir(parents=True, exist_ok=True)
120
+ # One writer per path: the supervisor owns the child's stdout/stderr
121
+ # stream. The child's own rotating file log is separate and untouched.
122
+ # A long-lived supervisor reopens this on every restart, so it is bounded
123
+ # like the child's own rotating handler: roll once past the cap, keeping
124
+ # one previous run. Roll BEFORE the open, and never while a writer holds
125
+ # it — a rename does not retarget an already-open fd.
126
+ stdout_path = log_dir / f"{self.alias}.supervisor.stdout"
127
+ if stdout_path.exists() and stdout_path.stat().st_size >= STDOUT_MAX_BYTES:
128
+ stdout_path.replace(stdout_path.with_name(f"{stdout_path.name}.1"))
129
+ with open(stdout_path, "a") as stream:
130
+ child = subprocess.Popen(
131
+ cmd, stdout=stream, stderr=stream, start_new_session=True
132
+ )
133
+ # Publish the pid BEFORE returning: a signal that fires between here and
134
+ # the caller's `_stop` check must be able to reach the child, not a gone
135
+ # one.
136
+ self._child = child
137
+ return child
138
+
139
+ def run(self) -> int:
140
+ signal.signal(signal.SIGTERM, self._on_signal)
141
+ signal.signal(signal.SIGINT, self._on_signal)
142
+ self._write_record(None, "starting")
143
+
144
+ while not self._stop:
145
+ # `_spawn` publishes `self._child` before returning, so a signal
146
+ # during the fork reaches the new pid; one arriving just after is
147
+ # caught by the `_stop` re-check below.
148
+ child = self._spawn()
149
+ if self._stop:
150
+ # A stop arrived while we forked: forward it and do not restart.
151
+ # Without this the loop would block on a child never signalled.
152
+ self._signal_child(signal.SIGTERM)
153
+ self._write_record(child.pid, "running")
154
+ child.wait()
155
+ rc = child.returncode
156
+
157
+ if self._stop:
158
+ break
159
+
160
+ now = time.monotonic()
161
+ self._restarts = [
162
+ t for t in self._restarts if now - t < RESTART_WINDOW_SECONDS
163
+ ]
164
+ self._restarts.append(now)
165
+ if len(self._restarts) > RESTART_LIMIT:
166
+ self._write_record(None, "crash-loop")
167
+ print(
168
+ f"supervisor: child exited {len(self._restarts)} times in "
169
+ f"{RESTART_WINDOW_SECONDS}s (rc={rc}); refusing to restart",
170
+ file=sys.stderr,
171
+ )
172
+ return 1
173
+
174
+ self._write_record(None, "restarting")
175
+ time.sleep(RESTART_BACKOFF_SECONDS)
176
+
177
+ # Terminal stop: wind the child down, then clear our record so `status`
178
+ # does not report a dead supervisor.
179
+ if self._child and self._child.poll() is None:
180
+ self._signal_child(signal.SIGTERM)
181
+ try:
182
+ self._child.wait(timeout=STOP_GRACE_SECONDS)
183
+ except subprocess.TimeoutExpired:
184
+ self._signal_child(signal.SIGKILL)
185
+ supervisor_record_path(self.alias).unlink(missing_ok=True)
186
+ return 0
187
+
188
+
189
+ def main(argv: list[str] | None = None) -> int:
190
+ parser = argparse.ArgumentParser(
191
+ prog="manta-supervisor",
192
+ description="Own a manta_node child and its restart (ADR-0041 boundary B1).",
193
+ )
194
+ parser.add_argument("alias", help="node alias (the config name it starts)")
195
+ parser.add_argument("--config", help="config name (defaults to the alias)")
196
+ parser.add_argument(
197
+ "--python",
198
+ help=(
199
+ "interpreter that runs the node child (defaults to the supervisor's"
200
+ " own; set it when the child runs from a separate version tree, 2c)"
201
+ ),
202
+ )
203
+ args = parser.parse_args(argv)
204
+ return Supervisor(
205
+ args.alias, args.config or args.alias, child_python=args.python
206
+ ).run()
207
+
208
+
209
+ if __name__ == "__main__":
210
+ sys.exit(main())
@@ -0,0 +1,167 @@
1
+ """The two on-disk records the supervisor and the node keep for one instance.
2
+
3
+ Under the split (ADR-0041 boundary B1) there are two processes and two records:
4
+
5
+ - the **node instance record**, ``<id>.json`` — node-owned content (alias,
6
+ config, ``log_file``, manager host). The running node writes it; the
7
+ supervisor never does.
8
+ - the **supervisor record**, ``<alias>.supervisor.json`` — supervisor-owned, and
9
+ the **pid of record**. Under ``--subprocess`` the child writes no instance
10
+ record (``start.py`` skips it), so on a supervised node this is the only record
11
+ present and the CLI's ``status``/``stop`` must read it.
12
+
13
+ This module has no dependency on the node's domain: the supervisor is
14
+ infrastructure, and a guard test pins that (``tests/.../test_supervisor_is_infrastructure``).
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import json
20
+ import os
21
+ from datetime import datetime, timezone
22
+ from pathlib import Path
23
+
24
+ INSTANCE_DIR = Path.home() / ".manta" / "nodes" / "instances"
25
+
26
+ SUPERVISOR_SUFFIX = ".supervisor.json"
27
+
28
+
29
+ def supervisor_record_path(alias: str) -> Path:
30
+ """The supervisor record for ``alias`` (the pid of record)."""
31
+ return INSTANCE_DIR / f"{alias}{SUPERVISOR_SUFFIX}"
32
+
33
+
34
+ def write_json_atomic(path: Path, data: dict) -> None:
35
+ """Write ``data`` as JSON so no reader ever sees a half-written file.
36
+
37
+ A tmp file beside the target plus ``replace`` — an atomic rename on the same
38
+ filesystem. A reader either sees the previous record or the new one.
39
+ """
40
+ path.parent.mkdir(parents=True, exist_ok=True)
41
+ tmp = path.with_suffix(path.suffix + ".tmp")
42
+ tmp.write_text(json.dumps(data, indent=2))
43
+ tmp.replace(path)
44
+
45
+
46
+ def read_json(path: Path) -> dict | None:
47
+ """Read a JSON object, or None if it is absent or unreadable/invalid."""
48
+ try:
49
+ return json.loads(path.read_text())
50
+ except (FileNotFoundError, ValueError, OSError):
51
+ return None
52
+
53
+
54
+ def supervisor_record(
55
+ alias: str, supervisor_pid: int, child_pid: int | None, state: str
56
+ ) -> dict:
57
+ """The supervisor record's shape."""
58
+ return {
59
+ "alias": alias,
60
+ "supervisor_pid": supervisor_pid,
61
+ "child_pid": child_pid,
62
+ "state": state,
63
+ "start_time": datetime.now(timezone.utc).isoformat(),
64
+ }
65
+
66
+
67
+ def is_alive(pid: int | None) -> bool:
68
+ """True when ``pid`` names a live process."""
69
+ if not pid:
70
+ return False
71
+ try:
72
+ os.kill(pid, 0)
73
+ except OSError:
74
+ return False
75
+ return True
76
+
77
+
78
+ def pid_of_record(alias: str) -> tuple[int | None, str]:
79
+ """(pid, source) for ``alias``: the supervisor record wins when it is live.
80
+
81
+ The pid of record is the one a stop signals. Under the split that is the
82
+ supervisor's; the instance record (the node's own pid) is the fallback for a
83
+ node running without a supervisor.
84
+ """
85
+ sup = read_json(supervisor_record_path(alias))
86
+ if sup and is_alive(sup.get("supervisor_pid")):
87
+ return sup["supervisor_pid"], "supervisor"
88
+
89
+ # Fall back to the node instance record: the supervisor may be absent (a node
90
+ # on the pre-split path) or dead. Match on the record's own alias, not the
91
+ # filename stem, which carries the per-start instance id.
92
+ for record in sorted(INSTANCE_DIR.glob("*.json")):
93
+ if record.name.endswith(SUPERVISOR_SUFFIX):
94
+ continue
95
+ data = read_json(record)
96
+ if data and data.get("alias") == alias and is_alive(data.get("pid")):
97
+ return data["pid"], "instance"
98
+ return None, "none"
99
+
100
+
101
+ def list_instances() -> list[dict]:
102
+ """Live instances for the CLI, one per alias: the pid of record and its source.
103
+
104
+ A supervised node has only a supervisor record; a pre-split node has only an
105
+ instance record. A stale record (the pid is gone) is not returned, and its
106
+ file is removed — the same cleanup the CLI did before the split.
107
+ """
108
+ # Collect the two kinds separately, then resolve per alias with an EXPLICIT
109
+ # rule — never by filename/glob order. A supervisor record always wins over a
110
+ # node instance record for the same alias (as `pid_of_record` does); relying
111
+ # on sort order would let a legacy instance record silently override a live
112
+ # supervisor record whenever its config name sorts after the alias.
113
+ supervisors: dict[str, dict] = {}
114
+ instances: dict[str, dict] = {}
115
+
116
+ for record in sorted(INSTANCE_DIR.glob("*.json")):
117
+ data = read_json(record)
118
+ if data is None:
119
+ record.unlink(missing_ok=True)
120
+ continue
121
+
122
+ if record.name.endswith(SUPERVISOR_SUFFIX):
123
+ alias = data.get("alias")
124
+ pid = data.get("supervisor_pid")
125
+ entry = {
126
+ "alias": alias,
127
+ "instance_id": alias,
128
+ "config_name": alias,
129
+ "pid": pid,
130
+ # The child is the workload the operator cares about: `status`
131
+ # reports ITS cpu/memory, not the thin supervisor's. `pid` stays
132
+ # the pid of record (the supervisor) — what a stop signals.
133
+ "child_pid": data.get("child_pid"),
134
+ "workload_pid": data.get("child_pid"),
135
+ "source": "supervisor",
136
+ "start_time": data.get("start_time"),
137
+ "instance_file": record,
138
+ }
139
+ bucket = supervisors
140
+ else:
141
+ instance_id = data.get("instance_id", record.stem)
142
+ alias = data.get("alias", instance_id)
143
+ pid = data.get("pid")
144
+ entry = {
145
+ "alias": alias,
146
+ "instance_id": instance_id,
147
+ "config_name": data.get("config_name", "unknown"),
148
+ "pid": pid,
149
+ "child_pid": None,
150
+ "workload_pid": pid,
151
+ "source": "instance",
152
+ "start_time": data.get("start_time"),
153
+ "instance_file": record,
154
+ }
155
+ bucket = instances
156
+
157
+ if not alias or not is_alive(pid):
158
+ # A stale record is not a running instance: remove it so the next
159
+ # `status` does not keep reporting a dead pid.
160
+ record.unlink(missing_ok=True)
161
+ continue
162
+
163
+ bucket[alias] = entry
164
+
165
+ # Explicit precedence: the supervisor record wins for an alias that has both.
166
+ merged = {**instances, **supervisors}
167
+ return [merged[alias] for alias in sorted(merged)]
@@ -0,0 +1,190 @@
1
+ """The supervisor's Unix-socket adapter for the local seam.
2
+
3
+ The seam's **port** is ``manta_node.seam`` (the node/domain's interface); this
4
+ module is the supervisor's **adapter** that serves it (mechanism §6.4). It is
5
+ infrastructure: it carries a request in and an outcome out, and decides nothing.
6
+
7
+ **Unused ⇒ no behaviour change.** The adapter is only started when the supervisor
8
+ is asked to serve the seam; nothing connects to it until the node relays. That is
9
+ the 2b acceptance: the code can ship, be idle, and change nothing.
10
+
11
+ **Transport.** A Unix domain socket owned by the service user, at
12
+ ``~/.manta/supervisor.sock`` (the state dir, never the version tree — NF-6), with
13
+ ``0600`` on the socket and its parent directory. Local and same-user only
14
+ (NF-9/NF-10).
15
+
16
+ **What "execute" means here.** At 2b the supervisor has no version tree yet, so
17
+ its executor is a stub: it records the request, idempotently, and answers
18
+ ``REQUESTED`` (or ``REFUSED`` for a repeat that is still in flight). The real
19
+ fetch/verify/stage/switch/restart/revert lifecycle is 2c/2d; the seam's contract
20
+ is what this lands.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import asyncio
26
+ import contextlib
27
+ import os
28
+ from pathlib import Path
29
+
30
+ from ..seam import (
31
+ SeamError,
32
+ SeamState,
33
+ SwitchOutcome,
34
+ UpdateRequest,
35
+ seam_socket_path,
36
+ )
37
+
38
+ _SOCKET_MODE = 0o600
39
+ _DIR_MODE = 0o700
40
+
41
+
42
+ class SeamServer:
43
+ """Serves the seam on a Unix socket; asks a handler for each outcome.
44
+
45
+ ``handler`` is ``async (UpdateRequest) -> SwitchOutcome`` — the executor. At
46
+ 2b it is the stub below; at 2c/2d it becomes the real lifecycle.
47
+ """
48
+
49
+ def __init__(self, socket_path: Path | None = None) -> None:
50
+ self.socket_path = socket_path or seam_socket_path()
51
+ self._server: asyncio.AbstractServer | None = None
52
+ # Idempotency (NF-5): request_id -> the outcome already answered for it.
53
+ self._answered: dict[str, SwitchOutcome] = {}
54
+ self._in_flight: set[str] = set()
55
+
56
+ async def _read_request(self, reader: asyncio.StreamReader) -> UpdateRequest:
57
+ raw = await reader.readline()
58
+ if not raw:
59
+ raise SeamError("empty request")
60
+ try:
61
+ return UpdateRequest.from_bytes(raw.strip())
62
+ except (ValueError, KeyError) as exc:
63
+ raise SeamError(f"malformed request: {exc}") from exc
64
+
65
+ async def _handle_connection(
66
+ self,
67
+ reader: asyncio.StreamReader,
68
+ writer: asyncio.StreamWriter,
69
+ handler,
70
+ ) -> None:
71
+ try:
72
+ request = await self._read_request(reader)
73
+ outcome = await self._answer(request, handler)
74
+ writer.write(outcome.to_bytes() + b"\n")
75
+ await writer.drain()
76
+ except SeamError as exc:
77
+ # A malformed request gets no outcome; the error is the answer.
78
+ writer.write(
79
+ SwitchOutcome(
80
+ request_id="",
81
+ state=SeamState.REFUSED,
82
+ reason=str(exc),
83
+ ).to_bytes()
84
+ + b"\n"
85
+ )
86
+ with contextlib.suppress(ConnectionError):
87
+ await writer.drain()
88
+ finally:
89
+ writer.close()
90
+ with contextlib.suppress(Exception):
91
+ await writer.wait_closed()
92
+
93
+ async def _answer(self, request: UpdateRequest, handler) -> SwitchOutcome:
94
+ # Idempotency: a repeat of a completed request returns the same outcome;
95
+ # a repeat still in flight is refused, not run twice (NF-5).
96
+ if request.request_id in self._answered:
97
+ return self._answered[request.request_id]
98
+ if request.request_id in self._in_flight:
99
+ return SwitchOutcome(
100
+ request_id=request.request_id,
101
+ state=SeamState.REFUSED,
102
+ reason="a request with this request_id is already in flight",
103
+ )
104
+ self._in_flight.add(request.request_id)
105
+ try:
106
+ outcome = await handler(request)
107
+ finally:
108
+ self._in_flight.discard(request.request_id)
109
+ self._answered[request.request_id] = outcome
110
+ return outcome
111
+
112
+ async def start(self, handler) -> None:
113
+ """Bind the socket (0600, in a 0700 parent) and serve until stopped."""
114
+ parent = self.socket_path.parent
115
+ # `mkdir(mode=...)` only applies the mode when it CREATES the directory.
116
+ # `~/.manta` is usually created earlier by the config/log paths at the
117
+ # default umask, so chmod it explicitly: the socket's parent must be 0700
118
+ # even when it already existed (NF-9/NF-10).
119
+ parent.mkdir(parents=True, exist_ok=True)
120
+ os.chmod(parent, _DIR_MODE)
121
+ # A stale socket from a previous supervisor would make `bind` fail; it is
122
+ # ours to remove (same user, same path), and nothing else listens on it.
123
+ self.socket_path.unlink(missing_ok=True)
124
+ # The parent is 0700 before `bind`, so no non-owner can even reach the
125
+ # socket path; `chmod` after the bind then asserts the socket's own 0600
126
+ # (NF-9). No umask is set: it is process-wide, and `start_unix_server`
127
+ # yields before returning, so a tightened mask would leak across the
128
+ # await to any concurrent file creation.
129
+ self._server = await asyncio.start_unix_server(
130
+ lambda r, w: self._handle_connection(r, w, handler),
131
+ path=str(self.socket_path),
132
+ )
133
+ os.chmod(self.socket_path, _SOCKET_MODE)
134
+ async with self._server:
135
+ await self._server.serve_forever()
136
+
137
+ async def stop(self) -> None:
138
+ if self._server is not None:
139
+ self._server.close()
140
+ with contextlib.suppress(Exception):
141
+ await self._server.wait_closed()
142
+ self._server = None
143
+ self.socket_path.unlink(missing_ok=True)
144
+
145
+
146
+ async def stub_handler(request: UpdateRequest) -> SwitchOutcome:
147
+ """The 2b executor: accept and record; the lifecycle is 2c/2d.
148
+
149
+ It makes no decision about versions — it answers ``REQUESTED`` so the seam's
150
+ contract is exercised end to end without a version tree.
151
+ """
152
+ return SwitchOutcome(
153
+ request_id=request.request_id,
154
+ state=SeamState.REQUESTED,
155
+ reason="accepted; the switch lifecycle lands in 2c/2d",
156
+ detail={"target": request.target, "force": request.force},
157
+ )
158
+
159
+
160
+ class SocketSeamClient:
161
+ """The port's adapter over the supervisor's Unix socket (the node's side).
162
+
163
+ Implements ``manta_node.seam.SeamClient``. A connect failure raises
164
+ ``SeamError``: the node reports it and mutates nothing (NF-3).
165
+ """
166
+
167
+ def __init__(self, socket_path: Path | None = None) -> None:
168
+ self.socket_path = socket_path or seam_socket_path()
169
+
170
+ async def request_update(self, request: UpdateRequest) -> SwitchOutcome:
171
+ try:
172
+ reader, writer = await asyncio.open_unix_connection(str(self.socket_path))
173
+ except OSError as exc:
174
+ raise SeamError(f"seam unreachable at {self.socket_path}: {exc}") from exc
175
+ try:
176
+ writer.write(request.to_bytes() + b"\n")
177
+ await writer.drain()
178
+ raw = await reader.readline()
179
+ except OSError as exc:
180
+ raise SeamError(f"seam failed mid-request: {exc}") from exc
181
+ finally:
182
+ writer.close()
183
+ with contextlib.suppress(Exception):
184
+ await writer.wait_closed()
185
+ if not raw:
186
+ raise SeamError("seam closed without an outcome")
187
+ try:
188
+ return SwitchOutcome.from_bytes(raw.strip())
189
+ except (ValueError, KeyError) as exc:
190
+ raise SeamError(f"malformed outcome: {exc}") from exc
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: manta_node
3
- Version: 0.6b5.dev697
3
+ Version: 0.6b5.dev702
4
4
  Summary: Node side software of the Manta platform. It is responsible for the communication with the Manta Core and the execution of the tasks.
5
5
  Author-email: Hugo Miralles <hugo.miralles@manta-tech.io>, Matthew Thompson <matthew.thompson@manta-tech.io>
6
6
  License-Expression: AGPL-3.0-or-later
@@ -1,11 +1,12 @@
1
1
  manta_node/__init__.py,sha256=bgGWXH3yQVhNf0v1ZGUBAy5Y51fqb0Y-EaLnocbcino,153
2
2
  manta_node/__main__.py,sha256=SjrJyuMg_ZFKV6WOVe3MrtWM8Qmu065dkKZYl4n2YLw,1665
3
3
  manta_node/node_orchestrator.py,sha256=K2UZAk-FHs1ERrwf5hTcrQC2AASG2J2eVlr-l45aDt4,28066
4
+ manta_node/seam.py,sha256=wAI3uL1iuV6pmDOaBW7WZi7_V7-NsHQDP6C_gFIjCiA,4747
4
5
  manta_node/task_manager.py,sha256=xRE-yKdUW0FFR8vtX1jDwl1AxTbqVD_JS7QVR62GA00,38002
5
6
  manta_node/tasks.py,sha256=o837Hi-KFVJg9LF5iOtE8oD6UuTOfrEPWR-JOdhRGeY,6983
6
7
  manta_node/utils.py,sha256=Hw4Iw2jYQYsfVLVJuBMt-_rMFBeml3nCDE2jiiXc8YM,1155
7
8
  manta_node/cli/__init__.py,sha256=arzO3mXDJ-E2yeeytbZfN7mCswwxdQ43_QjyeL2E5yE,134
8
- manta_node/cli/_version_generated.py,sha256=U8tnsuCO0NubWEyVUBj6fAkDGgNGnB4pplg1P5LTHJA,275
9
+ manta_node/cli/_version_generated.py,sha256=lUZps58EgIuYIAwnIQz2-ijOxuSzZ_io_eQmog7Eujc,275
9
10
  manta_node/cli/config_manager.py,sha256=BCju9zqzraviL3KrzRnB-vZQUY__Q5kuEqb7EdB8YdI,4409
10
11
  manta_node/cli/main.py,sha256=dZa6zrypWBvpN8hd4qpxevvlyNFhxKINCg1537NJ36w,4318
11
12
  manta_node/cli/version.py,sha256=3lrnIWpOosszBN-VsCBVLxDgpvCC7IB0aDIKx_3e7hI,3115
@@ -15,8 +16,8 @@ manta_node/cli/commands/config.py,sha256=BiarCD0S8rcNYbio4RLzDvUS7A50s5dd5F4TiZq
15
16
  manta_node/cli/commands/logs.py,sha256=cXOSdwhDIJAiKMmJYdzrnpapIxudxVdMzYT-KAWDKVk,5259
16
17
  manta_node/cli/commands/network.py,sha256=QqncD6vbNlg65ptQdFYexEUMALQm8UwcftbW3sfQrjw,42070
17
18
  manta_node/cli/commands/start.py,sha256=HuIovoL3a1yQuEIIZMmvRUu3mq1cKJlFZgBf-U2zS7A,25637
18
- manta_node/cli/commands/status.py,sha256=FhlJuFxIRzxv9y8jUJRuWaCLfDBsGBqv6DCHUFQta3A,6401
19
- manta_node/cli/commands/stop.py,sha256=8on7NqqaBeHRcbrZ5pGJ3Ms_6E_HNcF22mZ908JVu4A,7695
19
+ manta_node/cli/commands/status.py,sha256=I3z4bee0uqobEhA4akJ3OErRJ7_UcieW6tvLP3pyZ-A,5608
20
+ manta_node/cli/commands/stop.py,sha256=bGsvDrTiIvDN20HMsXKQYg83iWRaAnhbJ8xcalJwre0,9117
20
21
  manta_node/domain/__init__.py,sha256=uyJXDf0QgfGCsXLMggqKCB5QV9oneYJ4iM-REaJGXDM,217
21
22
  manta_node/domain/task_lifecycle.py,sha256=CmaK6OL4KNSy81YYosINduVQQW3LjycAwmfpRmXrldk,3323
22
23
  manta_node/domain/value_objects/__init__.py,sha256=Sut2I8iSNNYBtI42-tfKXJ8aod2HV_k_N_uM_KWwjqs,135
@@ -40,7 +41,7 @@ manta_node/infrastructure/metrics/__init__.py,sha256=ODGAj9s63F5jba2c3dQRMfNK6I4
40
41
  manta_node/infrastructure/metrics/collector.py,sha256=9ESw__Pc2tV6ldG5yhos2IS7T5Td59KxTIwDRZc_G4w,3067
41
42
  manta_node/infrastructure/metrics/metrics_collector.py,sha256=TI_9tRoIUunGx_BstVavK6VElig7Wc5mJ0kB4bFumVs,11048
42
43
  manta_node/infrastructure/mqtt/__init__.py,sha256=riXo1sXgO4YV6DHCmWRqK61BAmuGLdHiVdQFunAJadA,140
43
- manta_node/infrastructure/mqtt/command_handler.py,sha256=YaRPhOvgo4MSQksnhOCAT-Op9Zyl-yNCuoeXchcHeSk,26791
44
+ manta_node/infrastructure/mqtt/command_handler.py,sha256=Ym8t0fA-qWjg8n0VDyYJYxCqulVH3lOJB1MjQHu5iZE,28110
44
45
  manta_node/infrastructure/network/__init__.py,sha256=On3XLgycFQoc-fBCa5hFuFDfEJjmFy7Wl2CK3D2LkdI,478
45
46
  manta_node/infrastructure/network/event_registry.py,sha256=iWEfUJRzDnWjOrDinpYqVyIUpGKJjp7RZkjMDPfpplM,1661
46
47
  manta_node/infrastructure/network/factory.py,sha256=-LvHbBp5nBpQ7fyQT1T0pvK3aczZqWc9QNDW37brBeU,3897
@@ -51,9 +52,13 @@ manta_node/infrastructure/process/executor.py,sha256=Ab1XvNuelEGCD5KdZLgYYnpjYZ5
51
52
  manta_node/infrastructure/security/__init__.py,sha256=lI6vkbUmfmt0m363iO4HzuzVNoQAc7Clp2R5vAhZbgE,64
52
53
  manta_node/infrastructure/security/auth_agent.py,sha256=WkPTg_g0gix7xomb_on_Ft3TNX0FpeqS7gwgJW9mTvk,11037
53
54
  manta_node/infrastructure/security/token_provider.py,sha256=u3bcbQYNBskFzunO-wOSZpGkDN_CU1BCB5HUbZTC4Vg,1832
54
- manta_node-0.6b5.dev697.dist-info/licenses/LICENSE,sha256=9mBgLhiB9goQPQjg9XQJ6FjgVxYw9Q8lyOZYkPGwF_c,34484
55
- manta_node-0.6b5.dev697.dist-info/METADATA,sha256=ffSLua9Ci0UH0_YNS2WKaeQcj6K0A7MpGQAvC3sZedE,4231
56
- manta_node-0.6b5.dev697.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
57
- manta_node-0.6b5.dev697.dist-info/entry_points.txt,sha256=C_zcw6mdmztNxVUZGLZ83UH5TwcSuqb2C-rB4e2CQoc,56
58
- manta_node-0.6b5.dev697.dist-info/top_level.txt,sha256=nbT5J5WETuXM9UPoQckl29uUOq8wMYdRb9QDx5kAFeo,11
59
- manta_node-0.6b5.dev697.dist-info/RECORD,,
55
+ manta_node/supervisor/__init__.py,sha256=kgdoNce1tM4e7jvdxkdmnNJV1L92-NI2cRfYSdm9vsw,542
56
+ manta_node/supervisor/process.py,sha256=is3phcIKF9AdRORqXK7_rwiiqCF_4t4txXu-vGtUOE8,8655
57
+ manta_node/supervisor/records.py,sha256=fUlL1SfmWpGnCbJ9956cuy0H3a7-aEF-B-naqbJiYoU,6405
58
+ manta_node/supervisor/seam.py,sha256=zafKSWFeZClprftM0tYaTW3Ng7lxeFcndRIHplpSnf4,7708
59
+ manta_node-0.6b5.dev702.dist-info/licenses/LICENSE,sha256=9mBgLhiB9goQPQjg9XQJ6FjgVxYw9Q8lyOZYkPGwF_c,34484
60
+ manta_node-0.6b5.dev702.dist-info/METADATA,sha256=5JY-PMYelVoodkp4dK-vs1D-lNTNWd-lHT0NzZ_6tXE,4231
61
+ manta_node-0.6b5.dev702.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
62
+ manta_node-0.6b5.dev702.dist-info/entry_points.txt,sha256=uCwggRApalLEsYs1YxwTBN0-e9oMJAkMoBp01HaSdoY,110
63
+ manta_node-0.6b5.dev702.dist-info/top_level.txt,sha256=nbT5J5WETuXM9UPoQckl29uUOq8wMYdRb9QDx5kAFeo,11
64
+ manta_node-0.6b5.dev702.dist-info/RECORD,,
@@ -1,2 +1,3 @@
1
1
  [console_scripts]
2
+ manta-supervisor = manta_node.supervisor.process:main
2
3
  manta_node = manta_node.__main__:main