agmx 0.1.0__tar.gz
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.
- agmx-0.1.0/PKG-INFO +60 -0
- agmx-0.1.0/README.md +42 -0
- agmx-0.1.0/agmx/__init__.py +7 -0
- agmx-0.1.0/agmx/__main__.py +354 -0
- agmx-0.1.0/agmx/client.py +65 -0
- agmx-0.1.0/agmx/config.py +82 -0
- agmx-0.1.0/agmx/executor.py +272 -0
- agmx-0.1.0/agmx.egg-info/PKG-INFO +60 -0
- agmx-0.1.0/agmx.egg-info/SOURCES.txt +17 -0
- agmx-0.1.0/agmx.egg-info/dependency_links.txt +1 -0
- agmx-0.1.0/agmx.egg-info/entry_points.txt +2 -0
- agmx-0.1.0/agmx.egg-info/requires.txt +1 -0
- agmx-0.1.0/agmx.egg-info/top_level.txt +1 -0
- agmx-0.1.0/pyproject.toml +34 -0
- agmx-0.1.0/setup.cfg +4 -0
- agmx-0.1.0/tests/test_config.py +87 -0
- agmx-0.1.0/tests/test_executor.py +161 -0
- agmx-0.1.0/tests/test_login.py +86 -0
- agmx-0.1.0/tests/test_runner_loop.py +234 -0
agmx-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: agmx
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Chạy việc của AgentMatrix ngay trên máy bạn, bằng phiên đăng nhập CLI của chính bạn.
|
|
5
|
+
Author: lupca
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/lupca/AgentMatrix
|
|
8
|
+
Project-URL: Source, https://github.com/lupca/AgentMatrix/tree/main/runner
|
|
9
|
+
Keywords: agmx,agentmatrix,coding-agent,runner
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Environment :: Console
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Topic :: Software Development :: Build Tools
|
|
15
|
+
Requires-Python: >=3.10
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
Requires-Dist: httpx>=0.24
|
|
18
|
+
|
|
19
|
+
# agmx
|
|
20
|
+
|
|
21
|
+
Chạy việc của AgentMatrix ngay trên **máy bạn**, bằng phiên đăng nhập CLI của
|
|
22
|
+
chính bạn. Gói này chỉ cần Python và `httpx`: không Postgres, không Redis,
|
|
23
|
+
không `DATABASE_URL` của control plane.
|
|
24
|
+
|
|
25
|
+
## Cài
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
pipx install ./runner # hoặc: pipx install agmx
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Dùng
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
agmx login # hỏi email + mật khẩu, cất token
|
|
35
|
+
agmx runner start --project agmx=~/code/AgentMatrix
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
`--project` lặp lại được nhiều lần. Server **chỉ** gửi `project_id`; đường dẫn
|
|
39
|
+
thật do máy này quyết định, và một `project_id` chưa đăng ký ở đây bị báo
|
|
40
|
+
`failed` chứ không bao giờ bị đoán thành một thư mục nào đó.
|
|
41
|
+
|
|
42
|
+
## Cấu hình ở đâu
|
|
43
|
+
|
|
44
|
+
| File | Quyền | Chứa gì |
|
|
45
|
+
|---|---|---|
|
|
46
|
+
| `~/.agmx/.credentials.json` | `0600` | token đăng nhập |
|
|
47
|
+
| `~/.agmx/settings.json` | `0644` | `server`, `email`, `runner_id` |
|
|
48
|
+
|
|
49
|
+
`AGMX_CONFIG_DIR` đổi được thư mục. File `config.json` một-cục của bản cũ vẫn
|
|
50
|
+
đọc được, và được dọn đi ở lần ghi kế tiếp.
|
|
51
|
+
|
|
52
|
+
## Một run chạy như thế nào
|
|
53
|
+
|
|
54
|
+
Mỗi run được cấp một `git worktree` riêng trên nhánh `ct-run/<run_id>`, cắt từ
|
|
55
|
+
HEAD hiện tại. Lệnh chạy trong worktree đó, output đẩy về server theo mẻ trong
|
|
56
|
+
lúc đang chạy. Xong thì `git add -A` + commit, gỡ worktree nhưng **giữ nhánh**:
|
|
57
|
+
commit sống tiếp trong repo chính.
|
|
58
|
+
|
|
59
|
+
Runner **không phán xét**: nó báo mã thoát, `base_sha`, `head_sha`, có quá giờ
|
|
60
|
+
hay không. Ai thành ai bại là việc của server.
|
agmx-0.1.0/README.md
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# agmx
|
|
2
|
+
|
|
3
|
+
Chạy việc của AgentMatrix ngay trên **máy bạn**, bằng phiên đăng nhập CLI của
|
|
4
|
+
chính bạn. Gói này chỉ cần Python và `httpx`: không Postgres, không Redis,
|
|
5
|
+
không `DATABASE_URL` của control plane.
|
|
6
|
+
|
|
7
|
+
## Cài
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pipx install ./runner # hoặc: pipx install agmx
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Dùng
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
agmx login # hỏi email + mật khẩu, cất token
|
|
17
|
+
agmx runner start --project agmx=~/code/AgentMatrix
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
`--project` lặp lại được nhiều lần. Server **chỉ** gửi `project_id`; đường dẫn
|
|
21
|
+
thật do máy này quyết định, và một `project_id` chưa đăng ký ở đây bị báo
|
|
22
|
+
`failed` chứ không bao giờ bị đoán thành một thư mục nào đó.
|
|
23
|
+
|
|
24
|
+
## Cấu hình ở đâu
|
|
25
|
+
|
|
26
|
+
| File | Quyền | Chứa gì |
|
|
27
|
+
|---|---|---|
|
|
28
|
+
| `~/.agmx/.credentials.json` | `0600` | token đăng nhập |
|
|
29
|
+
| `~/.agmx/settings.json` | `0644` | `server`, `email`, `runner_id` |
|
|
30
|
+
|
|
31
|
+
`AGMX_CONFIG_DIR` đổi được thư mục. File `config.json` một-cục của bản cũ vẫn
|
|
32
|
+
đọc được, và được dọn đi ở lần ghi kế tiếp.
|
|
33
|
+
|
|
34
|
+
## Một run chạy như thế nào
|
|
35
|
+
|
|
36
|
+
Mỗi run được cấp một `git worktree` riêng trên nhánh `ct-run/<run_id>`, cắt từ
|
|
37
|
+
HEAD hiện tại. Lệnh chạy trong worktree đó, output đẩy về server theo mẻ trong
|
|
38
|
+
lúc đang chạy. Xong thì `git add -A` + commit, gỡ worktree nhưng **giữ nhánh**:
|
|
39
|
+
commit sống tiếp trong repo chính.
|
|
40
|
+
|
|
41
|
+
Runner **không phán xét**: nó báo mã thoát, `base_sha`, `head_sha`, có quá giờ
|
|
42
|
+
hay không. Ai thành ai bại là việc của server.
|
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
"""``agmx`` -- công cụ dòng lệnh chạy trên MÁY CỦA NGƯỜI DÙNG.
|
|
2
|
+
|
|
3
|
+
Hai việc duy nhất:
|
|
4
|
+
|
|
5
|
+
* ``agmx login`` -- đổi email/mật khẩu lấy token, cất vào ``~/.agmx``.
|
|
6
|
+
* ``agmx runner start`` -- nhận việc từ server rồi thực thi ngay tại đây, bằng
|
|
7
|
+
phiên đăng nhập CLI của chính người dùng này.
|
|
8
|
+
|
|
9
|
+
Điểm mấu chốt của thiết kế: **server không nói đường dẫn**. Nó chỉ nói
|
|
10
|
+
``project_id``; máy này tự tra ra thư mục của mình từ danh sách ``--project``
|
|
11
|
+
đã đăng ký. Một ``project_id`` lạ bị báo ``failed`` chứ không bao giờ được đoán
|
|
12
|
+
thành một đường dẫn nào đó.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import argparse
|
|
18
|
+
import getpass
|
|
19
|
+
import os
|
|
20
|
+
import socket
|
|
21
|
+
import sys
|
|
22
|
+
import time
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
|
|
25
|
+
import httpx
|
|
26
|
+
|
|
27
|
+
from . import config, executor
|
|
28
|
+
from .client import DEFAULT_SERVER, Client
|
|
29
|
+
|
|
30
|
+
DEFAULT_POLL_SECONDS = 5
|
|
31
|
+
|
|
32
|
+
#: Mã lỗi server trả về -> câu người đọc hiểu được.
|
|
33
|
+
_LOGIN_ERRORS = {
|
|
34
|
+
"invalid_credentials": "email hoặc mật khẩu không đúng.",
|
|
35
|
+
"locked": "tài khoản đang bị khoá (sai mật khẩu quá nhiều lần).",
|
|
36
|
+
"disabled": "tài khoản đã bị vô hiệu hoá.",
|
|
37
|
+
"invalid_request": "server từ chối yêu cầu (thiếu email hoặc mật khẩu).",
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _sleep(seconds: float) -> None:
|
|
42
|
+
"""Chỗ duy nhất runner ngủ -- test thay nó để vòng lặp chạy tức thì."""
|
|
43
|
+
time.sleep(seconds)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
# --------------------------------------------------------------------------- #
|
|
47
|
+
# agmx login
|
|
48
|
+
# --------------------------------------------------------------------------- #
|
|
49
|
+
|
|
50
|
+
def cmd_login(args: argparse.Namespace) -> int:
|
|
51
|
+
cfg = config.load()
|
|
52
|
+
server = args.server or cfg.get("server") or os.environ.get("AGMX_SERVER") or DEFAULT_SERVER
|
|
53
|
+
|
|
54
|
+
email = (args.email or input("Email: ")).strip()
|
|
55
|
+
if not email:
|
|
56
|
+
print("Chưa nhập email, dừng lại.", file=sys.stderr)
|
|
57
|
+
return 1
|
|
58
|
+
# Mật khẩu CHỈ đọc từ TTY: một cờ --password nằm lại trong lịch sử shell và
|
|
59
|
+
# trong argv của tiến trình, ai chạy `ps` cũng đọc được.
|
|
60
|
+
password = getpass.getpass("Mật khẩu: ")
|
|
61
|
+
if not password:
|
|
62
|
+
print("Chưa nhập mật khẩu, dừng lại.", file=sys.stderr)
|
|
63
|
+
return 1
|
|
64
|
+
|
|
65
|
+
try:
|
|
66
|
+
status, body = Client(server).login(email, password)
|
|
67
|
+
except httpx.HTTPError as exc:
|
|
68
|
+
print(f"Không gọi được server {server}: {exc}", file=sys.stderr)
|
|
69
|
+
return 1
|
|
70
|
+
|
|
71
|
+
if status != 200 or not body.get("ok") or not body.get("token"):
|
|
72
|
+
code = str(body.get("error") or f"HTTP {status}")
|
|
73
|
+
print(f"Đăng nhập thất bại: {_LOGIN_ERRORS.get(code, code)}", file=sys.stderr)
|
|
74
|
+
return 1
|
|
75
|
+
|
|
76
|
+
# Ghi cấu hình MỚI, không vá lên cái cũ: đăng nhập là một phiên khác, và
|
|
77
|
+
# runner_id của phiên trước có thể thuộc về người khác.
|
|
78
|
+
config.save({
|
|
79
|
+
"server": server,
|
|
80
|
+
"token": body["token"],
|
|
81
|
+
"expires_at": body.get("expires_at"),
|
|
82
|
+
"email": email,
|
|
83
|
+
})
|
|
84
|
+
name = (body.get("user") or {}).get("name") or email
|
|
85
|
+
print(f"Đã đăng nhập {name} <{email}> tại {server}.")
|
|
86
|
+
print(f"Token cất ở {config.credentials_path()} (quyền 0600).")
|
|
87
|
+
return 0
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
# --------------------------------------------------------------------------- #
|
|
91
|
+
# agmx runner start
|
|
92
|
+
# --------------------------------------------------------------------------- #
|
|
93
|
+
|
|
94
|
+
def parse_projects(pairs: list[str]) -> dict[str, str]:
|
|
95
|
+
"""``["agmx=/repo"]`` -> ``{"agmx": "/repo"}``. Đây là bản đồ duy nhất từ
|
|
96
|
+
``project_id`` ra đường dẫn thật, và nó nằm ở phía máy người dùng."""
|
|
97
|
+
projects: dict[str, str] = {}
|
|
98
|
+
for raw in pairs:
|
|
99
|
+
project_id, separator, local_path = raw.partition("=")
|
|
100
|
+
project_id, local_path = project_id.strip(), local_path.strip()
|
|
101
|
+
if not separator or not project_id or not local_path:
|
|
102
|
+
raise ValueError(
|
|
103
|
+
f"--project phải có dạng PROJECT_ID=/đường/dẫn, nhận được: {raw!r}"
|
|
104
|
+
)
|
|
105
|
+
projects[project_id] = str(Path(local_path).expanduser().resolve())
|
|
106
|
+
return projects
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _enroll(client: Client, projects: dict[str, str]) -> str | None:
|
|
110
|
+
"""Đăng ký máy này với server. Trả ``runner_id``, hoặc ``None`` nếu hỏng."""
|
|
111
|
+
try:
|
|
112
|
+
status, body = client.enroll(socket.gethostname(), projects)
|
|
113
|
+
except httpx.HTTPError as exc:
|
|
114
|
+
print(f"Không đăng ký được runner với {client.server}: {exc}", file=sys.stderr)
|
|
115
|
+
return None
|
|
116
|
+
if status != 200 or not body.get("ok") or not body.get("runner_id"):
|
|
117
|
+
print(
|
|
118
|
+
f"Server từ chối đăng ký runner (HTTP {status}): {body.get('error') or body}",
|
|
119
|
+
file=sys.stderr,
|
|
120
|
+
)
|
|
121
|
+
return None
|
|
122
|
+
|
|
123
|
+
runner_id = str(body["runner_id"])
|
|
124
|
+
config.save({**config.load(), "runner_id": runner_id})
|
|
125
|
+
return runner_id
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _report(client: Client, runner_id: str, run_id: str, status: str, **facts) -> None:
|
|
129
|
+
try:
|
|
130
|
+
code, body = client.result(runner_id, run_id, status, **facts)
|
|
131
|
+
except httpx.HTTPError as exc:
|
|
132
|
+
# Mất kết quả của một run thì tiếc, nhưng giết cả runner thì tệ hơn.
|
|
133
|
+
print(f"Không báo được kết quả run {run_id} về server: {exc}", file=sys.stderr)
|
|
134
|
+
return
|
|
135
|
+
if code != 200 or not body.get("ok"):
|
|
136
|
+
# Im lặng ở đây nghĩa là server vẫn nghĩ run đang chạy, còn máy này thì
|
|
137
|
+
# đã quên nó -- nói ra để người dùng còn biết mà tra.
|
|
138
|
+
print(
|
|
139
|
+
f"Server không nhận kết quả run {run_id} (HTTP {code}): "
|
|
140
|
+
f"{body.get('error') or body}",
|
|
141
|
+
file=sys.stderr,
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _output_sink(client: Client, runner_id: str, run_id: str):
|
|
146
|
+
def send(seq: int, stream: str, content: str) -> None:
|
|
147
|
+
try:
|
|
148
|
+
client.output(runner_id, run_id, seq, stream, content)
|
|
149
|
+
except httpx.HTTPError as exc:
|
|
150
|
+
# Mất một mẻ log không phải lý do để bỏ dở việc đang chạy.
|
|
151
|
+
print(f"Không đẩy được output run {run_id} (mẻ {seq}): {exc}", file=sys.stderr)
|
|
152
|
+
|
|
153
|
+
return send
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _handle_run(client: Client, runner_id: str, projects: dict[str, str], run: dict) -> None:
|
|
157
|
+
run_id = str(run.get("run_id") or "")
|
|
158
|
+
task_id = str(run.get("task_id") or "")
|
|
159
|
+
project_id = run.get("project_id")
|
|
160
|
+
|
|
161
|
+
local_path = projects.get(project_id)
|
|
162
|
+
if local_path is None:
|
|
163
|
+
known = ", ".join(sorted(projects)) or "(chưa đăng ký project nào)"
|
|
164
|
+
reason = (
|
|
165
|
+
f"project_id {project_id!r} chưa đăng ký trên máy này; "
|
|
166
|
+
f"runner chỉ nhận việc cho: {known}"
|
|
167
|
+
)
|
|
168
|
+
print(f"Bỏ qua run {run_id}: {reason}", file=sys.stderr)
|
|
169
|
+
_report(client, runner_id, run_id, "failed", error=reason)
|
|
170
|
+
return
|
|
171
|
+
|
|
172
|
+
print(f"Nhận run {run_id} (task {task_id}), project {project_id} -> {local_path}")
|
|
173
|
+
try:
|
|
174
|
+
outcome = executor.execute(
|
|
175
|
+
local_path=local_path,
|
|
176
|
+
run_id=run_id,
|
|
177
|
+
command=str(run.get("command") or ""),
|
|
178
|
+
timeout_seconds=float(run.get("timeout_seconds") or 900),
|
|
179
|
+
on_output=_output_sink(client, runner_id, run_id),
|
|
180
|
+
)
|
|
181
|
+
except Exception as exc: # kể cả lỗi lạ: im lặng nuốt là mất hẳn một run
|
|
182
|
+
reason = f"thực thi thất bại: {type(exc).__name__}: {exc}"
|
|
183
|
+
print(f"Run {run_id} lỗi: {reason}", file=sys.stderr)
|
|
184
|
+
_report(client, runner_id, run_id, "failed", error=reason)
|
|
185
|
+
return
|
|
186
|
+
|
|
187
|
+
# ``status`` là bắt buộc trong hợp đồng, nên nó được nói lại một cách MÁY
|
|
188
|
+
# MÓC từ mã thoát: không đọc output, không suy diễn, không dựng result_ref.
|
|
189
|
+
# Sự việc thô đi kèm ngay bên cạnh để server tự phán.
|
|
190
|
+
if outcome["timed_out"]:
|
|
191
|
+
status = "timeout"
|
|
192
|
+
else:
|
|
193
|
+
status = "success" if outcome["exit_code"] == 0 else "failed"
|
|
194
|
+
print(f"Run {run_id} kết thúc: {status} (mã thoát {outcome['exit_code']}).")
|
|
195
|
+
_report(client, runner_id, run_id, status, **outcome)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def poll_loop(
|
|
199
|
+
client: Client,
|
|
200
|
+
runner_id: str,
|
|
201
|
+
projects: dict[str, str],
|
|
202
|
+
poll_seconds: float = DEFAULT_POLL_SECONDS,
|
|
203
|
+
max_iterations: int | None = None,
|
|
204
|
+
) -> None:
|
|
205
|
+
"""Vòng heartbeat -> lease -> thực thi -> result, tới khi Ctrl-C.
|
|
206
|
+
|
|
207
|
+
``max_iterations`` chỉ để test không chạy vô hạn.
|
|
208
|
+
"""
|
|
209
|
+
iterations = 0
|
|
210
|
+
while max_iterations is None or iterations < max_iterations:
|
|
211
|
+
iterations += 1
|
|
212
|
+
try:
|
|
213
|
+
status, body = client.heartbeat(runner_id)
|
|
214
|
+
if status == 404 and body.get("error") == "unknown_runner":
|
|
215
|
+
# Server không còn biết runner này. Đăng ký lại, chứ poll mãi
|
|
216
|
+
# vào một id đã chết thì runner sống mà không nhận được việc.
|
|
217
|
+
print("Server không còn nhớ runner này, đăng ký lại.", file=sys.stderr)
|
|
218
|
+
new_id = _enroll(client, projects)
|
|
219
|
+
if new_id is None:
|
|
220
|
+
_sleep(poll_seconds)
|
|
221
|
+
continue
|
|
222
|
+
runner_id = new_id
|
|
223
|
+
|
|
224
|
+
status, body = client.lease(runner_id)
|
|
225
|
+
if status != 200 or not body.get("ok"):
|
|
226
|
+
print(
|
|
227
|
+
f"Server từ chối cấp việc (HTTP {status}): {body.get('error') or body}",
|
|
228
|
+
file=sys.stderr,
|
|
229
|
+
)
|
|
230
|
+
_sleep(poll_seconds)
|
|
231
|
+
continue
|
|
232
|
+
|
|
233
|
+
run = body.get("run")
|
|
234
|
+
if not run:
|
|
235
|
+
_sleep(poll_seconds)
|
|
236
|
+
continue
|
|
237
|
+
except httpx.HTTPError as exc:
|
|
238
|
+
# Máy người dùng rớt mạng là chuyện thường ngày, không phải lý do
|
|
239
|
+
# để runner chết.
|
|
240
|
+
print(f"Lỗi mạng: {exc}. Thử lại sau {poll_seconds}s.", file=sys.stderr)
|
|
241
|
+
_sleep(poll_seconds)
|
|
242
|
+
continue
|
|
243
|
+
|
|
244
|
+
_handle_run(client, runner_id, projects, run)
|
|
245
|
+
# Vừa có việc thì nhiều khả năng còn việc nữa: lease tiếp, đừng ngủ.
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def cmd_runner_start(args: argparse.Namespace) -> int:
|
|
249
|
+
cfg = config.load()
|
|
250
|
+
token = cfg.get("token")
|
|
251
|
+
if not token:
|
|
252
|
+
print("Chưa đăng nhập, chạy `agmx login` trước.", file=sys.stderr)
|
|
253
|
+
return 1
|
|
254
|
+
server = args.server or cfg.get("server") or DEFAULT_SERVER
|
|
255
|
+
|
|
256
|
+
try:
|
|
257
|
+
projects = parse_projects(args.project)
|
|
258
|
+
except ValueError as exc:
|
|
259
|
+
print(str(exc), file=sys.stderr)
|
|
260
|
+
return 1
|
|
261
|
+
for project_id, local_path in sorted(projects.items()):
|
|
262
|
+
if not Path(local_path).is_dir():
|
|
263
|
+
print(
|
|
264
|
+
f"Cảnh báo: {project_id} trỏ tới {local_path} nhưng đó không "
|
|
265
|
+
f"phải thư mục đang tồn tại.",
|
|
266
|
+
file=sys.stderr,
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
client = Client(server, token)
|
|
270
|
+
runner_id = _enroll(client, projects)
|
|
271
|
+
if runner_id is None:
|
|
272
|
+
return 1
|
|
273
|
+
|
|
274
|
+
print(
|
|
275
|
+
f"Runner {runner_id} chạy trên {socket.gethostname()}, "
|
|
276
|
+
f"hỏi việc {server} mỗi {args.poll_seconds}s. Ctrl-C để dừng."
|
|
277
|
+
)
|
|
278
|
+
poll_loop(client, runner_id, projects, args.poll_seconds)
|
|
279
|
+
return 0
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
# --------------------------------------------------------------------------- #
|
|
283
|
+
# Vào cửa
|
|
284
|
+
# --------------------------------------------------------------------------- #
|
|
285
|
+
|
|
286
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
287
|
+
parser = argparse.ArgumentParser(
|
|
288
|
+
prog="agmx",
|
|
289
|
+
description=(
|
|
290
|
+
"agmx -- chạy việc của AgentMatrix ngay trên máy bạn, bằng phiên "
|
|
291
|
+
"đăng nhập CLI của chính bạn."
|
|
292
|
+
),
|
|
293
|
+
)
|
|
294
|
+
subparsers = parser.add_subparsers(dest="command", metavar="LỆNH")
|
|
295
|
+
|
|
296
|
+
login = subparsers.add_parser(
|
|
297
|
+
"login",
|
|
298
|
+
help="đăng nhập vào server và cất token vào ~/.agmx/.credentials.json",
|
|
299
|
+
description="Đăng nhập. Mật khẩu chỉ nhập từ bàn phím, không có cờ --password.",
|
|
300
|
+
)
|
|
301
|
+
login.add_argument("--server", help=f"địa chỉ server (mặc định {DEFAULT_SERVER})")
|
|
302
|
+
login.add_argument("--email", help="email đăng nhập (bỏ trống thì sẽ hỏi)")
|
|
303
|
+
login.set_defaults(func=cmd_login)
|
|
304
|
+
|
|
305
|
+
runner = subparsers.add_parser(
|
|
306
|
+
"runner",
|
|
307
|
+
help="điều khiển runner trên máy này",
|
|
308
|
+
description="Runner: nhận việc từ server rồi chạy ngay tại máy này.",
|
|
309
|
+
)
|
|
310
|
+
runner_sub = runner.add_subparsers(dest="runner_command", metavar="LỆNH")
|
|
311
|
+
start = runner_sub.add_parser(
|
|
312
|
+
"start",
|
|
313
|
+
help="đăng ký máy này rồi nhận việc liên tục",
|
|
314
|
+
description=(
|
|
315
|
+
"Đăng ký máy này với server rồi lặp: xin việc, chạy, báo kết quả. "
|
|
316
|
+
"Server chỉ gửi project_id; đường dẫn thật do máy này quyết định."
|
|
317
|
+
),
|
|
318
|
+
)
|
|
319
|
+
start.add_argument(
|
|
320
|
+
"--project",
|
|
321
|
+
action="append",
|
|
322
|
+
default=[],
|
|
323
|
+
required=True,
|
|
324
|
+
metavar="PROJECT_ID=/đường/dẫn",
|
|
325
|
+
help="project máy này nhận việc, lặp lại được nhiều lần",
|
|
326
|
+
)
|
|
327
|
+
start.add_argument(
|
|
328
|
+
"--poll-seconds",
|
|
329
|
+
type=float,
|
|
330
|
+
default=DEFAULT_POLL_SECONDS,
|
|
331
|
+
help=f"nghỉ bao lâu giữa hai lần hỏi việc (mặc định {DEFAULT_POLL_SECONDS})",
|
|
332
|
+
)
|
|
333
|
+
start.add_argument("--server", help="ghi đè địa chỉ server đã lưu khi đăng nhập")
|
|
334
|
+
start.set_defaults(func=cmd_runner_start)
|
|
335
|
+
runner.set_defaults(func=lambda _args: (runner.print_help(), 1)[1])
|
|
336
|
+
|
|
337
|
+
return parser
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def main(argv: list[str] | None = None) -> int:
|
|
341
|
+
parser = build_parser()
|
|
342
|
+
args = parser.parse_args(argv)
|
|
343
|
+
if not getattr(args, "func", None):
|
|
344
|
+
parser.print_help()
|
|
345
|
+
return 1
|
|
346
|
+
try:
|
|
347
|
+
return args.func(args)
|
|
348
|
+
except KeyboardInterrupt:
|
|
349
|
+
print("\nĐã dừng. Tạm biệt.")
|
|
350
|
+
return 0
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
if __name__ == "__main__":
|
|
354
|
+
sys.exit(main())
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Các route HTTP máy này nói với control plane. Chỉ httpx, không gì khác."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
|
|
7
|
+
# Control plane công khai. HTTPS không phải trang trí: ``agmx login`` đặt mật
|
|
8
|
+
# khẩu vào thân yêu cầu, http trần trên internet là đưa nó cho cả đường dây.
|
|
9
|
+
DEFAULT_SERVER = "https://agmx.nothanagentic.vn"
|
|
10
|
+
HTTP_TIMEOUT = 30.0
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Client:
|
|
14
|
+
"""Một phiên nói chuyện với server. ``token`` rỗng thì chỉ login được."""
|
|
15
|
+
|
|
16
|
+
def __init__(self, server: str | None, token: str | None = None, timeout: float = HTTP_TIMEOUT):
|
|
17
|
+
self.server = (server or DEFAULT_SERVER).rstrip("/")
|
|
18
|
+
self.token = token
|
|
19
|
+
self._http = httpx.Client(timeout=timeout)
|
|
20
|
+
|
|
21
|
+
def post(self, path: str, payload: dict) -> tuple[int, dict]:
|
|
22
|
+
"""POST JSON, trả ``(status_code, body)``. Ném ``httpx.HTTPError`` khi mạng hỏng."""
|
|
23
|
+
headers = {"Authorization": f"Bearer {self.token}"} if self.token else {}
|
|
24
|
+
response = self._http.post(self.server + path, json=payload, headers=headers)
|
|
25
|
+
try:
|
|
26
|
+
body = response.json()
|
|
27
|
+
except ValueError:
|
|
28
|
+
body = {}
|
|
29
|
+
return response.status_code, body if isinstance(body, dict) else {}
|
|
30
|
+
|
|
31
|
+
# -- 5 route -------------------------------------------------------- #
|
|
32
|
+
|
|
33
|
+
def login(self, email: str, password: str) -> tuple[int, dict]:
|
|
34
|
+
return self.post("/auth/login", {"email": email, "password": password})
|
|
35
|
+
|
|
36
|
+
def enroll(self, hostname: str, projects: dict[str, str]) -> tuple[int, dict]:
|
|
37
|
+
return self.post("/runner/enroll", {
|
|
38
|
+
"hostname": hostname,
|
|
39
|
+
"projects": [
|
|
40
|
+
{"project_id": project_id, "local_path": local_path}
|
|
41
|
+
for project_id, local_path in sorted(projects.items())
|
|
42
|
+
],
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
def heartbeat(self, runner_id: str) -> tuple[int, dict]:
|
|
46
|
+
return self.post("/runner/heartbeat", {"runner_id": runner_id})
|
|
47
|
+
|
|
48
|
+
def lease(self, runner_id: str) -> tuple[int, dict]:
|
|
49
|
+
return self.post("/runner/lease", {"runner_id": runner_id})
|
|
50
|
+
|
|
51
|
+
def output(self, runner_id: str, run_id: str, seq: int, stream: str, content: str):
|
|
52
|
+
return self.post("/runner/output", {
|
|
53
|
+
"runner_id": runner_id,
|
|
54
|
+
"run_id": run_id,
|
|
55
|
+
"seq": seq,
|
|
56
|
+
"stream": stream,
|
|
57
|
+
"content": content,
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
def result(self, runner_id: str, run_id: str, status: str, **facts) -> tuple[int, dict]:
|
|
61
|
+
"""Báo run đã kết thúc. ``facts``: exit_code, base_sha, head_sha,
|
|
62
|
+
timed_out, error -- những gì máy này QUAN SÁT được, không phải kết luận."""
|
|
63
|
+
payload = {"runner_id": runner_id, "run_id": run_id, "status": status}
|
|
64
|
+
payload.update(facts)
|
|
65
|
+
return self.post("/runner/result", payload)
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""Cấu hình của ``agmx``: bí mật một nơi, phần còn lại một nơi khác.
|
|
2
|
+
|
|
3
|
+
``~/.agmx/.credentials.json`` (0600) giữ token; ``~/.agmx/settings.json``
|
|
4
|
+
(0644) giữ những thứ không phải bí mật (server, runner_id, email). Tách ra
|
|
5
|
+
theo đúng cách ``~/.claude`` làm: settings là thứ người ta mở ra xem, sửa, dán
|
|
6
|
+
vào issue; token thì không. Gộp chung một file nghĩa là mỗi lần khoe cấu hình
|
|
7
|
+
là một lần lộ phiên đăng nhập.
|
|
8
|
+
|
|
9
|
+
``config.json`` kiểu cũ vẫn đọc được, để máy đã cài không phải đăng nhập lại.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
import os
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
#: Những khoá là BÍ MẬT. Chỉ chúng mới được vào file 0600, và mọi khoá khác
|
|
19
|
+
#: đều ra file 0644 -- danh sách này là ranh giới, không phải gợi ý.
|
|
20
|
+
SECRET_KEYS = ("token", "expires_at")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def config_dir() -> Path:
|
|
24
|
+
"""Thư mục config. ``AGMX_CONFIG_DIR`` đổi được (test không đụng ``~`` thật)."""
|
|
25
|
+
return Path(os.environ.get("AGMX_CONFIG_DIR") or (Path.home() / ".agmx"))
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def credentials_path() -> Path:
|
|
29
|
+
return config_dir() / ".credentials.json"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def settings_path() -> Path:
|
|
33
|
+
return config_dir() / "settings.json"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def legacy_path() -> Path:
|
|
37
|
+
"""File một-cục của bản cũ. Chỉ còn được ĐỌC, không bao giờ được ghi nữa."""
|
|
38
|
+
return config_dir() / "config.json"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _read(path: Path) -> dict:
|
|
42
|
+
try:
|
|
43
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
44
|
+
except (OSError, ValueError):
|
|
45
|
+
return {}
|
|
46
|
+
return data if isinstance(data, dict) else {}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def load() -> dict:
|
|
50
|
+
"""Cấu hình đã lưu: file cũ làm nền, file mới đè lên."""
|
|
51
|
+
merged = _read(legacy_path())
|
|
52
|
+
merged.update(_read(settings_path()))
|
|
53
|
+
merged.update(_read(credentials_path()))
|
|
54
|
+
return merged
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _write(path: Path, data: dict, mode: int) -> None:
|
|
58
|
+
"""Ghi với quyền đặt TRƯỚC khi có nội dung.
|
|
59
|
+
|
|
60
|
+
``open()`` rồi ``chmod()`` để lại một khoảng thời gian token nằm trên đĩa
|
|
61
|
+
với quyền mặc định; ai đọc được trong khoảng đó thì đọc được phiên đăng
|
|
62
|
+
nhập. ``os.open`` mang sẵn mode, và ``fchmod`` ép đúng quyền kể cả khi file
|
|
63
|
+
đã tồn tại sẵn với quyền rộng hơn (``O_CREAT`` không sửa quyền file cũ) hay
|
|
64
|
+
khi umask của máy cắt bớt.
|
|
65
|
+
"""
|
|
66
|
+
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, mode)
|
|
67
|
+
os.fchmod(fd, mode)
|
|
68
|
+
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
|
69
|
+
handle.write(json.dumps(data, indent=2, ensure_ascii=False) + "\n")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def save(data: dict) -> None:
|
|
73
|
+
"""Cất cấu hình, tự tách bí mật ra khỏi phần còn lại."""
|
|
74
|
+
directory = config_dir()
|
|
75
|
+
directory.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
76
|
+
os.chmod(directory, 0o700) # mkdir(mode=) im lặng bỏ qua khi thư mục đã có
|
|
77
|
+
|
|
78
|
+
_write(credentials_path(), {k: data[k] for k in SECRET_KEYS if k in data}, 0o600)
|
|
79
|
+
_write(settings_path(), {k: v for k, v in data.items() if k not in SECRET_KEYS}, 0o644)
|
|
80
|
+
# Bỏ file cũ đi: token của nó không còn ai đọc nữa nhưng vẫn ai cũng đọc
|
|
81
|
+
# được. Một bản sao bí mật nằm lì trên đĩa là bí mật bị lộ, chỉ chậm hơn.
|
|
82
|
+
legacy_path().unlink(missing_ok=True)
|