kanibako-cli 1.5.0.dev14__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.
- kanibako/__init__.py +3 -0
- kanibako/__main__.py +6 -0
- kanibako/auth_browser.py +296 -0
- kanibako/auth_parser.py +51 -0
- kanibako/browser_sidecar.py +183 -0
- kanibako/browser_state.py +103 -0
- kanibako/bun_sea.py +144 -0
- kanibako/cli.py +344 -0
- kanibako/commands/__init__.py +0 -0
- kanibako/commands/archive.py +228 -0
- kanibako/commands/box/__init__.py +22 -0
- kanibako/commands/box/_duplicate.py +395 -0
- kanibako/commands/box/_migrate.py +574 -0
- kanibako/commands/box/_parser.py +1178 -0
- kanibako/commands/clean.py +166 -0
- kanibako/commands/crab_cmd.py +480 -0
- kanibako/commands/diagnose.py +239 -0
- kanibako/commands/fork_cmd.py +51 -0
- kanibako/commands/helper_cmd.py +669 -0
- kanibako/commands/image.py +1300 -0
- kanibako/commands/install.py +152 -0
- kanibako/commands/refresh_credentials.py +67 -0
- kanibako/commands/restore.py +298 -0
- kanibako/commands/setup_cmd.py +89 -0
- kanibako/commands/start.py +1600 -0
- kanibako/commands/stop.py +116 -0
- kanibako/commands/system_cmd.py +224 -0
- kanibako/commands/upgrade.py +161 -0
- kanibako/commands/vault_cmd.py +199 -0
- kanibako/commands/workset_cmd.py +552 -0
- kanibako/config.py +514 -0
- kanibako/config_interface.py +573 -0
- kanibako/config_io.py +36 -0
- kanibako/container.py +607 -0
- kanibako/containerfiles.py +58 -0
- kanibako/containers/Containerfile.kanibako +99 -0
- kanibako/containers/Containerfile.template-android +55 -0
- kanibako/containers/Containerfile.template-dotnet +29 -0
- kanibako/containers/Containerfile.template-js +43 -0
- kanibako/containers/Containerfile.template-jvm +27 -0
- kanibako/containers/Containerfile.template-systems +46 -0
- kanibako/containers/__init__.py +0 -0
- kanibako/crabs.py +89 -0
- kanibako/errors.py +33 -0
- kanibako/freshness.py +67 -0
- kanibako/git.py +114 -0
- kanibako/helper_client.py +132 -0
- kanibako/helper_listener.py +538 -0
- kanibako/helpers.py +339 -0
- kanibako/hygiene.py +296 -0
- kanibako/image_sharing.py +133 -0
- kanibako/instructions.py +160 -0
- kanibako/log.py +31 -0
- kanibako/names.py +248 -0
- kanibako/paths.py +1483 -0
- kanibako/plugins/__init__.py +10 -0
- kanibako/registry.py +71 -0
- kanibako/rig_bundle.py +121 -0
- kanibako/rig_meta.py +92 -0
- kanibako/rig_registry.py +132 -0
- kanibako/rig_resolve.py +182 -0
- kanibako/rig_source.py +245 -0
- kanibako/scripts/__init__.py +0 -0
- kanibako/scripts/helper-init.sh +45 -0
- kanibako/scripts/kanibako-entry +12 -0
- kanibako/settings_resolve.py +312 -0
- kanibako/settings_seeds.py +154 -0
- kanibako/settings_shares.py +154 -0
- kanibako/shellenv.py +75 -0
- kanibako/snapshots.py +281 -0
- kanibako/targets/__init__.py +173 -0
- kanibako/targets/base.py +243 -0
- kanibako/targets/no_agent.py +58 -0
- kanibako/templates.py +60 -0
- kanibako/templates_image.py +224 -0
- kanibako/tweakcc.py +140 -0
- kanibako/tweakcc_cache.py +171 -0
- kanibako/utils.py +136 -0
- kanibako/workset.py +347 -0
- kanibako_cli-1.5.0.dev14.dist-info/METADATA +15 -0
- kanibako_cli-1.5.0.dev14.dist-info/RECORD +85 -0
- kanibako_cli-1.5.0.dev14.dist-info/WHEEL +5 -0
- kanibako_cli-1.5.0.dev14.dist-info/entry_points.txt +5 -0
- kanibako_cli-1.5.0.dev14.dist-info/licenses/LICENSE.md +594 -0
- kanibako_cli-1.5.0.dev14.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""Container-side helper client: socket communication with the host hub."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import socket
|
|
7
|
+
import threading
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class HelperConnection:
|
|
12
|
+
"""Persistent connection to the HelperHub for messaging.
|
|
13
|
+
|
|
14
|
+
Use for helpers that need to send/receive messages over time.
|
|
15
|
+
For one-shot commands (spawn/stop), use ``send_request()`` instead.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
def __init__(self) -> None:
|
|
19
|
+
self._sock: socket.socket | None = None
|
|
20
|
+
self._recv_buf = b""
|
|
21
|
+
self._lock = threading.Lock()
|
|
22
|
+
|
|
23
|
+
def connect(self, socket_path: Path, helper_num: int | None = None) -> None:
|
|
24
|
+
"""Connect to the hub socket, optionally registering as a helper."""
|
|
25
|
+
self._sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
|
26
|
+
self._sock.connect(str(socket_path))
|
|
27
|
+
if helper_num is not None:
|
|
28
|
+
resp = self._request({"action": "register", "helper_num": helper_num})
|
|
29
|
+
if resp.get("status") != "ok":
|
|
30
|
+
raise ConnectionError(
|
|
31
|
+
f"Registration failed: {resp.get('message', 'unknown')}"
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
def spawn(self, helper_num: int, model: str | None = None,
|
|
35
|
+
helpers_dir: str | None = None) -> dict:
|
|
36
|
+
"""Request the hub to spawn a helper container."""
|
|
37
|
+
req: dict = {"action": "spawn", "helper_num": helper_num}
|
|
38
|
+
if model:
|
|
39
|
+
req["model"] = model
|
|
40
|
+
if helpers_dir:
|
|
41
|
+
req["helpers_dir"] = helpers_dir
|
|
42
|
+
return self._request(req)
|
|
43
|
+
|
|
44
|
+
def stop(self, container_name: str) -> dict:
|
|
45
|
+
"""Request the hub to stop a helper container."""
|
|
46
|
+
return self._request({
|
|
47
|
+
"action": "stop",
|
|
48
|
+
"container_name": container_name,
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
def send(self, to: int, payload: dict) -> dict:
|
|
52
|
+
"""Send a message to a specific peer or parent."""
|
|
53
|
+
return self._request({
|
|
54
|
+
"action": "send", "to": to, "payload": payload,
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
def broadcast(self, payload: dict) -> dict:
|
|
58
|
+
"""Broadcast a message to all connected helpers."""
|
|
59
|
+
return self._request({
|
|
60
|
+
"action": "broadcast", "payload": payload,
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
def recv(self, timeout: float | None = None) -> dict | None:
|
|
64
|
+
"""Receive an incoming message (blocking).
|
|
65
|
+
|
|
66
|
+
Returns the message dict or None on timeout/disconnect.
|
|
67
|
+
"""
|
|
68
|
+
if self._sock is None:
|
|
69
|
+
return None
|
|
70
|
+
old_timeout = self._sock.gettimeout()
|
|
71
|
+
self._sock.settimeout(timeout)
|
|
72
|
+
try:
|
|
73
|
+
while b"\n" not in self._recv_buf:
|
|
74
|
+
try:
|
|
75
|
+
data = self._sock.recv(4096)
|
|
76
|
+
except socket.timeout:
|
|
77
|
+
return None
|
|
78
|
+
except OSError:
|
|
79
|
+
return None
|
|
80
|
+
if not data:
|
|
81
|
+
return None
|
|
82
|
+
self._recv_buf += data
|
|
83
|
+
line, self._recv_buf = self._recv_buf.split(b"\n", 1)
|
|
84
|
+
return json.loads(line)
|
|
85
|
+
finally:
|
|
86
|
+
self._sock.settimeout(old_timeout)
|
|
87
|
+
|
|
88
|
+
def close(self) -> None:
|
|
89
|
+
"""Close the connection."""
|
|
90
|
+
if self._sock:
|
|
91
|
+
try:
|
|
92
|
+
self._sock.close()
|
|
93
|
+
except Exception:
|
|
94
|
+
pass
|
|
95
|
+
self._sock = None
|
|
96
|
+
|
|
97
|
+
def _request(self, data: dict) -> dict:
|
|
98
|
+
"""Send a request and read the response."""
|
|
99
|
+
if self._sock is None:
|
|
100
|
+
raise ConnectionError("Not connected")
|
|
101
|
+
with self._lock:
|
|
102
|
+
self._sock.sendall(json.dumps(data).encode() + b"\n")
|
|
103
|
+
# Read response from buffer + socket
|
|
104
|
+
while b"\n" not in self._recv_buf:
|
|
105
|
+
chunk = self._sock.recv(4096)
|
|
106
|
+
if not chunk:
|
|
107
|
+
raise ConnectionError("Connection closed")
|
|
108
|
+
self._recv_buf += chunk
|
|
109
|
+
line, self._recv_buf = self._recv_buf.split(b"\n", 1)
|
|
110
|
+
return json.loads(line)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def send_request(socket_path: Path, request: dict) -> dict:
|
|
114
|
+
"""One-shot convenience: connect, send, read response, disconnect.
|
|
115
|
+
|
|
116
|
+
For spawn/stop commands that don't need a persistent connection.
|
|
117
|
+
"""
|
|
118
|
+
s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
|
119
|
+
try:
|
|
120
|
+
s.connect(str(socket_path))
|
|
121
|
+
s.settimeout(30.0)
|
|
122
|
+
s.sendall(json.dumps(request).encode() + b"\n")
|
|
123
|
+
buf = b""
|
|
124
|
+
while b"\n" not in buf:
|
|
125
|
+
data = s.recv(4096)
|
|
126
|
+
if not data:
|
|
127
|
+
raise ConnectionError("Connection closed before response")
|
|
128
|
+
buf += data
|
|
129
|
+
line = buf.split(b"\n")[0]
|
|
130
|
+
return json.loads(line)
|
|
131
|
+
finally:
|
|
132
|
+
s.close()
|
|
@@ -0,0 +1,538 @@
|
|
|
1
|
+
"""Host-side helper hub: Unix socket server for spawn/stop and message routing."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import shutil
|
|
7
|
+
import socket
|
|
8
|
+
import threading
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from kanibako.container import ContainerRuntime
|
|
14
|
+
from kanibako.log import get_logger
|
|
15
|
+
from kanibako.targets.base import Mount
|
|
16
|
+
|
|
17
|
+
logger = get_logger("helper_listener")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class HelperContext:
|
|
22
|
+
"""Everything needed to launch helper containers from the host."""
|
|
23
|
+
|
|
24
|
+
runtime: ContainerRuntime
|
|
25
|
+
image: str
|
|
26
|
+
container_name_prefix: str # e.g. "kanibako-myapp" (project container name)
|
|
27
|
+
shell_path: Path # director's shell_path (parent of helpers/)
|
|
28
|
+
helpers_dir: Path # absolute host path to helpers/ inside shell_path
|
|
29
|
+
socket_path: Path # host path to helper.sock
|
|
30
|
+
binary_mounts: list[Mount] = field(default_factory=list)
|
|
31
|
+
env: dict[str, str] | None = None
|
|
32
|
+
entrypoint: str | None = None
|
|
33
|
+
default_entrypoint: str | None = None # from target.default_entrypoint
|
|
34
|
+
project_path: Path | None = None # host-side workspace directory
|
|
35
|
+
data_path: Path | None = None # kanibako data root (~/.local/share/kanibako/)
|
|
36
|
+
boxes: Path | None = None # resolved system.path.boxes (std.boxes)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class HelperHub:
|
|
40
|
+
"""Central message router and container orchestrator.
|
|
41
|
+
|
|
42
|
+
Runs a Unix domain socket server in a background thread. Helpers
|
|
43
|
+
connect and send JSON-line requests; the hub dispatches spawn/stop
|
|
44
|
+
commands and routes messages between helpers.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
def __init__(self) -> None:
|
|
48
|
+
self._sock: socket.socket | None = None
|
|
49
|
+
self._accept_thread: threading.Thread | None = None
|
|
50
|
+
self._shutdown = threading.Event()
|
|
51
|
+
self._ctx: HelperContext | None = None
|
|
52
|
+
self._log: MessageLog | None = None
|
|
53
|
+
|
|
54
|
+
# Connection table: helper_num -> socket connection
|
|
55
|
+
self._connections: dict[int, socket.socket] = {}
|
|
56
|
+
self._conn_lock = threading.Lock()
|
|
57
|
+
|
|
58
|
+
# Track launched container names for cleanup
|
|
59
|
+
self._containers: list[str] = []
|
|
60
|
+
self._containers_lock = threading.Lock()
|
|
61
|
+
|
|
62
|
+
def start(self, socket_path: Path, context: HelperContext,
|
|
63
|
+
log: MessageLog | None = None) -> None:
|
|
64
|
+
"""Bind the Unix socket and start the accept loop."""
|
|
65
|
+
self._ctx = context
|
|
66
|
+
self._log = log
|
|
67
|
+
|
|
68
|
+
# Ensure parent dir exists, remove stale socket
|
|
69
|
+
socket_path.parent.mkdir(parents=True, exist_ok=True)
|
|
70
|
+
if socket_path.exists():
|
|
71
|
+
socket_path.unlink()
|
|
72
|
+
|
|
73
|
+
self._sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
|
74
|
+
self._sock.bind(str(socket_path))
|
|
75
|
+
self._sock.listen(16)
|
|
76
|
+
self._sock.settimeout(1.0) # so accept loop checks shutdown flag
|
|
77
|
+
|
|
78
|
+
self._accept_thread = threading.Thread(
|
|
79
|
+
target=self._accept_loop, daemon=True, name="helper-hub",
|
|
80
|
+
)
|
|
81
|
+
self._accept_thread.start()
|
|
82
|
+
logger.debug("HelperHub listening on %s", socket_path)
|
|
83
|
+
|
|
84
|
+
def stop(self) -> None:
|
|
85
|
+
"""Shut down: stop all helper containers, close socket."""
|
|
86
|
+
self._shutdown.set()
|
|
87
|
+
|
|
88
|
+
# Stop all tracked helper containers
|
|
89
|
+
if self._ctx:
|
|
90
|
+
with self._containers_lock:
|
|
91
|
+
for name in self._containers:
|
|
92
|
+
try:
|
|
93
|
+
self._ctx.runtime.stop(name)
|
|
94
|
+
self._ctx.runtime.rm(name)
|
|
95
|
+
except Exception:
|
|
96
|
+
pass
|
|
97
|
+
self._containers.clear()
|
|
98
|
+
|
|
99
|
+
# Close all client connections
|
|
100
|
+
with self._conn_lock:
|
|
101
|
+
for conn in self._connections.values():
|
|
102
|
+
try:
|
|
103
|
+
conn.close()
|
|
104
|
+
except Exception:
|
|
105
|
+
pass
|
|
106
|
+
self._connections.clear()
|
|
107
|
+
|
|
108
|
+
# Close server socket
|
|
109
|
+
if self._sock:
|
|
110
|
+
try:
|
|
111
|
+
self._sock.close()
|
|
112
|
+
except Exception:
|
|
113
|
+
pass
|
|
114
|
+
self._sock = None
|
|
115
|
+
|
|
116
|
+
if self._accept_thread:
|
|
117
|
+
self._accept_thread.join(timeout=5.0)
|
|
118
|
+
self._accept_thread = None
|
|
119
|
+
|
|
120
|
+
if self._log:
|
|
121
|
+
self._log.close()
|
|
122
|
+
|
|
123
|
+
def _accept_loop(self) -> None:
|
|
124
|
+
"""Accept incoming connections until shutdown."""
|
|
125
|
+
while not self._shutdown.is_set():
|
|
126
|
+
try:
|
|
127
|
+
conn, _ = self._sock.accept() # type: ignore[union-attr]
|
|
128
|
+
t = threading.Thread(
|
|
129
|
+
target=self._client_reader,
|
|
130
|
+
args=(conn,),
|
|
131
|
+
daemon=True,
|
|
132
|
+
)
|
|
133
|
+
t.start()
|
|
134
|
+
except socket.timeout:
|
|
135
|
+
continue
|
|
136
|
+
except OSError:
|
|
137
|
+
if not self._shutdown.is_set():
|
|
138
|
+
logger.debug("Accept loop OSError (shutting down?)")
|
|
139
|
+
break
|
|
140
|
+
|
|
141
|
+
def _client_reader(self, conn: socket.socket) -> None:
|
|
142
|
+
"""Read newline-delimited JSON from a client connection."""
|
|
143
|
+
helper_num: int | None = None
|
|
144
|
+
buf = b""
|
|
145
|
+
try:
|
|
146
|
+
while not self._shutdown.is_set():
|
|
147
|
+
try:
|
|
148
|
+
data = conn.recv(4096)
|
|
149
|
+
except OSError:
|
|
150
|
+
break
|
|
151
|
+
if not data:
|
|
152
|
+
break
|
|
153
|
+
buf += data
|
|
154
|
+
while b"\n" in buf:
|
|
155
|
+
line, buf = buf.split(b"\n", 1)
|
|
156
|
+
if not line.strip():
|
|
157
|
+
continue
|
|
158
|
+
try:
|
|
159
|
+
request = json.loads(line)
|
|
160
|
+
except json.JSONDecodeError:
|
|
161
|
+
_send_json(conn, {"status": "error", "message": "invalid JSON"})
|
|
162
|
+
continue
|
|
163
|
+
response, helper_num = self._dispatch(
|
|
164
|
+
conn, request, helper_num,
|
|
165
|
+
)
|
|
166
|
+
if response is not None:
|
|
167
|
+
_send_json(conn, response)
|
|
168
|
+
finally:
|
|
169
|
+
if helper_num is not None:
|
|
170
|
+
self._unregister(helper_num)
|
|
171
|
+
if self._log:
|
|
172
|
+
self._log.log_control("disconnect", helper_num)
|
|
173
|
+
try:
|
|
174
|
+
conn.close()
|
|
175
|
+
except Exception:
|
|
176
|
+
pass
|
|
177
|
+
|
|
178
|
+
def _dispatch(
|
|
179
|
+
self, conn: socket.socket, request: dict,
|
|
180
|
+
current_helper: int | None,
|
|
181
|
+
) -> tuple[dict | None, int | None]:
|
|
182
|
+
"""Route a request to the appropriate handler.
|
|
183
|
+
|
|
184
|
+
Returns (response_dict, updated_helper_num).
|
|
185
|
+
"""
|
|
186
|
+
action = request.get("action", "")
|
|
187
|
+
|
|
188
|
+
if action == "register":
|
|
189
|
+
helper_num = int(request.get("helper_num", -1))
|
|
190
|
+
if helper_num < 0:
|
|
191
|
+
return {"status": "error", "message": "invalid helper_num"}, current_helper
|
|
192
|
+
self._register(helper_num, conn)
|
|
193
|
+
if self._log:
|
|
194
|
+
self._log.log_control("register", helper_num)
|
|
195
|
+
return {"status": "ok"}, helper_num
|
|
196
|
+
|
|
197
|
+
if action == "spawn":
|
|
198
|
+
resp = self._handle_spawn(request)
|
|
199
|
+
return resp, current_helper
|
|
200
|
+
|
|
201
|
+
if action == "stop":
|
|
202
|
+
resp = self._handle_stop(request)
|
|
203
|
+
return resp, current_helper
|
|
204
|
+
|
|
205
|
+
if action == "fork":
|
|
206
|
+
resp = self._handle_fork(request)
|
|
207
|
+
return resp, current_helper
|
|
208
|
+
|
|
209
|
+
if action == "send":
|
|
210
|
+
to = request.get("to")
|
|
211
|
+
payload = request.get("payload", {})
|
|
212
|
+
sender = current_helper if current_helper is not None else 0
|
|
213
|
+
if to is None:
|
|
214
|
+
return {"status": "error", "message": "missing 'to'"}, current_helper
|
|
215
|
+
self._route_message(sender, int(to), payload)
|
|
216
|
+
return {"status": "ok"}, current_helper
|
|
217
|
+
|
|
218
|
+
if action == "broadcast":
|
|
219
|
+
payload = request.get("payload", {})
|
|
220
|
+
sender = current_helper if current_helper is not None else 0
|
|
221
|
+
self._broadcast_message(sender, payload)
|
|
222
|
+
return {"status": "ok"}, current_helper
|
|
223
|
+
|
|
224
|
+
return {"status": "error", "message": f"unknown action: {action}"}, current_helper
|
|
225
|
+
|
|
226
|
+
def _register(self, helper_num: int, conn: socket.socket) -> None:
|
|
227
|
+
with self._conn_lock:
|
|
228
|
+
self._connections[helper_num] = conn
|
|
229
|
+
|
|
230
|
+
def _unregister(self, helper_num: int) -> None:
|
|
231
|
+
with self._conn_lock:
|
|
232
|
+
self._connections.pop(helper_num, None)
|
|
233
|
+
|
|
234
|
+
def _route_message(self, sender: int, recipient: int,
|
|
235
|
+
payload: dict) -> None:
|
|
236
|
+
"""Send a message to a specific helper."""
|
|
237
|
+
if self._log:
|
|
238
|
+
self._log.log_message(sender, recipient, payload)
|
|
239
|
+
with self._conn_lock:
|
|
240
|
+
conn = self._connections.get(recipient)
|
|
241
|
+
if conn:
|
|
242
|
+
msg = {"event": "message", "from": sender, "payload": payload}
|
|
243
|
+
try:
|
|
244
|
+
_send_json(conn, msg)
|
|
245
|
+
except OSError:
|
|
246
|
+
logger.debug("Failed to deliver to helper %d", recipient)
|
|
247
|
+
|
|
248
|
+
def _broadcast_message(self, sender: int, payload: dict) -> None:
|
|
249
|
+
"""Send a message to all connected helpers."""
|
|
250
|
+
if self._log:
|
|
251
|
+
self._log.log_message(sender, "all", payload)
|
|
252
|
+
with self._conn_lock:
|
|
253
|
+
targets = list(self._connections.items())
|
|
254
|
+
msg = {"event": "message", "from": sender, "payload": payload}
|
|
255
|
+
for num, conn in targets:
|
|
256
|
+
if num == sender:
|
|
257
|
+
continue
|
|
258
|
+
try:
|
|
259
|
+
_send_json(conn, msg)
|
|
260
|
+
except OSError:
|
|
261
|
+
logger.debug("Failed to broadcast to helper %d", num)
|
|
262
|
+
|
|
263
|
+
def _handle_spawn(self, request: dict) -> dict:
|
|
264
|
+
"""Launch a helper container."""
|
|
265
|
+
ctx = self._ctx
|
|
266
|
+
if ctx is None:
|
|
267
|
+
return {"status": "error", "message": "no context"}
|
|
268
|
+
|
|
269
|
+
helper_num = int(request.get("helper_num", -1))
|
|
270
|
+
if helper_num < 0:
|
|
271
|
+
return {"status": "error", "message": "invalid helper_num"}
|
|
272
|
+
|
|
273
|
+
helpers_dir = request.get("helpers_dir")
|
|
274
|
+
if helpers_dir:
|
|
275
|
+
# Container-side path; map to host path via ctx
|
|
276
|
+
helpers_dir_host = ctx.helpers_dir
|
|
277
|
+
else:
|
|
278
|
+
helpers_dir_host = ctx.helpers_dir
|
|
279
|
+
|
|
280
|
+
container_name = f"{ctx.container_name_prefix}-helper-{helper_num}"
|
|
281
|
+
|
|
282
|
+
mounts = _build_helper_mounts(ctx, helper_num, helpers_dir_host)
|
|
283
|
+
|
|
284
|
+
# Use helper-init.sh as entrypoint wrapper — it registers with the
|
|
285
|
+
# hub, sources broadcast scripts, then execs the agent command.
|
|
286
|
+
init_script = "/home/agent/playbook/scripts/helper-init.sh"
|
|
287
|
+
agent_cmd = ctx.entrypoint or ctx.default_entrypoint or "/bin/bash"
|
|
288
|
+
cli_args = [str(helper_num), agent_cmd]
|
|
289
|
+
model = request.get("model")
|
|
290
|
+
if model:
|
|
291
|
+
cli_args.extend(["--model", model])
|
|
292
|
+
|
|
293
|
+
try:
|
|
294
|
+
rc = ctx.runtime.run(
|
|
295
|
+
ctx.image,
|
|
296
|
+
shell_path=helpers_dir_host / str(helper_num),
|
|
297
|
+
project_path=helpers_dir_host / str(helper_num) / "workspace",
|
|
298
|
+
vault_ro_path=helpers_dir_host / str(helper_num) / "vault" / "ro",
|
|
299
|
+
vault_rw_path=helpers_dir_host / str(helper_num) / "vault" / "rw",
|
|
300
|
+
extra_mounts=mounts or None,
|
|
301
|
+
enable_vault=True,
|
|
302
|
+
env=ctx.env,
|
|
303
|
+
name=container_name,
|
|
304
|
+
entrypoint=init_script,
|
|
305
|
+
cli_args=cli_args,
|
|
306
|
+
detach=True,
|
|
307
|
+
)
|
|
308
|
+
except Exception as e:
|
|
309
|
+
return {"status": "error", "message": str(e)}
|
|
310
|
+
|
|
311
|
+
if rc != 0:
|
|
312
|
+
return {"status": "error", "message": f"container exited with {rc}"}
|
|
313
|
+
|
|
314
|
+
with self._containers_lock:
|
|
315
|
+
self._containers.append(container_name)
|
|
316
|
+
|
|
317
|
+
if self._log:
|
|
318
|
+
self._log.log_control(
|
|
319
|
+
"spawn", helper_num,
|
|
320
|
+
model=request.get("model"),
|
|
321
|
+
)
|
|
322
|
+
|
|
323
|
+
return {"status": "ok", "container_name": container_name}
|
|
324
|
+
|
|
325
|
+
def _handle_stop(self, request: dict) -> dict:
|
|
326
|
+
"""Stop and remove a helper container."""
|
|
327
|
+
ctx = self._ctx
|
|
328
|
+
if ctx is None:
|
|
329
|
+
return {"status": "error", "message": "no context"}
|
|
330
|
+
|
|
331
|
+
container_name = request.get("container_name", "")
|
|
332
|
+
if not container_name:
|
|
333
|
+
return {"status": "error", "message": "missing container_name"}
|
|
334
|
+
|
|
335
|
+
ctx.runtime.stop(container_name)
|
|
336
|
+
ctx.runtime.rm(container_name)
|
|
337
|
+
|
|
338
|
+
with self._containers_lock:
|
|
339
|
+
if container_name in self._containers:
|
|
340
|
+
self._containers.remove(container_name)
|
|
341
|
+
|
|
342
|
+
# Extract helper_num from container name if possible
|
|
343
|
+
helper_num = _parse_helper_num(container_name)
|
|
344
|
+
if self._log and helper_num is not None:
|
|
345
|
+
self._log.log_control("stop", helper_num)
|
|
346
|
+
|
|
347
|
+
return {"status": "ok"}
|
|
348
|
+
|
|
349
|
+
def _handle_fork(self, request: dict) -> dict:
|
|
350
|
+
"""Fork the current project into a sibling directory."""
|
|
351
|
+
ctx = self._ctx
|
|
352
|
+
if ctx is None:
|
|
353
|
+
return {"status": "error", "message": "no context"}
|
|
354
|
+
if ctx.project_path is None or ctx.data_path is None:
|
|
355
|
+
return {"status": "error", "message": "fork requires project_path and data_path"}
|
|
356
|
+
|
|
357
|
+
name = request.get("name", "")
|
|
358
|
+
if not name:
|
|
359
|
+
return {"status": "error", "message": "missing fork name"}
|
|
360
|
+
|
|
361
|
+
# Validate name: no path separators, no dots, not empty after strip
|
|
362
|
+
name = name.strip()
|
|
363
|
+
if not name or "/" in name or "\\" in name or "." in name:
|
|
364
|
+
return {"status": "error", "message": "invalid fork name (no slashes or dots)"}
|
|
365
|
+
|
|
366
|
+
# Compute destination
|
|
367
|
+
new_path = ctx.project_path.parent / f"{ctx.project_path.name}.{name}"
|
|
368
|
+
if new_path.exists():
|
|
369
|
+
return {"status": "error", "message": f"destination already exists: {new_path}"}
|
|
370
|
+
|
|
371
|
+
# Copy workspace
|
|
372
|
+
try:
|
|
373
|
+
shutil.copytree(ctx.project_path, new_path)
|
|
374
|
+
except Exception as e:
|
|
375
|
+
return {"status": "error", "message": f"workspace copy failed: {e}"}
|
|
376
|
+
|
|
377
|
+
# Resolve source metadata dir via names.yaml reverse lookup
|
|
378
|
+
from kanibako.names import assign_name, read_names
|
|
379
|
+
|
|
380
|
+
boxes_base = ctx.boxes or (ctx.data_path / "boxes")
|
|
381
|
+
source_meta_dir: Path | None = None
|
|
382
|
+
names = read_names(ctx.data_path)
|
|
383
|
+
for rname, rpath in names["projects"].items():
|
|
384
|
+
if rpath == str(ctx.project_path):
|
|
385
|
+
candidate = boxes_base / rname
|
|
386
|
+
if candidate.is_dir():
|
|
387
|
+
source_meta_dir = candidate
|
|
388
|
+
break
|
|
389
|
+
|
|
390
|
+
# Fallback: derive from shell_path (shell_path is typically boxes/{name}/shell/)
|
|
391
|
+
if source_meta_dir is None:
|
|
392
|
+
candidate = ctx.shell_path.parent
|
|
393
|
+
if candidate.is_dir() and candidate.parent.name == "boxes":
|
|
394
|
+
source_meta_dir = candidate
|
|
395
|
+
|
|
396
|
+
# Assign a new name for the fork
|
|
397
|
+
try:
|
|
398
|
+
new_name = assign_name(ctx.data_path, str(new_path))
|
|
399
|
+
except Exception as e:
|
|
400
|
+
return {"status": "error", "message": f"name assignment failed: {e}"}
|
|
401
|
+
|
|
402
|
+
# Copy metadata if we found the source
|
|
403
|
+
if source_meta_dir is not None:
|
|
404
|
+
new_meta_dir = boxes_base / new_name
|
|
405
|
+
try:
|
|
406
|
+
if not new_meta_dir.exists():
|
|
407
|
+
shutil.copytree(
|
|
408
|
+
source_meta_dir, new_meta_dir,
|
|
409
|
+
ignore=shutil.ignore_patterns(
|
|
410
|
+
".kanibako.lock", "helpers",
|
|
411
|
+
),
|
|
412
|
+
)
|
|
413
|
+
except Exception as e:
|
|
414
|
+
logger.warning("metadata copy failed: %s", e)
|
|
415
|
+
|
|
416
|
+
if self._log:
|
|
417
|
+
self._log.log_control("fork", name=name, path=str(new_path),
|
|
418
|
+
new_name=new_name)
|
|
419
|
+
|
|
420
|
+
return {"status": "ok", "path": str(new_path), "name": new_name}
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
_LOG_MAX_BYTES = 1_048_576 # 1 MiB
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
class MessageLog:
|
|
427
|
+
"""Append-only JSONL log with size-based rotation."""
|
|
428
|
+
|
|
429
|
+
def __init__(self, log_path: Path) -> None:
|
|
430
|
+
self._path = log_path
|
|
431
|
+
log_path.parent.mkdir(parents=True, exist_ok=True)
|
|
432
|
+
self._file = open(log_path, "a")
|
|
433
|
+
self._lock = threading.Lock()
|
|
434
|
+
|
|
435
|
+
def log_message(self, sender: int, recipient: int | str,
|
|
436
|
+
payload: dict) -> None:
|
|
437
|
+
"""Record a message event."""
|
|
438
|
+
self._write({
|
|
439
|
+
"type": "message",
|
|
440
|
+
"from": sender,
|
|
441
|
+
"to": recipient,
|
|
442
|
+
"payload": payload,
|
|
443
|
+
})
|
|
444
|
+
|
|
445
|
+
def log_control(self, event: str, helper: int | None = None,
|
|
446
|
+
**extra: Any) -> None:
|
|
447
|
+
"""Record a control event (spawn, stop, register, disconnect)."""
|
|
448
|
+
entry: dict[str, Any] = {"type": "control", "event": event}
|
|
449
|
+
if helper is not None:
|
|
450
|
+
entry["helper"] = helper
|
|
451
|
+
entry.update(extra)
|
|
452
|
+
self._write(entry)
|
|
453
|
+
|
|
454
|
+
def _write(self, entry: dict) -> None:
|
|
455
|
+
from datetime import datetime, timezone
|
|
456
|
+
entry["ts"] = datetime.now(timezone.utc).isoformat()
|
|
457
|
+
with self._lock:
|
|
458
|
+
self._file.write(json.dumps(entry) + "\n")
|
|
459
|
+
self._file.flush()
|
|
460
|
+
self._rotate_if_needed()
|
|
461
|
+
|
|
462
|
+
def _rotate_if_needed(self) -> None:
|
|
463
|
+
"""Rotate log file when it exceeds the size threshold.
|
|
464
|
+
|
|
465
|
+
Caller must hold ``self._lock``.
|
|
466
|
+
"""
|
|
467
|
+
try:
|
|
468
|
+
pos = self._file.tell()
|
|
469
|
+
except OSError:
|
|
470
|
+
return
|
|
471
|
+
if pos < _LOG_MAX_BYTES:
|
|
472
|
+
return
|
|
473
|
+
self._file.close()
|
|
474
|
+
backup = self._path.with_suffix(self._path.suffix + ".1")
|
|
475
|
+
self._path.rename(backup)
|
|
476
|
+
self._file = open(self._path, "a")
|
|
477
|
+
|
|
478
|
+
def close(self) -> None:
|
|
479
|
+
with self._lock:
|
|
480
|
+
self._file.close()
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
def _send_json(conn: socket.socket, data: dict) -> None:
|
|
484
|
+
"""Send a JSON object followed by newline."""
|
|
485
|
+
conn.sendall(json.dumps(data).encode() + b"\n")
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
def _build_helper_mounts(ctx: HelperContext, helper_num: int,
|
|
489
|
+
helpers_dir: Path) -> list[Mount]:
|
|
490
|
+
"""Build bind mounts for a helper container."""
|
|
491
|
+
helper_root = helpers_dir / str(helper_num)
|
|
492
|
+
mounts: list[Mount] = []
|
|
493
|
+
|
|
494
|
+
# Peers directory
|
|
495
|
+
peers_dir = helper_root / "peers"
|
|
496
|
+
if peers_dir.is_dir():
|
|
497
|
+
mounts.append(Mount(peers_dir, "/home/agent/peers", "Z,U"))
|
|
498
|
+
|
|
499
|
+
# Broadcast directory
|
|
500
|
+
all_link = helper_root / "all"
|
|
501
|
+
if all_link.exists():
|
|
502
|
+
mounts.append(Mount(all_link, "/home/agent/all", "Z,U"))
|
|
503
|
+
|
|
504
|
+
# Spawn config (read-only)
|
|
505
|
+
spawn_toml = helper_root / "spawn.yaml"
|
|
506
|
+
if spawn_toml.is_file():
|
|
507
|
+
mounts.append(Mount(spawn_toml, "/home/agent/spawn.yaml", "ro"))
|
|
508
|
+
|
|
509
|
+
# Helper socket — mount the hub socket into the helper
|
|
510
|
+
if ctx.socket_path.exists():
|
|
511
|
+
kanibako_dir = helper_root / ".local" / "state" / "kanibako"
|
|
512
|
+
kanibako_dir.mkdir(parents=True, exist_ok=True)
|
|
513
|
+
mounts.append(
|
|
514
|
+
Mount(ctx.socket_path, "/home/agent/.local/state/kanibako/helper.sock", "")
|
|
515
|
+
)
|
|
516
|
+
|
|
517
|
+
# Target binary mounts (same agent binary as the director)
|
|
518
|
+
mounts.extend(ctx.binary_mounts)
|
|
519
|
+
|
|
520
|
+
return mounts
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
def _parse_helper_num(container_name: str) -> int | None:
|
|
524
|
+
"""Extract helper number from a container name.
|
|
525
|
+
|
|
526
|
+
Handles both formats:
|
|
527
|
+
- New: ``kanibako-{name}-helper-{N}``
|
|
528
|
+
- Legacy: ``kanibako-helper-{N}-{hash}``
|
|
529
|
+
"""
|
|
530
|
+
parts = container_name.split("-")
|
|
531
|
+
# Walk backwards looking for "helper" followed by a numeric part.
|
|
532
|
+
for i in range(len(parts) - 1, 0, -1):
|
|
533
|
+
if parts[i - 1] == "helper":
|
|
534
|
+
try:
|
|
535
|
+
return int(parts[i])
|
|
536
|
+
except ValueError:
|
|
537
|
+
pass
|
|
538
|
+
return None
|