machinaos 0.0.73 → 0.0.75
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.
- package/client/package.json +1 -1
- package/machina/README.md +47 -0
- package/machina/__init__.py +3 -0
- package/machina/__main__.py +35 -0
- package/machina/buildenv.py +44 -0
- package/machina/cli.py +55 -0
- package/machina/colors.py +47 -0
- package/machina/commands/__init__.py +1 -0
- package/machina/commands/build.py +170 -0
- package/machina/commands/clean.py +80 -0
- package/machina/commands/daemon.py +150 -0
- package/machina/commands/dev.py +124 -0
- package/machina/commands/docs.py +154 -0
- package/machina/commands/start.py +164 -0
- package/machina/commands/stop.py +70 -0
- package/machina/commands/version.py +88 -0
- package/machina/config.py +93 -0
- package/machina/platform_.py +37 -0
- package/machina/ports.py +133 -0
- package/machina/pyproject.toml +29 -0
- package/machina/run.py +81 -0
- package/machina/supervisor.py +344 -0
- package/machina/tcp.py +52 -0
- package/machina/tree.py +125 -0
- package/package.json +4 -1
- package/scripts/postinstall.js +36 -23
package/client/package.json
CHANGED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# machina
|
|
2
|
+
|
|
3
|
+
Project supervisor CLI for the MachinaOS stack. Replaces `scripts/*.js`
|
|
4
|
+
gradually; backwards-compatible with `pnpm run start`/`dev`/`stop`/etc.
|
|
5
|
+
|
|
6
|
+
## Install
|
|
7
|
+
|
|
8
|
+
Done automatically in `scripts/postinstall.js` after `pnpm install`. To
|
|
9
|
+
install manually:
|
|
10
|
+
|
|
11
|
+
```sh
|
|
12
|
+
python -m pip install -e ./machina
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Use
|
|
16
|
+
|
|
17
|
+
```sh
|
|
18
|
+
python -m machina --help # list commands
|
|
19
|
+
python -m machina stop # kill ports + orphans + temporal
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Or via the npm scripts (which now invoke the same Python CLI):
|
|
23
|
+
|
|
24
|
+
```sh
|
|
25
|
+
pnpm run stop
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Cross-platform notes
|
|
29
|
+
|
|
30
|
+
- **Windows**: `python` resolves correctly out of the box.
|
|
31
|
+
- **macOS / Linux**: requires `python` to point to Python 3.11+. Most
|
|
32
|
+
modern distros provide this via `python-is-python3` or a symlink. If
|
|
33
|
+
your distro only ships `python3`, run `python3 -m machina <cmd>` or
|
|
34
|
+
add an alias.
|
|
35
|
+
|
|
36
|
+
## Architecture
|
|
37
|
+
|
|
38
|
+
Built on battle-tested primitives — minimal custom code:
|
|
39
|
+
|
|
40
|
+
| Layer | Library |
|
|
41
|
+
|---|---|
|
|
42
|
+
| CLI | `typer` |
|
|
43
|
+
| Subprocess | `anyio.open_process` |
|
|
44
|
+
| Tree-kill | `psutil` + `pywin32` (Job Objects on Windows) |
|
|
45
|
+
| Restart backoff | stdlib (deque + monotonic time + jittered exponential, ~20 LOC) |
|
|
46
|
+
| Output | `rich.Console` |
|
|
47
|
+
| Env | `python-dotenv` |
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Entry point for ``python -m machina``.
|
|
2
|
+
|
|
3
|
+
pnpm scripts call ``python -m machina ...`` from the system Python,
|
|
4
|
+
which only has ``uv`` -- not typer / rich / anyio / psutil. On first
|
|
5
|
+
run we detect the missing imports, pip-install the declared runtime
|
|
6
|
+
deps, and retry, so the user never has to ``pip install -e .`` by hand.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import subprocess
|
|
10
|
+
import sys
|
|
11
|
+
|
|
12
|
+
_RUNTIME_DEPS = (
|
|
13
|
+
"typer>=0.12",
|
|
14
|
+
"rich>=13.0",
|
|
15
|
+
"anyio>=4.0",
|
|
16
|
+
"psutil>=6.0",
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _bootstrap_deps() -> None:
|
|
21
|
+
print("machina: installing runtime dependencies (first run)...", file=sys.stderr)
|
|
22
|
+
subprocess.check_call(
|
|
23
|
+
[sys.executable, "-m", "pip", "install", "--quiet", *_RUNTIME_DEPS]
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
try:
|
|
28
|
+
from machina.cli import app
|
|
29
|
+
except ImportError:
|
|
30
|
+
_bootstrap_deps()
|
|
31
|
+
from machina.cli import app
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
if __name__ == "__main__":
|
|
35
|
+
app()
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Build-environment helpers shared by ``start`` / ``dev`` / ``daemon``.
|
|
2
|
+
|
|
3
|
+
Three commands had near-duplicate copies of "find the venv python" and
|
|
4
|
+
"the build artifacts must exist or refuse to start". Lifted here so a
|
|
5
|
+
new launcher gets both for free.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
import typer
|
|
13
|
+
|
|
14
|
+
from machina.colors import console
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def venv_python(root: Path) -> Path | None:
|
|
18
|
+
"""Return the project's venv python path, or ``None`` if absent.
|
|
19
|
+
|
|
20
|
+
Tries the Windows layout first (``Scripts/python.exe``) then POSIX
|
|
21
|
+
(``bin/python``); the first existing path wins.
|
|
22
|
+
"""
|
|
23
|
+
for candidate in (
|
|
24
|
+
root / "server" / ".venv" / "Scripts" / "python.exe",
|
|
25
|
+
root / "server" / ".venv" / "bin" / "python",
|
|
26
|
+
):
|
|
27
|
+
if candidate.exists():
|
|
28
|
+
return candidate
|
|
29
|
+
return None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def validate_build(root: Path, *, require_client_dist: bool = False) -> None:
|
|
33
|
+
"""Refuse to launch if ``machina build`` hasn't been run.
|
|
34
|
+
|
|
35
|
+
Raises ``typer.Exit(1)`` with a remediation hint. ``dev`` allows a
|
|
36
|
+
missing ``client/dist`` (Vite serves the source); ``start`` opts in
|
|
37
|
+
via ``require_client_dist=True``.
|
|
38
|
+
"""
|
|
39
|
+
if not (root / "node_modules").exists() or not (root / "server" / ".venv").exists():
|
|
40
|
+
console.print('[red]Error: Project not built. Run "machina build" first.[/]')
|
|
41
|
+
raise typer.Exit(code=1)
|
|
42
|
+
if require_client_dist and not (root / "client" / "dist" / "index.html").exists():
|
|
43
|
+
console.print('[red]Error: Client not built. Run "machina build" first.[/]')
|
|
44
|
+
raise typer.Exit(code=1)
|
package/machina/cli.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Typer CLI for ``machina``.
|
|
2
|
+
|
|
3
|
+
Each subcommand lives under ``machina.commands``; this module just
|
|
4
|
+
mounts them and exposes ``app`` as the entry point.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import typer
|
|
10
|
+
|
|
11
|
+
from machina.commands.build import build_command
|
|
12
|
+
from machina.commands.clean import clean_command
|
|
13
|
+
from machina.commands.daemon import app as daemon_app
|
|
14
|
+
from machina.commands.dev import dev_command
|
|
15
|
+
from machina.commands.docs import app as docs_app
|
|
16
|
+
from machina.commands.start import start_command
|
|
17
|
+
from machina.commands.stop import stop_command
|
|
18
|
+
from machina.commands.version import app as version_app
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
app = typer.Typer(
|
|
22
|
+
name="machina",
|
|
23
|
+
help="MachinaOS project supervisor CLI.",
|
|
24
|
+
no_args_is_help=True,
|
|
25
|
+
add_completion=False,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@app.callback()
|
|
30
|
+
def _root() -> None:
|
|
31
|
+
"""Marks the Typer app as a multi-command group."""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
app.command("start", help="Start all services in production mode (static client + uvicorn + temporal).")(
|
|
35
|
+
start_command
|
|
36
|
+
)
|
|
37
|
+
app.command("dev", help="Start all services in dev mode (Vite HMR + uvicorn + temporal).")(
|
|
38
|
+
dev_command
|
|
39
|
+
)
|
|
40
|
+
app.command("stop", help="Stop all MachinaOS services and free configured ports.")(
|
|
41
|
+
stop_command
|
|
42
|
+
)
|
|
43
|
+
app.command("clean", help="Stop services then remove build artefacts and venvs.")(
|
|
44
|
+
clean_command
|
|
45
|
+
)
|
|
46
|
+
app.command("build", help="Install toolchain + build client + sync Python + verify deps.")(
|
|
47
|
+
build_command
|
|
48
|
+
)
|
|
49
|
+
app.add_typer(version_app, name="version")
|
|
50
|
+
app.add_typer(docs_app, name="docs")
|
|
51
|
+
app.add_typer(daemon_app, name="daemon")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
if __name__ == "__main__":
|
|
55
|
+
app()
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Rich console + per-service color rotation (Honcho-style).
|
|
2
|
+
|
|
3
|
+
Latency-analysis hook: ``emit`` routes through ``Console.log`` instead of
|
|
4
|
+
``Console.print``, which prepends a built-in timestamp column. The console
|
|
5
|
+
is configured with ``log_time_format=[%H:%M:%S.%f]`` for ms precision and
|
|
6
|
+
``log_path=False`` to drop the file:line caller info that rich adds by
|
|
7
|
+
default. ``console.print`` callers (banner / step headers) remain timestamp-
|
|
8
|
+
less so the launch banner stays readable.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from itertools import cycle
|
|
14
|
+
from rich.console import Console
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
# Single shared console — single-writer aggregator avoids interleaving
|
|
18
|
+
# concurrent stream output (VS Code's pty host pattern). ``log_*`` settings
|
|
19
|
+
# only affect ``console.log`` calls; ``console.print`` is unaffected.
|
|
20
|
+
console = Console(log_time_format="[%H:%M:%S.%f]", log_path=False)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
# Honcho's rotation, lifted: skip very-dark (black) and very-bright (white)
|
|
24
|
+
# so prefixes remain readable on both light and dark terminals.
|
|
25
|
+
_PALETTE = [
|
|
26
|
+
"cyan", "green", "yellow", "blue", "magenta",
|
|
27
|
+
"bright_cyan", "bright_green", "bright_yellow", "bright_blue", "bright_magenta",
|
|
28
|
+
]
|
|
29
|
+
_color_cycle = cycle(_PALETTE)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def next_color() -> str:
|
|
33
|
+
return next(_color_cycle)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def emit(name: str, color: str, line: str, *, stream: str = "stdout") -> None:
|
|
37
|
+
"""Print one line tagged with the service name + color, timestamped.
|
|
38
|
+
|
|
39
|
+
Renders as ``[14:23:45.123] cyan | <message>``. The timestamp
|
|
40
|
+
column is added by ``console.log``; prefix width stays uniform so
|
|
41
|
+
side-by-side latency comparison across services lines up visually.
|
|
42
|
+
"""
|
|
43
|
+
width = 12
|
|
44
|
+
prefix = f"[{color}]{name:<{width}}[/{color}]"
|
|
45
|
+
style = "" if stream == "stdout" else "[dim]"
|
|
46
|
+
suffix = "" if stream == "stdout" else "[/dim]"
|
|
47
|
+
console.log(f"{prefix}{style} | {line}{suffix}", highlight=False)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""``machina`` subcommand modules. Imported by ``machina.cli``."""
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"""``machina build`` -- replaces ``scripts/build.js``.
|
|
2
|
+
|
|
3
|
+
Checks toolchain (node, npm, python, uv, temporal-server), then runs
|
|
4
|
+
the 4-step build: ``.env`` bootstrap -> ``pnpm install`` -> client
|
|
5
|
+
build -> ``uv sync`` -> verify edgymeow binary.
|
|
6
|
+
|
|
7
|
+
The ``MACHINAOS_BUILDING`` env var is set so ``scripts/postinstall.js``
|
|
8
|
+
skips its own ``install.js`` invocation when build is the orchestrator.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import os
|
|
14
|
+
import re
|
|
15
|
+
import shutil
|
|
16
|
+
import sys
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
import typer
|
|
20
|
+
|
|
21
|
+
from machina.run import capture, run
|
|
22
|
+
from machina.colors import console
|
|
23
|
+
from machina.platform_ import project_root
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
# ---------------------------------------------------------------- helpers
|
|
27
|
+
|
|
28
|
+
def _which_python() -> str | None:
|
|
29
|
+
"""Prefer ``python3`` so we don't pick up Python 2.x on POSIX distros."""
|
|
30
|
+
for cmd in ("python3", "python"):
|
|
31
|
+
if shutil.which(cmd):
|
|
32
|
+
return cmd
|
|
33
|
+
return None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _check_python(cmd: str) -> bool:
|
|
37
|
+
out = capture([cmd, "--version"])
|
|
38
|
+
if not out:
|
|
39
|
+
return False
|
|
40
|
+
match = re.search(r"Python (\d+)\.(\d+)", out)
|
|
41
|
+
if not match:
|
|
42
|
+
return False
|
|
43
|
+
major, minor = int(match.group(1)), int(match.group(2))
|
|
44
|
+
if major >= 3 and minor >= 12:
|
|
45
|
+
console.print(f" {out}")
|
|
46
|
+
return True
|
|
47
|
+
console.print(f" {out} [red](too old, need 3.12+)[/]")
|
|
48
|
+
return False
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _ensure_pip(python_cmd: str) -> None:
|
|
52
|
+
if not capture([python_cmd, "-m", "pip", "--version"]):
|
|
53
|
+
console.print(" Installing pip via ensurepip...")
|
|
54
|
+
run([python_cmd, "-m", "ensurepip", "--upgrade"])
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _ensure_uv(python_cmd: str) -> str:
|
|
58
|
+
"""Install ``uv`` via pip if missing; return the resolved version string."""
|
|
59
|
+
version = capture(["uv", "--version"])
|
|
60
|
+
if version:
|
|
61
|
+
console.print(f" uv: {version}")
|
|
62
|
+
return version
|
|
63
|
+
_ensure_pip(python_cmd)
|
|
64
|
+
console.print(" Installing uv via pip...")
|
|
65
|
+
run([python_cmd, "-m", "pip", "install", "uv"])
|
|
66
|
+
version = capture(["uv", "--version"])
|
|
67
|
+
if not version:
|
|
68
|
+
console.print("[red]Error: failed to install uv. See https://docs.astral.sh/uv/[/]")
|
|
69
|
+
raise typer.Exit(code=1)
|
|
70
|
+
console.print(f" uv: {version}")
|
|
71
|
+
return version
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _ensure_temporal() -> None:
|
|
75
|
+
"""Ensure the ``temporal`` CLI (from npm package ``temporal-server``) is on PATH."""
|
|
76
|
+
version = capture(["temporal", "--version"])
|
|
77
|
+
if version:
|
|
78
|
+
console.print(f" temporal: {version}")
|
|
79
|
+
return
|
|
80
|
+
console.print(" temporal: not found, installing globally...")
|
|
81
|
+
rc = run(["npm", "install", "-g", "temporal-server"], check=False)
|
|
82
|
+
if rc != 0:
|
|
83
|
+
console.print(
|
|
84
|
+
" [yellow]Warning: temporal install failed. "
|
|
85
|
+
"Distributed execution unavailable.[/]"
|
|
86
|
+
)
|
|
87
|
+
return
|
|
88
|
+
version = capture(["temporal", "--version"])
|
|
89
|
+
if version:
|
|
90
|
+
console.print(f" temporal: {version}")
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
# ---------------------------------------------------------------- build
|
|
94
|
+
|
|
95
|
+
def build_command() -> None:
|
|
96
|
+
root = project_root()
|
|
97
|
+
|
|
98
|
+
# Prevent the postinstall orchestrator from re-running install.js when
|
|
99
|
+
# we're orchestrating ourselves (matches the existing JS contract).
|
|
100
|
+
os.environ["MACHINAOS_BUILDING"] = "true"
|
|
101
|
+
os.environ.setdefault("PYTHONUTF8", "1")
|
|
102
|
+
|
|
103
|
+
is_postinstall = os.environ.get("npm_lifecycle_event") == "postinstall"
|
|
104
|
+
is_ci = os.environ.get("CI") == "true" or os.environ.get("GITHUB_ACTIONS") == "true"
|
|
105
|
+
if is_ci and is_postinstall:
|
|
106
|
+
console.print("CI environment detected, skipping postinstall build.")
|
|
107
|
+
return
|
|
108
|
+
|
|
109
|
+
# ---- toolchain ---------------------------------------------------
|
|
110
|
+
console.print("[bold]Checking dependencies...[/]\n")
|
|
111
|
+
node_version = capture(["node", "--version"])
|
|
112
|
+
console.print(f" Node.js: {node_version or '[red]not found[/]'}")
|
|
113
|
+
if not node_version:
|
|
114
|
+
console.print("[red]Error: Node.js is required.[/]")
|
|
115
|
+
raise typer.Exit(code=1)
|
|
116
|
+
|
|
117
|
+
npm_version = capture(["npm", "--version"])
|
|
118
|
+
console.print(f" npm: {npm_version or '[red]not found[/]'}")
|
|
119
|
+
|
|
120
|
+
python_cmd = _which_python()
|
|
121
|
+
if not python_cmd or not _check_python(python_cmd):
|
|
122
|
+
console.print(
|
|
123
|
+
"[red]Error: Python 3.12+ is required.[/] "
|
|
124
|
+
"Install from https://python.org/downloads/"
|
|
125
|
+
)
|
|
126
|
+
raise typer.Exit(code=1)
|
|
127
|
+
|
|
128
|
+
_ensure_uv(python_cmd)
|
|
129
|
+
_ensure_temporal()
|
|
130
|
+
|
|
131
|
+
console.print("\n[green]All dependencies ready.[/]\n")
|
|
132
|
+
|
|
133
|
+
# ---- build steps -------------------------------------------------
|
|
134
|
+
server_dir = root / "server"
|
|
135
|
+
env_path = root / ".env"
|
|
136
|
+
template_path = root / ".env.template"
|
|
137
|
+
|
|
138
|
+
# Step markers go through ``console.log`` so each [N/5] line is
|
|
139
|
+
# timestamped — diff between consecutive timestamps is the wall-clock
|
|
140
|
+
# cost of that step, no manual instrumentation needed.
|
|
141
|
+
if not env_path.exists() and template_path.exists():
|
|
142
|
+
shutil.copy2(template_path, env_path)
|
|
143
|
+
console.log("[0/5] Created .env from template")
|
|
144
|
+
|
|
145
|
+
if not is_postinstall:
|
|
146
|
+
console.log("[1/5] Installing dependencies...")
|
|
147
|
+
run(["pnpm", "install"], cwd=root)
|
|
148
|
+
else:
|
|
149
|
+
console.log("[1/5] Dependencies already installed by package manager")
|
|
150
|
+
|
|
151
|
+
console.log("[2/5] Building client...")
|
|
152
|
+
run(["pnpm", "--filter", "react-flow-client", "run", "build"], cwd=root)
|
|
153
|
+
|
|
154
|
+
console.log("[3/4] Installing Python dependencies...")
|
|
155
|
+
if not (server_dir / ".venv").exists():
|
|
156
|
+
run(["uv", "venv"], cwd=server_dir)
|
|
157
|
+
run(["uv", "sync"], cwd=server_dir)
|
|
158
|
+
|
|
159
|
+
console.log("[4/4] Verifying edgymeow binary...")
|
|
160
|
+
bin_name = "edgymeow-server.exe" if sys.platform == "win32" else "edgymeow-server"
|
|
161
|
+
edgymeow_bin = root / "node_modules" / "edgymeow" / "bin" / bin_name
|
|
162
|
+
if edgymeow_bin.exists():
|
|
163
|
+
console.print(f" Binary present: {edgymeow_bin}")
|
|
164
|
+
else:
|
|
165
|
+
console.print(
|
|
166
|
+
" [yellow]Warning: edgymeow binary not found. "
|
|
167
|
+
"Set WHATSAPP_RUNTIME_ENABLED=false to disable.[/]"
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
console.log("[green]Build complete.[/]")
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""``machina clean`` -- replaces ``scripts/clean.js``.
|
|
2
|
+
|
|
3
|
+
Stops every process listening on the configured ports + orphaned project
|
|
4
|
+
processes, waits for file locks to release, then removes build artefacts
|
|
5
|
+
and venvs.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import shutil
|
|
11
|
+
import time
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from machina.colors import console
|
|
15
|
+
from machina.config import load_config
|
|
16
|
+
from machina.platform_ import project_root
|
|
17
|
+
from machina.ports import kill_orphaned_machina_processes, kill_port
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
# Removed each run -- order doesn't matter, ``shutil.rmtree`` is recursive.
|
|
21
|
+
_TARGETS = [
|
|
22
|
+
"node_modules",
|
|
23
|
+
"client/node_modules",
|
|
24
|
+
"client/dist",
|
|
25
|
+
"client/.vite",
|
|
26
|
+
"server/data", # workflow.db, credentials.db, workspaces/
|
|
27
|
+
"server/.venv",
|
|
28
|
+
".venv", # stale root venv (should not exist)
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _rmtree_with_retry(path: Path, *, attempts: int = 3, delay: float = 0.1) -> bool:
|
|
33
|
+
"""``shutil.rmtree`` with Windows-friendly retry on file-lock errors."""
|
|
34
|
+
for attempt in range(attempts):
|
|
35
|
+
try:
|
|
36
|
+
shutil.rmtree(path, ignore_errors=False)
|
|
37
|
+
return True
|
|
38
|
+
except OSError as exc:
|
|
39
|
+
if attempt == attempts - 1:
|
|
40
|
+
console.print(f" [yellow]Warning: Could not remove {path.name}: {exc}[/]")
|
|
41
|
+
return False
|
|
42
|
+
time.sleep(delay)
|
|
43
|
+
return False
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def clean_command() -> None:
|
|
47
|
+
cfg = load_config()
|
|
48
|
+
root = project_root()
|
|
49
|
+
|
|
50
|
+
console.print("[bold]Cleaning MachinaOS...[/]\n")
|
|
51
|
+
|
|
52
|
+
# Step 1: kill anything on configured ports
|
|
53
|
+
console.print("Stopping running processes...")
|
|
54
|
+
killed_ports = 0
|
|
55
|
+
for port in cfg.all_ports:
|
|
56
|
+
result = kill_port(port)
|
|
57
|
+
if result.killed_pids:
|
|
58
|
+
console.print(f" Port {port}: Killed {len(result.killed_pids)} process(es)")
|
|
59
|
+
killed_ports += len(result.killed_pids)
|
|
60
|
+
|
|
61
|
+
# Step 2: kill orphaned project processes (may hold .venv file locks)
|
|
62
|
+
orphaned = kill_orphaned_machina_processes(str(root), exclude_substring="machina")
|
|
63
|
+
if orphaned:
|
|
64
|
+
console.print(f" Orphaned: Killed {len(orphaned)} process(es)")
|
|
65
|
+
|
|
66
|
+
if killed_ports or orphaned:
|
|
67
|
+
console.print(" Waiting for processes to release file locks...")
|
|
68
|
+
time.sleep(1.0)
|
|
69
|
+
else:
|
|
70
|
+
console.print(" No running processes found.")
|
|
71
|
+
|
|
72
|
+
# Step 3: remove directories
|
|
73
|
+
console.print("\nRemoving directories...")
|
|
74
|
+
for target in _TARGETS:
|
|
75
|
+
path = root / target
|
|
76
|
+
if path.exists():
|
|
77
|
+
console.print(f" Removing: {target}")
|
|
78
|
+
_rmtree_with_retry(path)
|
|
79
|
+
|
|
80
|
+
console.print("\n[green]Done.[/]")
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"""``machina daemon`` -- run the backend as a detached process.
|
|
2
|
+
|
|
3
|
+
Pure-Python, all-platforms. No NSSM / systemd / launchd integration.
|
|
4
|
+
Spawns ``uvicorn main:app`` in a new session (POSIX) or detached
|
|
5
|
+
process group (Windows), writes the PID under ``~/.machina/`` so the
|
|
6
|
+
other verbs can find it, and uses ``psutil`` for tree-kill on stop.
|
|
7
|
+
|
|
8
|
+
For boot-time auto-start, configure your OS service manager
|
|
9
|
+
separately (`systemctl`, `launchctl`, Task Scheduler) -- this CLI does
|
|
10
|
+
not register itself with the system.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import os
|
|
16
|
+
import signal
|
|
17
|
+
import subprocess
|
|
18
|
+
import sys
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
import psutil
|
|
22
|
+
import typer
|
|
23
|
+
|
|
24
|
+
from machina.buildenv import venv_python
|
|
25
|
+
from machina.colors import console
|
|
26
|
+
from machina.platform_ import IS_WINDOWS, project_root
|
|
27
|
+
|
|
28
|
+
app = typer.Typer(
|
|
29
|
+
name="daemon",
|
|
30
|
+
help="Run the MachinaOs backend as a detached process.",
|
|
31
|
+
no_args_is_help=True,
|
|
32
|
+
add_completion=False,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
# ---------------------------------------------------------------- helpers
|
|
37
|
+
|
|
38
|
+
_PID_DIR = Path.home() / ".machina"
|
|
39
|
+
_PID_FILE = _PID_DIR / "machina-backend.pid"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _detached_kwargs() -> dict:
|
|
43
|
+
"""Cross-platform "spawn detached, survive parent exit" kwargs."""
|
|
44
|
+
if IS_WINDOWS:
|
|
45
|
+
# CREATE_NEW_PROCESS_GROUP lets us send CTRL_BREAK_EVENT later;
|
|
46
|
+
# DETACHED_PROCESS releases the console handle.
|
|
47
|
+
return {
|
|
48
|
+
"creationflags": (
|
|
49
|
+
subprocess.DETACHED_PROCESS | subprocess.CREATE_NEW_PROCESS_GROUP
|
|
50
|
+
),
|
|
51
|
+
}
|
|
52
|
+
# POSIX: setsid puts the child in its own process group / session,
|
|
53
|
+
# so killing the parent (this CLI) doesn't take the daemon down.
|
|
54
|
+
return {"start_new_session": True}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _read_pid() -> int | None:
|
|
58
|
+
if not _PID_FILE.exists():
|
|
59
|
+
return None
|
|
60
|
+
try:
|
|
61
|
+
pid = int(_PID_FILE.read_text().strip())
|
|
62
|
+
except (ValueError, OSError):
|
|
63
|
+
return None
|
|
64
|
+
return pid if psutil.pid_exists(pid) else None
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _kill_tree(pid: int) -> None:
|
|
68
|
+
try:
|
|
69
|
+
proc = psutil.Process(pid)
|
|
70
|
+
except psutil.NoSuchProcess:
|
|
71
|
+
return
|
|
72
|
+
for child in proc.children(recursive=True):
|
|
73
|
+
try:
|
|
74
|
+
child.kill()
|
|
75
|
+
except psutil.NoSuchProcess:
|
|
76
|
+
pass
|
|
77
|
+
try:
|
|
78
|
+
proc.terminate()
|
|
79
|
+
proc.wait(timeout=5)
|
|
80
|
+
except psutil.NoSuchProcess:
|
|
81
|
+
return
|
|
82
|
+
except psutil.TimeoutExpired:
|
|
83
|
+
try:
|
|
84
|
+
proc.kill()
|
|
85
|
+
except psutil.NoSuchProcess:
|
|
86
|
+
pass
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
# ---------------------------------------------------------------- verbs
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@app.command("start")
|
|
93
|
+
def start_command() -> None:
|
|
94
|
+
"""Start the backend in the background; write PID to ~/.machina/."""
|
|
95
|
+
if (existing := _read_pid()) is not None:
|
|
96
|
+
console.print(f"[yellow]Already running (pid={existing}).[/]")
|
|
97
|
+
return
|
|
98
|
+
|
|
99
|
+
root = project_root()
|
|
100
|
+
server = root / "server"
|
|
101
|
+
py = venv_python(root)
|
|
102
|
+
if py is None:
|
|
103
|
+
console.print(f'[red]Python venv not found at {py}.[/] Run "machina build" first.')
|
|
104
|
+
raise typer.Exit(code=1)
|
|
105
|
+
|
|
106
|
+
_PID_DIR.mkdir(parents=True, exist_ok=True)
|
|
107
|
+
log_file = _PID_DIR / "backend.log"
|
|
108
|
+
log = log_file.open("ab")
|
|
109
|
+
|
|
110
|
+
proc = subprocess.Popen(
|
|
111
|
+
[str(py), "-m", "uvicorn", "main:app",
|
|
112
|
+
"--host", "0.0.0.0", "--port", "3010", "--log-level", "warning"],
|
|
113
|
+
cwd=str(server),
|
|
114
|
+
stdin=subprocess.DEVNULL,
|
|
115
|
+
stdout=log,
|
|
116
|
+
stderr=log,
|
|
117
|
+
**_detached_kwargs(),
|
|
118
|
+
)
|
|
119
|
+
_PID_FILE.write_text(str(proc.pid))
|
|
120
|
+
console.print(f"[green]Started pid={proc.pid}[/] (logs: {log_file})")
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
@app.command("stop")
|
|
124
|
+
def stop_command() -> None:
|
|
125
|
+
"""Stop the backend if running; clear PID file."""
|
|
126
|
+
pid = _read_pid()
|
|
127
|
+
if pid is None:
|
|
128
|
+
console.print("Not running.")
|
|
129
|
+
_PID_FILE.unlink(missing_ok=True)
|
|
130
|
+
return
|
|
131
|
+
_kill_tree(pid)
|
|
132
|
+
_PID_FILE.unlink(missing_ok=True)
|
|
133
|
+
console.print(f"[green]Stopped pid={pid}[/]")
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
@app.command("status")
|
|
137
|
+
def status_command() -> None:
|
|
138
|
+
"""Report whether the backend is running."""
|
|
139
|
+
pid = _read_pid()
|
|
140
|
+
if pid is None:
|
|
141
|
+
console.print("Not running.")
|
|
142
|
+
raise typer.Exit(code=1)
|
|
143
|
+
console.print(f"[green]Running pid={pid}[/]")
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
@app.command("restart")
|
|
147
|
+
def restart_command() -> None:
|
|
148
|
+
"""Stop then start; convenience wrapper."""
|
|
149
|
+
stop_command()
|
|
150
|
+
start_command()
|