kiwi-code 0.0.4__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.
- kiwi_code-0.0.4.dist-info/METADATA +234 -0
- kiwi_code-0.0.4.dist-info/RECORD +24 -0
- kiwi_code-0.0.4.dist-info/WHEEL +4 -0
- kiwi_code-0.0.4.dist-info/entry_points.txt +4 -0
- kiwi_runtime/__init__.py +3 -0
- kiwi_runtime/__main__.py +5 -0
- kiwi_runtime/main.py +989 -0
- kiwi_tui/__init__.py +3 -0
- kiwi_tui/auth.py +125 -0
- kiwi_tui/cli.py +243 -0
- kiwi_tui/client.py +539 -0
- kiwi_tui/commands.py +434 -0
- kiwi_tui/config.py +79 -0
- kiwi_tui/logger.py +32 -0
- kiwi_tui/main.py +337 -0
- kiwi_tui/models.py +85 -0
- kiwi_tui/runtime_manager.py +130 -0
- kiwi_tui/screens/__init__.py +9 -0
- kiwi_tui/screens/actions.py +271 -0
- kiwi_tui/screens/autobots.py +216 -0
- kiwi_tui/screens/dashboard.py +608 -0
- kiwi_tui/screens/login.py +320 -0
- kiwi_tui/screens/runtime_logs.py +96 -0
- kiwi_tui/widgets.py +197 -0
kiwi_tui/main.py
ADDED
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
"""Main entry point for Autobots TUI application."""
|
|
2
|
+
|
|
3
|
+
import collections
|
|
4
|
+
import os
|
|
5
|
+
import subprocess
|
|
6
|
+
|
|
7
|
+
from textual.app import App
|
|
8
|
+
from textual.binding import Binding
|
|
9
|
+
from loguru import logger
|
|
10
|
+
|
|
11
|
+
from .logger import setup_logging
|
|
12
|
+
from .config import ConfigManager
|
|
13
|
+
from .client import AutobotsClientWrapper
|
|
14
|
+
from .auth import TokenManager
|
|
15
|
+
from . import runtime_manager
|
|
16
|
+
from .screens import LoginScreen, DashboardScreen, AutobotsScreen, ActionsScreen, RuntimeLogsScreen
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class AutobotsTUI(App):
|
|
20
|
+
"""A modern Textual app for managing Autobots."""
|
|
21
|
+
|
|
22
|
+
CSS = """
|
|
23
|
+
Screen {
|
|
24
|
+
background: $surface;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
Header {
|
|
28
|
+
background: #006666;
|
|
29
|
+
color: #00ffff;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
Footer {
|
|
33
|
+
background: #006666;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
Footer > .footer--key {
|
|
37
|
+
background: #008888;
|
|
38
|
+
color: #00ffff;
|
|
39
|
+
}
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
TITLE = "Kiwi Code"
|
|
43
|
+
SUB_TITLE = ""
|
|
44
|
+
|
|
45
|
+
SCREENS = {
|
|
46
|
+
"login": LoginScreen,
|
|
47
|
+
"dashboard": DashboardScreen,
|
|
48
|
+
"autobots": AutobotsScreen,
|
|
49
|
+
"actions": ActionsScreen,
|
|
50
|
+
"runtime_logs": RuntimeLogsScreen,
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
BINDINGS = [
|
|
54
|
+
Binding("ctrl+c", "quit", "Quit", show=True),
|
|
55
|
+
Binding("ctrl+d", "toggle_dark", "Toggle Dark Mode", show=False),
|
|
56
|
+
Binding("ctrl+r", "runtime_logs", "Runtime Logs", show=True),
|
|
57
|
+
Binding("ctrl+l", "logout", "Logout", show=False),
|
|
58
|
+
]
|
|
59
|
+
|
|
60
|
+
# Map backend HTTP URLs to kiwi connect server presets
|
|
61
|
+
_KIWI_SERVER_MAP = {
|
|
62
|
+
"https://api.meetkiwi.ai": "app",
|
|
63
|
+
"https://dev.api.myautobots.com": "dev",
|
|
64
|
+
"http://localhost:8000": "local",
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
# Max lines to keep in the runtime log buffer
|
|
68
|
+
_KIWI_LOG_BUFFER_SIZE = 5000
|
|
69
|
+
|
|
70
|
+
def __init__(self, config_manager: ConfigManager | None = None, token_manager: TokenManager | None = None):
|
|
71
|
+
"""Initialize Autobots TUI app.
|
|
72
|
+
|
|
73
|
+
Args:
|
|
74
|
+
config_manager: Configuration manager instance
|
|
75
|
+
token_manager: Token manager instance
|
|
76
|
+
"""
|
|
77
|
+
super().__init__()
|
|
78
|
+
self.config_manager = config_manager or ConfigManager()
|
|
79
|
+
self.config = self.config_manager.config
|
|
80
|
+
|
|
81
|
+
self._kiwi_token = None # Set after auth for kiwi CLI session
|
|
82
|
+
self._kiwi_pid = None # PID of the runtime process
|
|
83
|
+
self._log_fp = None # File pointer for tailing runtime log
|
|
84
|
+
self._kiwi_log_lines = collections.deque(maxlen=self._KIWI_LOG_BUFFER_SIZE)
|
|
85
|
+
|
|
86
|
+
# Initialize token manager
|
|
87
|
+
self.token_manager = token_manager or TokenManager()
|
|
88
|
+
|
|
89
|
+
# Check for existing tokens
|
|
90
|
+
tokens = self.token_manager.load_tokens()
|
|
91
|
+
access_token = None
|
|
92
|
+
|
|
93
|
+
if tokens:
|
|
94
|
+
# Check if token needs refresh
|
|
95
|
+
if tokens.is_expired():
|
|
96
|
+
logger.info("Access token expired, attempting refresh")
|
|
97
|
+
# Try to refresh token
|
|
98
|
+
temp_client = AutobotsClientWrapper(base_url=self.config.backend_url)
|
|
99
|
+
success, new_tokens, message = temp_client.refresh_token(tokens.refresh_token)
|
|
100
|
+
|
|
101
|
+
if success and new_tokens:
|
|
102
|
+
logger.info("Token refreshed successfully")
|
|
103
|
+
self.token_manager.save_tokens(new_tokens)
|
|
104
|
+
access_token = new_tokens.access_token
|
|
105
|
+
else:
|
|
106
|
+
logger.warning(f"Token refresh failed: {message}")
|
|
107
|
+
self.token_manager.clear_tokens()
|
|
108
|
+
else:
|
|
109
|
+
logger.info("Valid access token found")
|
|
110
|
+
access_token = tokens.access_token
|
|
111
|
+
|
|
112
|
+
# Share token with kiwi CLI session
|
|
113
|
+
self._kiwi_token = access_token
|
|
114
|
+
|
|
115
|
+
# Initialize autobots client
|
|
116
|
+
self.autobots_client = AutobotsClientWrapper(
|
|
117
|
+
base_url=self.config.backend_url,
|
|
118
|
+
access_token=access_token,
|
|
119
|
+
api_key=self.config.api_key,
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
def on_mount(self) -> None:
|
|
123
|
+
"""Called when app is mounted."""
|
|
124
|
+
logger.info("Autobots TUI on_mount called")
|
|
125
|
+
logger.info(f"Backend URL: {self.config.backend_url}")
|
|
126
|
+
|
|
127
|
+
# Set theme
|
|
128
|
+
self.dark = self.config.theme == "dark"
|
|
129
|
+
|
|
130
|
+
# Check if user is authenticated
|
|
131
|
+
if self.token_manager.is_authenticated():
|
|
132
|
+
logger.info("User is authenticated, showing dashboard")
|
|
133
|
+
self._start_kiwi_session()
|
|
134
|
+
try:
|
|
135
|
+
self.switch_screen("dashboard")
|
|
136
|
+
except IndexError:
|
|
137
|
+
self.push_screen("dashboard")
|
|
138
|
+
else:
|
|
139
|
+
logger.info("User not authenticated, showing login screen")
|
|
140
|
+
try:
|
|
141
|
+
self.switch_screen("login")
|
|
142
|
+
except IndexError:
|
|
143
|
+
self.push_screen("login")
|
|
144
|
+
|
|
145
|
+
@property
|
|
146
|
+
def runtime_log_cmd(self) -> str:
|
|
147
|
+
"""Derive the kiwi connect command from the configured backend_url."""
|
|
148
|
+
# Allow env var override
|
|
149
|
+
override = os.getenv("KIWI_CODE_RUNTIME_LOG_CMD")
|
|
150
|
+
if override:
|
|
151
|
+
return override
|
|
152
|
+
|
|
153
|
+
import sys
|
|
154
|
+
python = sys.executable
|
|
155
|
+
|
|
156
|
+
backend_url = self.config.backend_url.rstrip("/")
|
|
157
|
+
server = self._KIWI_SERVER_MAP.get(backend_url)
|
|
158
|
+
if server:
|
|
159
|
+
cmd = f"{python} -m kiwi_runtime connect --server {server}"
|
|
160
|
+
else:
|
|
161
|
+
# Custom URL — convert http(s) to ws(s)
|
|
162
|
+
ws_url = backend_url.replace("https://", "wss://").replace("http://", "ws://")
|
|
163
|
+
cmd = f"{python} -m kiwi_runtime connect --server {ws_url}"
|
|
164
|
+
|
|
165
|
+
return cmd
|
|
166
|
+
|
|
167
|
+
# ------------------------------------------------------------------
|
|
168
|
+
# Persistent kiwi runtime (per working directory, survives TUI restarts)
|
|
169
|
+
# ------------------------------------------------------------------
|
|
170
|
+
|
|
171
|
+
def _start_kiwi_session(self) -> None:
|
|
172
|
+
"""Start a new kiwi-runtime for this directory, or attach if already running."""
|
|
173
|
+
existing_pid = runtime_manager.get_running_pid()
|
|
174
|
+
if existing_pid:
|
|
175
|
+
logger.info(f"Kiwi runtime already running (PID {existing_pid})")
|
|
176
|
+
self._kiwi_pid = existing_pid
|
|
177
|
+
self._start_log_tail()
|
|
178
|
+
return
|
|
179
|
+
|
|
180
|
+
cmd = self.runtime_log_cmd
|
|
181
|
+
if not cmd:
|
|
182
|
+
return
|
|
183
|
+
|
|
184
|
+
logger.info(f"Starting kiwi runtime: {cmd}")
|
|
185
|
+
try:
|
|
186
|
+
env = os.environ.copy()
|
|
187
|
+
env["PYTHONUNBUFFERED"] = "1"
|
|
188
|
+
|
|
189
|
+
token = self._kiwi_token
|
|
190
|
+
if token:
|
|
191
|
+
env["KIWI_AUTH_TOKEN"] = token
|
|
192
|
+
|
|
193
|
+
rt_log_path = runtime_manager.log_path()
|
|
194
|
+
log_fp = open(rt_log_path, "a")
|
|
195
|
+
|
|
196
|
+
proc = subprocess.Popen(
|
|
197
|
+
cmd,
|
|
198
|
+
shell=True,
|
|
199
|
+
stdin=subprocess.DEVNULL,
|
|
200
|
+
stdout=log_fp,
|
|
201
|
+
stderr=subprocess.STDOUT,
|
|
202
|
+
start_new_session=True,
|
|
203
|
+
env=env,
|
|
204
|
+
)
|
|
205
|
+
log_fp.close() # Child inherited the fd via fork
|
|
206
|
+
|
|
207
|
+
runtime_manager.save_pid(proc.pid)
|
|
208
|
+
self._kiwi_pid = proc.pid
|
|
209
|
+
|
|
210
|
+
self._start_log_tail()
|
|
211
|
+
logger.info(f"Kiwi runtime started (PID {proc.pid})")
|
|
212
|
+
except Exception as e:
|
|
213
|
+
logger.error(f"Failed to start kiwi runtime: {e}")
|
|
214
|
+
|
|
215
|
+
def _start_log_tail(self) -> None:
|
|
216
|
+
"""Open the runtime log file for tailing."""
|
|
217
|
+
if self._log_fp is not None:
|
|
218
|
+
return # Already tailing
|
|
219
|
+
|
|
220
|
+
rt_log_path = runtime_manager.log_path()
|
|
221
|
+
if not rt_log_path.exists():
|
|
222
|
+
return
|
|
223
|
+
|
|
224
|
+
try:
|
|
225
|
+
self._log_fp = open(rt_log_path, "r", encoding="utf-8", errors="replace")
|
|
226
|
+
# Seek near end so we don't replay the entire history
|
|
227
|
+
size = rt_log_path.stat().st_size
|
|
228
|
+
if size > 65536:
|
|
229
|
+
self._log_fp.seek(size - 65536)
|
|
230
|
+
self.set_interval(0.1, self._drain_kiwi_output)
|
|
231
|
+
logger.info("Started tailing runtime log")
|
|
232
|
+
except OSError as e:
|
|
233
|
+
logger.error(f"Failed to tail runtime log: {e}")
|
|
234
|
+
|
|
235
|
+
def _drain_kiwi_output(self) -> None:
|
|
236
|
+
"""Read new lines from the runtime log file and buffer them."""
|
|
237
|
+
fp = self._log_fp
|
|
238
|
+
if fp is None:
|
|
239
|
+
return
|
|
240
|
+
|
|
241
|
+
chunk = fp.read()
|
|
242
|
+
if not chunk:
|
|
243
|
+
# No new output — check if runtime is still alive
|
|
244
|
+
if self._kiwi_pid:
|
|
245
|
+
try:
|
|
246
|
+
os.kill(self._kiwi_pid, 0)
|
|
247
|
+
except ProcessLookupError:
|
|
248
|
+
logger.info("Kiwi runtime exited, restarting...")
|
|
249
|
+
self._kiwi_pid = None
|
|
250
|
+
self._close_log_tail()
|
|
251
|
+
self._start_kiwi_session()
|
|
252
|
+
return
|
|
253
|
+
|
|
254
|
+
for line in chunk.splitlines():
|
|
255
|
+
self._kiwi_log_lines.append(line)
|
|
256
|
+
|
|
257
|
+
def _close_log_tail(self) -> None:
|
|
258
|
+
"""Close the log file we are tailing (does NOT stop the runtime)."""
|
|
259
|
+
fp = self._log_fp
|
|
260
|
+
if fp:
|
|
261
|
+
try:
|
|
262
|
+
fp.close()
|
|
263
|
+
except OSError:
|
|
264
|
+
pass
|
|
265
|
+
self._log_fp = None
|
|
266
|
+
|
|
267
|
+
def _stop_runtime(self) -> None:
|
|
268
|
+
"""Explicitly stop the kiwi-runtime for the current directory."""
|
|
269
|
+
runtime_manager.stop_runtime()
|
|
270
|
+
self._kiwi_pid = None
|
|
271
|
+
self._close_log_tail()
|
|
272
|
+
|
|
273
|
+
# ------------------------------------------------------------------
|
|
274
|
+
|
|
275
|
+
def action_runtime_logs(self) -> None:
|
|
276
|
+
"""Show the runtime log stream."""
|
|
277
|
+
self.push_screen("runtime_logs")
|
|
278
|
+
|
|
279
|
+
def action_toggle_dark(self) -> None:
|
|
280
|
+
"""Toggle dark mode."""
|
|
281
|
+
self.dark = not self.dark
|
|
282
|
+
theme = "dark" if self.dark else "light"
|
|
283
|
+
self.config_manager.update(theme=theme)
|
|
284
|
+
logger.info(f"Theme changed to {theme}")
|
|
285
|
+
self.notify(f"Theme: {theme}", severity="information")
|
|
286
|
+
|
|
287
|
+
def action_logout(self) -> None:
|
|
288
|
+
"""Logout user and return to login screen."""
|
|
289
|
+
logger.info("User logout requested")
|
|
290
|
+
|
|
291
|
+
# Detach from runtime logs — do NOT stop the runtime process
|
|
292
|
+
self._close_log_tail()
|
|
293
|
+
self._kiwi_pid = None
|
|
294
|
+
|
|
295
|
+
# Clear tokens
|
|
296
|
+
self.token_manager.clear_tokens()
|
|
297
|
+
|
|
298
|
+
# Reset client to unauthenticated
|
|
299
|
+
self.autobots_client = AutobotsClientWrapper(
|
|
300
|
+
base_url=self.config.backend_url,
|
|
301
|
+
)
|
|
302
|
+
|
|
303
|
+
# Switch to login screen
|
|
304
|
+
self.switch_screen("login")
|
|
305
|
+
self.notify("Logged out successfully", severity="information")
|
|
306
|
+
|
|
307
|
+
def action_quit(self) -> None:
|
|
308
|
+
"""Quit the application."""
|
|
309
|
+
logger.info("Autobots TUI shutting down")
|
|
310
|
+
# Detach from runtime logs — do NOT stop the runtime process
|
|
311
|
+
self._close_log_tail()
|
|
312
|
+
# Close autobots client connection
|
|
313
|
+
if hasattr(self, 'autobots_client'):
|
|
314
|
+
self.autobots_client.close()
|
|
315
|
+
self.exit()
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def main():
|
|
319
|
+
"""Run the Autobots TUI application."""
|
|
320
|
+
# Setup logging
|
|
321
|
+
config_manager = ConfigManager()
|
|
322
|
+
config = config_manager.load()
|
|
323
|
+
setup_logging(log_level=config.log_level)
|
|
324
|
+
|
|
325
|
+
logger.info("="*60)
|
|
326
|
+
logger.info("Starting Autobots TUI")
|
|
327
|
+
logger.info("="*60)
|
|
328
|
+
|
|
329
|
+
# Run app
|
|
330
|
+
app = AutobotsTUI(config_manager=config_manager)
|
|
331
|
+
app.run()
|
|
332
|
+
|
|
333
|
+
logger.info("Autobots TUI terminated")
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
if __name__ == "__main__":
|
|
337
|
+
main()
|
kiwi_tui/models.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""Pydantic models for Autobots TUI."""
|
|
2
|
+
|
|
3
|
+
from datetime import datetime, timedelta
|
|
4
|
+
from enum import Enum
|
|
5
|
+
from typing import Optional, Any
|
|
6
|
+
from pydantic import BaseModel, Field
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class ActionStatus(str, Enum):
|
|
10
|
+
"""Status of an action execution."""
|
|
11
|
+
PENDING = "pending"
|
|
12
|
+
RUNNING = "running"
|
|
13
|
+
COMPLETED = "completed"
|
|
14
|
+
FAILED = "failed"
|
|
15
|
+
CANCELLED = "cancelled"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class AutobotType(str, Enum):
|
|
19
|
+
"""Type of autobot."""
|
|
20
|
+
CHAT = "chat"
|
|
21
|
+
SEARCH = "search"
|
|
22
|
+
AUTOMATION = "automation"
|
|
23
|
+
ANALYSIS = "analysis"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class Autobot(BaseModel):
|
|
27
|
+
"""Represents an autobot instance."""
|
|
28
|
+
id: str
|
|
29
|
+
name: str
|
|
30
|
+
type: AutobotType
|
|
31
|
+
description: str
|
|
32
|
+
enabled: bool = True
|
|
33
|
+
created_at: datetime = Field(default_factory=datetime.now)
|
|
34
|
+
last_run: Optional[datetime] = None
|
|
35
|
+
run_count: int = 0
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class Action(BaseModel):
|
|
39
|
+
"""Represents an action that can be executed."""
|
|
40
|
+
id: str
|
|
41
|
+
name: str
|
|
42
|
+
description: str
|
|
43
|
+
autobot_id: str
|
|
44
|
+
parameters: dict[str, Any] = Field(default_factory=dict)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class ActionExecution(BaseModel):
|
|
48
|
+
"""Represents an action execution instance."""
|
|
49
|
+
id: str
|
|
50
|
+
action_id: str
|
|
51
|
+
status: ActionStatus
|
|
52
|
+
started_at: datetime = Field(default_factory=datetime.now)
|
|
53
|
+
completed_at: Optional[datetime] = None
|
|
54
|
+
result: Optional[dict[str, Any]] = None
|
|
55
|
+
error: Optional[str] = None
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class AuthTokens(BaseModel):
|
|
59
|
+
"""Authentication tokens."""
|
|
60
|
+
access_token: str
|
|
61
|
+
refresh_token: str
|
|
62
|
+
token_type: str = "Bearer"
|
|
63
|
+
expires_at: Optional[datetime] = None
|
|
64
|
+
|
|
65
|
+
def is_expired(self) -> bool:
|
|
66
|
+
"""Check if access token is expired."""
|
|
67
|
+
if not self.expires_at:
|
|
68
|
+
return False
|
|
69
|
+
# Add 60 second buffer before expiry
|
|
70
|
+
return datetime.now() >= (self.expires_at - timedelta(seconds=60))
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class LoginCredentials(BaseModel):
|
|
74
|
+
"""Login credentials."""
|
|
75
|
+
username: str
|
|
76
|
+
password: str
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class AppConfig(BaseModel):
|
|
80
|
+
"""Application configuration."""
|
|
81
|
+
backend_url: str = "https://dev.api.myautobots.com"#"https://api.meetkiwi.ai"
|
|
82
|
+
api_key: Optional[str] = None
|
|
83
|
+
log_level: str = "INFO"
|
|
84
|
+
theme: str = "dark"
|
|
85
|
+
refresh_interval: int = 5
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""Shared helpers for managing per-directory kiwi-runtime processes.
|
|
2
|
+
|
|
3
|
+
Each working directory gets its own runtime, identified by a hash of the
|
|
4
|
+
absolute path. State is stored under ~/.autobots-tui/runtimes/<key>/:
|
|
5
|
+
pid — PID of the running process
|
|
6
|
+
log — stdout/stderr of the runtime
|
|
7
|
+
cwd — the working directory this runtime is scoped to
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import hashlib
|
|
11
|
+
import os
|
|
12
|
+
import signal
|
|
13
|
+
import time
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
RUNTIMES_DIR = Path.home() / ".autobots-tui" / "runtimes"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def runtime_key(cwd: str | None = None) -> str:
|
|
20
|
+
"""Generate a short hash key for a working directory."""
|
|
21
|
+
if cwd is None:
|
|
22
|
+
cwd = os.getcwd()
|
|
23
|
+
return hashlib.sha256(os.path.realpath(cwd).encode()).hexdigest()[:12]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def runtime_dir(cwd: str | None = None) -> Path:
|
|
27
|
+
"""Return the runtime state directory for a given working directory."""
|
|
28
|
+
d = RUNTIMES_DIR / runtime_key(cwd)
|
|
29
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
30
|
+
return d
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def get_running_pid(cwd: str | None = None) -> int | None:
|
|
34
|
+
"""Return PID of a running runtime for the directory, or None."""
|
|
35
|
+
pid_path = runtime_dir(cwd) / "pid"
|
|
36
|
+
if not pid_path.exists():
|
|
37
|
+
return None
|
|
38
|
+
try:
|
|
39
|
+
pid = int(pid_path.read_text().strip())
|
|
40
|
+
os.kill(pid, 0)
|
|
41
|
+
return pid
|
|
42
|
+
except (ValueError, ProcessLookupError, PermissionError):
|
|
43
|
+
pid_path.unlink(missing_ok=True)
|
|
44
|
+
return None
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def save_pid(pid: int, cwd: str | None = None) -> None:
|
|
48
|
+
"""Record a runtime PID and its working directory."""
|
|
49
|
+
if cwd is None:
|
|
50
|
+
cwd = os.getcwd()
|
|
51
|
+
rd = runtime_dir(cwd)
|
|
52
|
+
(rd / "pid").write_text(str(pid))
|
|
53
|
+
(rd / "cwd").write_text(os.path.realpath(cwd))
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def log_path(cwd: str | None = None) -> Path:
|
|
57
|
+
"""Return the log file path for a directory's runtime."""
|
|
58
|
+
return runtime_dir(cwd) / "log"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def stop_runtime(cwd: str | None = None) -> bool:
|
|
62
|
+
"""Stop the runtime for a directory. Returns True if a process was stopped."""
|
|
63
|
+
pid = get_running_pid(cwd)
|
|
64
|
+
if not pid:
|
|
65
|
+
return False
|
|
66
|
+
|
|
67
|
+
try:
|
|
68
|
+
os.kill(pid, signal.SIGTERM)
|
|
69
|
+
for _ in range(20):
|
|
70
|
+
time.sleep(0.1)
|
|
71
|
+
try:
|
|
72
|
+
os.kill(pid, 0)
|
|
73
|
+
except ProcessLookupError:
|
|
74
|
+
break
|
|
75
|
+
else:
|
|
76
|
+
try:
|
|
77
|
+
os.kill(pid, signal.SIGKILL)
|
|
78
|
+
except ProcessLookupError:
|
|
79
|
+
pass
|
|
80
|
+
except ProcessLookupError:
|
|
81
|
+
pass
|
|
82
|
+
|
|
83
|
+
pid_path = runtime_dir(cwd) / "pid"
|
|
84
|
+
pid_path.unlink(missing_ok=True)
|
|
85
|
+
return True
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def list_all() -> list[dict]:
|
|
89
|
+
"""List all known runtimes with their status."""
|
|
90
|
+
if not RUNTIMES_DIR.exists():
|
|
91
|
+
return []
|
|
92
|
+
|
|
93
|
+
results = []
|
|
94
|
+
for entry in sorted(RUNTIMES_DIR.iterdir()):
|
|
95
|
+
if not entry.is_dir():
|
|
96
|
+
continue
|
|
97
|
+
cwd_file = entry / "cwd"
|
|
98
|
+
pid_file = entry / "pid"
|
|
99
|
+
if not cwd_file.exists():
|
|
100
|
+
continue
|
|
101
|
+
|
|
102
|
+
cwd = cwd_file.read_text().strip()
|
|
103
|
+
pid = None
|
|
104
|
+
running = False
|
|
105
|
+
if pid_file.exists():
|
|
106
|
+
try:
|
|
107
|
+
pid = int(pid_file.read_text().strip())
|
|
108
|
+
os.kill(pid, 0)
|
|
109
|
+
running = True
|
|
110
|
+
except (ValueError, ProcessLookupError, PermissionError):
|
|
111
|
+
pid_file.unlink(missing_ok=True)
|
|
112
|
+
pid = None
|
|
113
|
+
|
|
114
|
+
results.append({
|
|
115
|
+
"key": entry.name,
|
|
116
|
+
"cwd": cwd,
|
|
117
|
+
"pid": pid,
|
|
118
|
+
"running": running,
|
|
119
|
+
})
|
|
120
|
+
return results
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def stop_all() -> int:
|
|
124
|
+
"""Stop all running runtimes. Returns count of processes stopped."""
|
|
125
|
+
count = 0
|
|
126
|
+
for rt in list_all():
|
|
127
|
+
if rt["running"]:
|
|
128
|
+
if stop_runtime(rt["cwd"]):
|
|
129
|
+
count += 1
|
|
130
|
+
return count
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""Screens for Autobots TUI."""
|
|
2
|
+
|
|
3
|
+
from .login import LoginScreen
|
|
4
|
+
from .dashboard import DashboardScreen
|
|
5
|
+
from .autobots import AutobotsScreen
|
|
6
|
+
from .actions import ActionsScreen
|
|
7
|
+
from .runtime_logs import RuntimeLogsScreen
|
|
8
|
+
|
|
9
|
+
__all__ = ["LoginScreen", "DashboardScreen", "AutobotsScreen", "ActionsScreen", "RuntimeLogsScreen"]
|