silicon-cli 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- silicon_cli/__init__.py +2 -0
- silicon_cli/cli.py +250 -0
- silicon_cli/config.py +29 -0
- silicon_cli/glassagent.py +66 -0
- silicon_cli/process.py +258 -0
- silicon_cli/registry.py +145 -0
- silicon_cli/stemcell.py +226 -0
- silicon_cli/sync.py +216 -0
- silicon_cli/ui.py +61 -0
- silicon_cli/update.py +80 -0
- silicon_cli-1.0.0.dist-info/METADATA +74 -0
- silicon_cli-1.0.0.dist-info/RECORD +16 -0
- silicon_cli-1.0.0.dist-info/WHEEL +5 -0
- silicon_cli-1.0.0.dist-info/entry_points.txt +2 -0
- silicon_cli-1.0.0.dist-info/licenses/LICENSE +21 -0
- silicon_cli-1.0.0.dist-info/top_level.txt +1 -0
silicon_cli/__init__.py
ADDED
silicon_cli/cli.py
ADDED
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
"""silicon — manage your silicon instances. Dispatch mirrors the original bash CLI."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import subprocess
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from . import glassagent, process, registry, stemcell, sync, ui, update
|
|
9
|
+
from .config import python_run_cmd
|
|
10
|
+
|
|
11
|
+
COMMANDS = ["start", "stop", "restart", "status", "browser", "debug", "attach",
|
|
12
|
+
"pull", "push", "update", "list", "install", "new", "help", "script", "agent"]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
# ----------------------------------------------------------------- commands
|
|
16
|
+
def cmd_list() -> None:
|
|
17
|
+
rows = registry.installs()
|
|
18
|
+
if not rows:
|
|
19
|
+
ui.info("No silicon installations found.")
|
|
20
|
+
ui.info("Run 'silicon install' to set up a new instance.")
|
|
21
|
+
return
|
|
22
|
+
print(f"\n{ui.BOLD}{ui.CYAN}Silicon Installations{ui.RESET}\n")
|
|
23
|
+
print(f" {ui.DIM}{'#':<4}{'NAME':<22}{'STATUS':<10}PATH{ui.RESET}")
|
|
24
|
+
print(f" {ui.DIM}{'---':<4}{'----':<22}{'------':<10}----{ui.RESET}")
|
|
25
|
+
for i in rows:
|
|
26
|
+
if process.is_running(i.pid_file):
|
|
27
|
+
pid = process.get_pid(i.pid_file)
|
|
28
|
+
status = f"{ui.GREEN}● running{ui.RESET}"
|
|
29
|
+
extra = f" {ui.DIM}(PID {pid}){ui.RESET}"
|
|
30
|
+
else:
|
|
31
|
+
status, extra = f"{ui.DIM}○ stopped{ui.RESET}", ""
|
|
32
|
+
print(f" {i.index + 1:<4}{i.name:<22}{status}{extra} {ui.DIM}{i.path}{ui.RESET}")
|
|
33
|
+
print()
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _print_status(inst) -> None:
|
|
37
|
+
if process.is_running(inst.pid_file):
|
|
38
|
+
pid = process.get_pid(inst.pid_file)
|
|
39
|
+
print(f"\n{ui.BOLD}{inst.name}{ui.RESET} {ui.GREEN}● running{ui.RESET} (PID {pid})")
|
|
40
|
+
else:
|
|
41
|
+
print(f"\n{ui.BOLD}{inst.name}{ui.RESET} {ui.DIM}○ stopped{ui.RESET}")
|
|
42
|
+
print(f"{ui.DIM} Path: {inst.path}{ui.RESET}\n")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def cmd_status(target: str | None) -> None:
|
|
46
|
+
if target:
|
|
47
|
+
inst = registry.find(target)
|
|
48
|
+
if not inst:
|
|
49
|
+
cmd_list()
|
|
50
|
+
return
|
|
51
|
+
_print_status(inst)
|
|
52
|
+
return
|
|
53
|
+
inst = registry.find()
|
|
54
|
+
if inst:
|
|
55
|
+
_print_status(inst)
|
|
56
|
+
else:
|
|
57
|
+
cmd_list()
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def cmd_browser(target: str | None) -> None:
|
|
61
|
+
inst = registry.resolve_one(target)
|
|
62
|
+
ui.info(f"Opening browser for '{inst.name}'...")
|
|
63
|
+
subprocess.run([python_run_cmd(), "main.py", "browser"], cwd=inst.path)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def cmd_debug(target: str | None) -> None:
|
|
67
|
+
inst = registry.resolve_one(target)
|
|
68
|
+
if not process.is_running(inst.pid_file):
|
|
69
|
+
ui.error(f"'{inst.name}' is not running. Start it first with: silicon start {inst.name}")
|
|
70
|
+
sys.exit(1)
|
|
71
|
+
log_file = Path(inst.path) / ".silicon.log"
|
|
72
|
+
if not log_file.exists():
|
|
73
|
+
ui.error(f"No log file found at {log_file}")
|
|
74
|
+
sys.exit(1)
|
|
75
|
+
pid = process.get_pid(inst.pid_file)
|
|
76
|
+
print(f"\n{ui.BOLD}{ui.CYAN}Debugging '{inst.name}'{ui.RESET} (PID {pid})")
|
|
77
|
+
print(f"{ui.DIM} Log: {log_file}{ui.RESET}")
|
|
78
|
+
print(f"{ui.DIM} Press Ctrl+C to detach{ui.RESET}\n")
|
|
79
|
+
try:
|
|
80
|
+
subprocess.run(["tail", "-f", str(log_file)])
|
|
81
|
+
except KeyboardInterrupt:
|
|
82
|
+
pass
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def cmd_attach(target_dir: str | None) -> None:
|
|
86
|
+
target = Path(target_dir or ".").resolve()
|
|
87
|
+
if not (target / "main.py").exists() or not (target / "config.py").exists():
|
|
88
|
+
ui.error("This doesn't look like a silicon directory.")
|
|
89
|
+
ui.info(f"Expected main.py and config.py in: {target}")
|
|
90
|
+
ui.info(" silicon attach /path/to/silicon")
|
|
91
|
+
sys.exit(1)
|
|
92
|
+
if not (target / "prompts").is_dir() or not (target / "core").is_dir():
|
|
93
|
+
ui.error("Missing prompts/ or core/ directory. Not a valid silicon.")
|
|
94
|
+
sys.exit(1)
|
|
95
|
+
|
|
96
|
+
for i in registry.installs():
|
|
97
|
+
if i.path == str(target):
|
|
98
|
+
ui.warn(f"This silicon is already registered as '{i.name}'")
|
|
99
|
+
return
|
|
100
|
+
|
|
101
|
+
ui.success(f"Found a silicon at: {target}")
|
|
102
|
+
name = ui.ask("Instance name", target.name)
|
|
103
|
+
if registry.name_taken(name):
|
|
104
|
+
ui.error(f"Name '{name}' is already taken. Pick a different one.")
|
|
105
|
+
sys.exit(1)
|
|
106
|
+
|
|
107
|
+
pid_file = target / ".silicon.pid"
|
|
108
|
+
running = process.is_running(str(pid_file))
|
|
109
|
+
registry.register(name, str(target), str(pid_file))
|
|
110
|
+
ui.success(f"Attached '{name}' at {target}")
|
|
111
|
+
if running:
|
|
112
|
+
print(f"\n {ui.BOLD}{name}{ui.RESET} {ui.GREEN}● running{ui.RESET} (PID {process.get_pid(str(pid_file))})\n")
|
|
113
|
+
else:
|
|
114
|
+
print(f"\n {ui.BOLD}{name}{ui.RESET} {ui.DIM}○ stopped{ui.RESET}")
|
|
115
|
+
print(f" Start it with: {ui.BOLD}silicon start {name}{ui.RESET}\n")
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def cmd_agent(subcmd: str | None, target: str | None) -> None:
|
|
119
|
+
if not subcmd:
|
|
120
|
+
ui.error("Usage: silicon agent <start|stop|status> [name]")
|
|
121
|
+
sys.exit(1)
|
|
122
|
+
inst = registry.resolve_one(target)
|
|
123
|
+
if subcmd == "start":
|
|
124
|
+
glassagent.start(inst.path)
|
|
125
|
+
elif subcmd == "stop":
|
|
126
|
+
glassagent.stop(inst.path)
|
|
127
|
+
elif subcmd == "status":
|
|
128
|
+
if glassagent.status(inst.path):
|
|
129
|
+
pid = (Path(inst.path) / ".glass_agent.pid").read_text().strip()
|
|
130
|
+
print(f"{ui.GREEN}●{ui.RESET} Glass agent running (PID {pid})")
|
|
131
|
+
else:
|
|
132
|
+
print(f"{ui.DIM}○{ui.RESET} Glass agent stopped")
|
|
133
|
+
else:
|
|
134
|
+
ui.error(f"Unknown agent command: {subcmd}. Use start, stop, or status.")
|
|
135
|
+
sys.exit(1)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def cmd_new(target: str | None) -> None:
|
|
139
|
+
if target:
|
|
140
|
+
stemcell.hydrate(target)
|
|
141
|
+
return
|
|
142
|
+
# No target: ask for a folder to create (Python-native installer).
|
|
143
|
+
name = ui.ask("New silicon folder name", "silicon")
|
|
144
|
+
if not name:
|
|
145
|
+
ui.error("A folder name is required.")
|
|
146
|
+
sys.exit(1)
|
|
147
|
+
stemcell.hydrate(str(Path.cwd() / name))
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def cmd_help() -> None:
|
|
151
|
+
print(f"""
|
|
152
|
+
{ui.BOLD}{ui.CYAN}silicon{ui.RESET} – manage your silicon instances
|
|
153
|
+
|
|
154
|
+
{ui.BOLD}Usage:{ui.RESET}
|
|
155
|
+
silicon Show status or list instances
|
|
156
|
+
silicon new [dir] Create a new Silicon (hydrate from stemcell)
|
|
157
|
+
silicon new . Hydrate the current folder into a runnable silicon
|
|
158
|
+
silicon start <target> Start silicon(s). target = name, index, 1,2,4, or all
|
|
159
|
+
silicon stop <target> Stop silicon(s) (agent stays alive)
|
|
160
|
+
silicon stop --full <target> Stop silicon(s) and glass agent
|
|
161
|
+
silicon restart <target> Restart silicon(s)
|
|
162
|
+
silicon agent <start|stop|status> [name] Manage glass agent
|
|
163
|
+
silicon status [name] Show instance status
|
|
164
|
+
silicon browser [name] Open headed browser for login
|
|
165
|
+
silicon debug [name] Attach to running instance (live logs)
|
|
166
|
+
silicon attach [path] Register an existing silicon instance
|
|
167
|
+
silicon pull <username> Pull a silicon from Glass into a new folder
|
|
168
|
+
silicon push [name] Start hourly backup loop to Glass
|
|
169
|
+
silicon push [name] now Push a one-time backup to Glass
|
|
170
|
+
silicon push [name] stop Stop the hourly backup loop
|
|
171
|
+
silicon update <target> Update silicon(s) to latest. target = name, index, 1,2,4, or all
|
|
172
|
+
silicon list List all instances
|
|
173
|
+
silicon script update Update the silicon CLI itself
|
|
174
|
+
silicon install Install a new instance
|
|
175
|
+
silicon help Show this help
|
|
176
|
+
""")
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def suggest_command(inp: str) -> None:
|
|
180
|
+
def score(cmd: str) -> int:
|
|
181
|
+
ld = abs(len(inp) - len(cmd))
|
|
182
|
+
if cmd.startswith(inp) or inp.startswith(cmd):
|
|
183
|
+
return ld
|
|
184
|
+
common = sum(1 for a, b in zip(inp, cmd) if a == b)
|
|
185
|
+
return max(len(cmd), len(inp)) - common + ld
|
|
186
|
+
best = min(COMMANDS + ["ls"], key=score)
|
|
187
|
+
if score(best) <= 3:
|
|
188
|
+
print(f"\n{ui.YELLOW}Did you mean?{ui.RESET}\n silicon {best}\n")
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
# ----------------------------------------------------------------- dispatch
|
|
192
|
+
def main(argv: list[str] | None = None) -> None:
|
|
193
|
+
argv = argv if argv is not None else sys.argv[1:]
|
|
194
|
+
cmd = argv[0] if argv else ""
|
|
195
|
+
a1 = argv[1] if len(argv) > 1 else None
|
|
196
|
+
a2 = argv[2] if len(argv) > 2 else None
|
|
197
|
+
|
|
198
|
+
if cmd == "_watchdog": # internal: the supervised auto-restart loop
|
|
199
|
+
process.watchdog_loop(name=a2 or "silicon", path=a1, pid_file=argv[3] if len(argv) > 3 else "")
|
|
200
|
+
return
|
|
201
|
+
|
|
202
|
+
if cmd == "start":
|
|
203
|
+
process.start(a1)
|
|
204
|
+
elif cmd == "stop":
|
|
205
|
+
if a1 == "--full":
|
|
206
|
+
process.stop(a2, full=True)
|
|
207
|
+
else:
|
|
208
|
+
process.stop(a1, full=(a2 == "--full"))
|
|
209
|
+
elif cmd == "restart":
|
|
210
|
+
process.restart(a1)
|
|
211
|
+
elif cmd == "status":
|
|
212
|
+
cmd_status(a1)
|
|
213
|
+
elif cmd == "browser":
|
|
214
|
+
cmd_browser(a1)
|
|
215
|
+
elif cmd == "debug":
|
|
216
|
+
cmd_debug(a1)
|
|
217
|
+
elif cmd == "attach":
|
|
218
|
+
cmd_attach(a1)
|
|
219
|
+
elif cmd == "pull":
|
|
220
|
+
sync.pull(a1)
|
|
221
|
+
elif cmd == "push":
|
|
222
|
+
sync.push(a1, a2)
|
|
223
|
+
elif cmd == "update":
|
|
224
|
+
update.update_instance(a1)
|
|
225
|
+
elif cmd in ("list", "ls"):
|
|
226
|
+
cmd_list()
|
|
227
|
+
elif cmd == "agent":
|
|
228
|
+
cmd_agent(a1, a2)
|
|
229
|
+
elif cmd == "script":
|
|
230
|
+
if a1 == "update":
|
|
231
|
+
update.update_cli()
|
|
232
|
+
else:
|
|
233
|
+
ui.error(f"Unknown script command: {a1}. Did you mean: silicon script update?")
|
|
234
|
+
sys.exit(1)
|
|
235
|
+
elif cmd == "new":
|
|
236
|
+
cmd_new(a1)
|
|
237
|
+
elif cmd == "install":
|
|
238
|
+
cmd_new(None)
|
|
239
|
+
elif cmd in ("help", "-h", "--help"):
|
|
240
|
+
cmd_help()
|
|
241
|
+
elif cmd == "":
|
|
242
|
+
cmd_status(None)
|
|
243
|
+
else:
|
|
244
|
+
ui.error(f"Unknown command: {cmd}")
|
|
245
|
+
suggest_command(cmd)
|
|
246
|
+
sys.exit(1)
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
if __name__ == "__main__":
|
|
250
|
+
main()
|
silicon_cli/config.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Paths + endpoints. Everything is env-overridable so this CLI can point at
|
|
2
|
+
either the original Glass or your own."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import shutil
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
HOME = Path.home()
|
|
10
|
+
REGISTRY_DIR = Path(os.environ.get("SILICON_HOME", HOME / ".silicon"))
|
|
11
|
+
REGISTRY_FILE = REGISTRY_DIR / "registry.json"
|
|
12
|
+
CLI_SOURCE_FILE = REGISTRY_DIR / "cli-source" # where `silicon script update` reinstalls from
|
|
13
|
+
|
|
14
|
+
# Glass sync server (pull/push). Kept as the original default for compatibility;
|
|
15
|
+
# override with GLASS_SERVER_URL to point at your own.
|
|
16
|
+
GLASS_SERVER_URL = os.environ.get("GLASS_SERVER_URL", "https://glass.unlikefraction.com").rstrip("/")
|
|
17
|
+
|
|
18
|
+
# Stemcell — the base every new silicon is hydrated from.
|
|
19
|
+
STEMCELL_REPO = os.environ.get("SILICON_STEMCELL_REPO", "unlikefraction/silicon-stemcell")
|
|
20
|
+
STEMCELL_GIT_URL = f"https://github.com/{STEMCELL_REPO}.git"
|
|
21
|
+
STEMCELL_ZIP_URL = f"https://github.com/{STEMCELL_REPO}/archive/refs/heads/main.zip"
|
|
22
|
+
|
|
23
|
+
# Glass CLI (used by pull/push for backups).
|
|
24
|
+
GLASS_CLI_REPO = os.environ.get("SILICON_GLASS_CLI_REPO", "unlikefraction/glass")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def python_run_cmd() -> str:
|
|
28
|
+
"""The interpreter used to RUN a silicon's main.py (not this CLI's venv)."""
|
|
29
|
+
return os.environ.get("SILICON_PYTHON") or shutil.which("python3") or shutil.which("python") or "python3"
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""The per-silicon Glass agent (glass_agent.py) — remote control / backups.
|
|
2
|
+
|
|
3
|
+
Only relevant when the silicon dir has a .glass.json. Tracked via .glass_agent.pid.
|
|
4
|
+
"""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import os
|
|
8
|
+
import signal
|
|
9
|
+
import subprocess
|
|
10
|
+
import time
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from . import ui
|
|
14
|
+
from .config import python_run_cmd
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _pid_file(path: str) -> Path:
|
|
18
|
+
return Path(path) / ".glass_agent.pid"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _read_pid(path: str) -> int | None:
|
|
22
|
+
try:
|
|
23
|
+
return int(_pid_file(path).read_text().strip())
|
|
24
|
+
except Exception:
|
|
25
|
+
return None
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _alive(pid: int) -> bool:
|
|
29
|
+
try:
|
|
30
|
+
os.kill(pid, 0)
|
|
31
|
+
return True
|
|
32
|
+
except (OSError, ProcessLookupError):
|
|
33
|
+
return False
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def status(path: str) -> bool:
|
|
37
|
+
pid = _read_pid(path)
|
|
38
|
+
return bool(pid and _alive(pid))
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def start(path: str) -> None:
|
|
42
|
+
if not (Path(path) / ".glass.json").exists():
|
|
43
|
+
return
|
|
44
|
+
if status(path):
|
|
45
|
+
return
|
|
46
|
+
log = open(Path(path) / ".glass_agent.log", "a")
|
|
47
|
+
proc = subprocess.Popen(
|
|
48
|
+
[python_run_cmd(), "-u", "glass_agent.py"], cwd=path,
|
|
49
|
+
stdout=log, stderr=subprocess.STDOUT, start_new_session=True,
|
|
50
|
+
)
|
|
51
|
+
_pid_file(path).write_text(str(proc.pid))
|
|
52
|
+
ui.info(f"Glass agent started (PID {proc.pid})")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def stop(path: str) -> None:
|
|
56
|
+
pid = _read_pid(path)
|
|
57
|
+
if pid and _alive(pid):
|
|
58
|
+
try:
|
|
59
|
+
os.kill(pid, signal.SIGTERM)
|
|
60
|
+
time.sleep(1)
|
|
61
|
+
if _alive(pid):
|
|
62
|
+
os.kill(pid, signal.SIGKILL)
|
|
63
|
+
except (OSError, ProcessLookupError):
|
|
64
|
+
pass
|
|
65
|
+
ui.info("Glass agent stopped")
|
|
66
|
+
_pid_file(path).unlink(missing_ok=True)
|
silicon_cli/process.py
ADDED
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
"""Process supervision — start/stop instances with an auto-restart watchdog.
|
|
2
|
+
|
|
3
|
+
Mirrors the bash watchdog: a detached supervisor process runs `python -u main.py`,
|
|
4
|
+
restarts it on exit (with crash-loop detection), honors a .silicon.stop sentinel,
|
|
5
|
+
writes .silicon.log + .silicon.pid (the pid is the *watchdog's*, so a stop signal
|
|
6
|
+
reaches the supervisor which then kills its child and cleans up).
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
import re
|
|
12
|
+
import signal
|
|
13
|
+
import subprocess
|
|
14
|
+
import sys
|
|
15
|
+
import time
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
from . import glassagent, registry, ui
|
|
19
|
+
from .config import python_run_cmd
|
|
20
|
+
|
|
21
|
+
RESTART_DELAY = 5
|
|
22
|
+
MAX_RAPID = 5
|
|
23
|
+
RAPID_WINDOW = 60
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def get_pid(pid_file: str) -> str | None:
|
|
27
|
+
try:
|
|
28
|
+
pid = Path(pid_file).read_text().strip()
|
|
29
|
+
return pid or None
|
|
30
|
+
except Exception:
|
|
31
|
+
return None
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _alive(pid: int) -> bool:
|
|
35
|
+
try:
|
|
36
|
+
os.kill(pid, 0)
|
|
37
|
+
return True
|
|
38
|
+
except (OSError, ProcessLookupError):
|
|
39
|
+
return False
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def is_running(pid_file: str) -> bool:
|
|
43
|
+
pid = get_pid(pid_file)
|
|
44
|
+
if not pid:
|
|
45
|
+
return False
|
|
46
|
+
try:
|
|
47
|
+
return _alive(int(pid))
|
|
48
|
+
except ValueError:
|
|
49
|
+
return False
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _floater_pids(path: str, skip: int | None = None) -> list[int]:
|
|
53
|
+
"""PIDs of python processes running this dir's main.py (orphans)."""
|
|
54
|
+
main_py = str(Path(path) / "main.py")
|
|
55
|
+
try:
|
|
56
|
+
out = subprocess.run(["ps", "-eo", "pid=,command="], capture_output=True, text=True).stdout
|
|
57
|
+
except Exception:
|
|
58
|
+
return []
|
|
59
|
+
pids = []
|
|
60
|
+
for line in out.splitlines():
|
|
61
|
+
line = line.strip()
|
|
62
|
+
if not line:
|
|
63
|
+
continue
|
|
64
|
+
m = re.match(r"^(\d+)\s+(.*)$", line)
|
|
65
|
+
if not m:
|
|
66
|
+
continue
|
|
67
|
+
pid, cmd = int(m.group(1)), m.group(2)
|
|
68
|
+
if "python" in cmd and main_py in cmd:
|
|
69
|
+
if skip is not None and pid == skip:
|
|
70
|
+
continue
|
|
71
|
+
if pid == os.getpid():
|
|
72
|
+
continue
|
|
73
|
+
pids.append(pid)
|
|
74
|
+
return pids
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def kill_floaters(path: str, skip: int | None = None) -> None:
|
|
78
|
+
for pid in _floater_pids(path, skip):
|
|
79
|
+
ui.warn(f"Killing orphaned process (PID {pid}) from {path}")
|
|
80
|
+
try:
|
|
81
|
+
os.kill(pid, signal.SIGTERM)
|
|
82
|
+
time.sleep(1)
|
|
83
|
+
if _alive(pid):
|
|
84
|
+
os.kill(pid, signal.SIGKILL)
|
|
85
|
+
except (OSError, ProcessLookupError):
|
|
86
|
+
pass
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
# --------------------------------------------------------------- watchdog
|
|
90
|
+
def watchdog_loop(name: str, path: str, pid_file: str) -> None:
|
|
91
|
+
"""Runs as the detached `silicon _watchdog` process."""
|
|
92
|
+
log_file = Path(path) / ".silicon.log"
|
|
93
|
+
main_py = str(Path(path) / "main.py")
|
|
94
|
+
stop_file = Path(path) / ".silicon.stop"
|
|
95
|
+
py = python_run_cmd()
|
|
96
|
+
child: subprocess.Popen | None = None
|
|
97
|
+
|
|
98
|
+
def _terminate(signum=None, frame=None):
|
|
99
|
+
if child and child.poll() is None:
|
|
100
|
+
try:
|
|
101
|
+
child.terminate()
|
|
102
|
+
for _ in range(6):
|
|
103
|
+
if child.poll() is not None:
|
|
104
|
+
break
|
|
105
|
+
time.sleep(0.5)
|
|
106
|
+
if child.poll() is None:
|
|
107
|
+
child.kill()
|
|
108
|
+
except Exception:
|
|
109
|
+
pass
|
|
110
|
+
try:
|
|
111
|
+
Path(pid_file).unlink()
|
|
112
|
+
except OSError:
|
|
113
|
+
pass
|
|
114
|
+
sys.exit(0)
|
|
115
|
+
|
|
116
|
+
signal.signal(signal.SIGTERM, _terminate)
|
|
117
|
+
signal.signal(signal.SIGINT, _terminate)
|
|
118
|
+
|
|
119
|
+
restart_times: list[float] = []
|
|
120
|
+
while True:
|
|
121
|
+
kill_floaters(path, skip=os.getpid())
|
|
122
|
+
with open(log_file, "a") as lf:
|
|
123
|
+
child = subprocess.Popen([py, "-u", main_py], cwd=path, stdout=lf, stderr=subprocess.STDOUT)
|
|
124
|
+
exit_code = child.wait()
|
|
125
|
+
child = None
|
|
126
|
+
|
|
127
|
+
if stop_file.exists():
|
|
128
|
+
stop_file.unlink(missing_ok=True)
|
|
129
|
+
Path(pid_file).unlink(missing_ok=True)
|
|
130
|
+
break
|
|
131
|
+
|
|
132
|
+
now = time.time()
|
|
133
|
+
restart_times.append(now)
|
|
134
|
+
cutoff = now - RAPID_WINDOW
|
|
135
|
+
restart_times = [t for t in restart_times if t >= cutoff]
|
|
136
|
+
if len(restart_times) >= MAX_RAPID:
|
|
137
|
+
with open(log_file, "a") as lf:
|
|
138
|
+
lf.write(f"[silicon-watchdog] {time.ctime()}: '{name}' crashed {MAX_RAPID} times "
|
|
139
|
+
f"in {RAPID_WINDOW}s. Giving up.\n")
|
|
140
|
+
Path(pid_file).unlink(missing_ok=True)
|
|
141
|
+
break
|
|
142
|
+
|
|
143
|
+
with open(log_file, "a") as lf:
|
|
144
|
+
lf.write(f"[silicon-watchdog] {time.ctime()}: '{name}' exited (code {exit_code}). "
|
|
145
|
+
f"Restarting in {RESTART_DELAY}s...\n")
|
|
146
|
+
time.sleep(RESTART_DELAY)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
# --------------------------------------------------------------- start/stop
|
|
150
|
+
def _spawn_watchdog(name: str, path: str, pid_file: str) -> int:
|
|
151
|
+
"""Launch the detached watchdog; return its PID."""
|
|
152
|
+
proc = subprocess.Popen(
|
|
153
|
+
[sys.executable, "-m", "silicon_cli.cli", "_watchdog", path, name, pid_file],
|
|
154
|
+
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
|
155
|
+
start_new_session=True, # detach so it survives this CLI exiting
|
|
156
|
+
)
|
|
157
|
+
return proc.pid
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def start_one(target: str | None) -> None:
|
|
161
|
+
inst = registry.resolve_one(target)
|
|
162
|
+
if is_running(inst.pid_file):
|
|
163
|
+
ui.warn(f"'{inst.name}' is already running (PID {get_pid(inst.pid_file)})")
|
|
164
|
+
glassagent.start(inst.path)
|
|
165
|
+
return
|
|
166
|
+
|
|
167
|
+
kill_floaters(inst.path)
|
|
168
|
+
Path(inst.pid_file).unlink(missing_ok=True)
|
|
169
|
+
(Path(inst.path) / ".silicon.stop").unlink(missing_ok=True)
|
|
170
|
+
|
|
171
|
+
ui.info(f"Starting '{inst.name}' (with auto-restart)...")
|
|
172
|
+
pid = _spawn_watchdog(inst.name, inst.path, inst.pid_file)
|
|
173
|
+
Path(inst.pid_file).write_text(str(pid))
|
|
174
|
+
|
|
175
|
+
time.sleep(2)
|
|
176
|
+
if _alive(pid):
|
|
177
|
+
ui.success(f"'{inst.name}' started (PID {pid})")
|
|
178
|
+
ui.info(f"Auto-restart enabled. Logs: {inst.path}/.silicon.log")
|
|
179
|
+
else:
|
|
180
|
+
ui.error(f"'{inst.name}' failed to start. Check logs: {inst.path}/.silicon.log")
|
|
181
|
+
Path(inst.pid_file).unlink(missing_ok=True)
|
|
182
|
+
|
|
183
|
+
glassagent.start(inst.path)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def stop_one(target: str | None, full: bool = False) -> None:
|
|
187
|
+
inst = registry.resolve_one(target)
|
|
188
|
+
if not is_running(inst.pid_file):
|
|
189
|
+
ui.warn(f"'{inst.name}' is not running")
|
|
190
|
+
kill_floaters(inst.path)
|
|
191
|
+
Path(inst.pid_file).unlink(missing_ok=True)
|
|
192
|
+
(Path(inst.path) / ".silicon.stop").unlink(missing_ok=True)
|
|
193
|
+
if full:
|
|
194
|
+
glassagent.stop(inst.path)
|
|
195
|
+
return
|
|
196
|
+
|
|
197
|
+
pid = int(get_pid(inst.pid_file))
|
|
198
|
+
(Path(inst.path) / ".silicon.stop").touch() # tell the watchdog not to restart
|
|
199
|
+
ui.info(f"Stopping '{inst.name}' (PID {pid})...")
|
|
200
|
+
try:
|
|
201
|
+
os.kill(pid, signal.SIGTERM)
|
|
202
|
+
except (OSError, ProcessLookupError):
|
|
203
|
+
pass
|
|
204
|
+
for _ in range(10):
|
|
205
|
+
if not _alive(pid):
|
|
206
|
+
break
|
|
207
|
+
time.sleep(0.5)
|
|
208
|
+
if _alive(pid):
|
|
209
|
+
ui.warn("Force stopping...")
|
|
210
|
+
try:
|
|
211
|
+
os.kill(pid, signal.SIGKILL)
|
|
212
|
+
except (OSError, ProcessLookupError):
|
|
213
|
+
pass
|
|
214
|
+
|
|
215
|
+
kill_floaters(inst.path)
|
|
216
|
+
Path(inst.pid_file).unlink(missing_ok=True)
|
|
217
|
+
(Path(inst.path) / ".silicon.stop").unlink(missing_ok=True)
|
|
218
|
+
ui.success(f"'{inst.name}' stopped")
|
|
219
|
+
|
|
220
|
+
if full:
|
|
221
|
+
glassagent.stop(inst.path)
|
|
222
|
+
else:
|
|
223
|
+
ui.info("Glass agent still running (use --full to stop it too).")
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _multi(target: str, verb: str, fn) -> bool:
|
|
227
|
+
"""Dispatch a multi-target selector. Returns True if it handled it."""
|
|
228
|
+
if not (target and registry.is_multi_target(target)):
|
|
229
|
+
return False
|
|
230
|
+
names = registry.resolve_targets(target)
|
|
231
|
+
if not names:
|
|
232
|
+
ui.error("No matching installations")
|
|
233
|
+
sys.exit(1)
|
|
234
|
+
if target == "all":
|
|
235
|
+
joined = ", ".join(names)
|
|
236
|
+
if not ui.confirm(f"Are you sure you want to {verb} the following silicons: {joined}?"):
|
|
237
|
+
return True
|
|
238
|
+
for n in names:
|
|
239
|
+
fn(n)
|
|
240
|
+
return True
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def start(target: str | None) -> None:
|
|
244
|
+
if _multi(target or "", "start", start_one):
|
|
245
|
+
return
|
|
246
|
+
start_one(target)
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def stop(target: str | None, full: bool = False) -> None:
|
|
250
|
+
if _multi(target or "", "stop", lambda n: stop_one(n, full)):
|
|
251
|
+
return
|
|
252
|
+
stop_one(target, full)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def restart(target: str | None) -> None:
|
|
256
|
+
stop(target)
|
|
257
|
+
time.sleep(1)
|
|
258
|
+
start(target)
|