agmx 0.1.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.
- agmx/__init__.py +7 -0
- agmx/__main__.py +354 -0
- agmx/client.py +65 -0
- agmx/config.py +82 -0
- agmx/executor.py +272 -0
- agmx-0.1.0.dist-info/METADATA +60 -0
- agmx-0.1.0.dist-info/RECORD +10 -0
- agmx-0.1.0.dist-info/WHEEL +5 -0
- agmx-0.1.0.dist-info/entry_points.txt +2 -0
- agmx-0.1.0.dist-info/top_level.txt +1 -0
agmx/__init__.py
ADDED
agmx/__main__.py
ADDED
|
@@ -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())
|
agmx/client.py
ADDED
|
@@ -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)
|
agmx/config.py
ADDED
|
@@ -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)
|
agmx/executor.py
ADDED
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
"""Chạy MỘT run trên máy này: worktree riêng, spawn, đẩy output theo mẻ.
|
|
2
|
+
|
|
3
|
+
Chỉ ``subprocess`` + ``git``. Không có gì của control plane ở đây.
|
|
4
|
+
|
|
5
|
+
Và cố ý không có: **runner không phán xét**. Nó không quyết định run thành công
|
|
6
|
+
hay thất bại, không dựng ``result_ref``, không đọc output để đoán ý người ta
|
|
7
|
+
muốn gì. Nó trả về sự việc -- mã thoát, base, head, có quá giờ hay không -- rồi
|
|
8
|
+
server phán. Logic phán xét bên server đã trả giá bằng nhiều sự cố thật; chép
|
|
9
|
+
lại ở phía client là mua lại đúng bộ bug đó, lần này trên máy người lạ, nơi
|
|
10
|
+
không ai vá được.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import os
|
|
16
|
+
import queue
|
|
17
|
+
import shutil
|
|
18
|
+
import signal
|
|
19
|
+
import subprocess
|
|
20
|
+
import tempfile
|
|
21
|
+
import threading
|
|
22
|
+
import time
|
|
23
|
+
from typing import Callable
|
|
24
|
+
|
|
25
|
+
#: Biến git trỏ vào repo KHÁC. Không xoá khỏi môi trường con thì lệnh git của
|
|
26
|
+
#: agent ghi thẳng vào repo chính dù ``cwd`` là worktree: GIT_DIR thắng cwd,
|
|
27
|
+
#: luôn luôn. Đây là sự cố có thật của dự án (CTV2-1427).
|
|
28
|
+
GIT_POINTER_VARS = (
|
|
29
|
+
"GIT_DIR",
|
|
30
|
+
"GIT_WORK_TREE",
|
|
31
|
+
"GIT_COMMON_DIR",
|
|
32
|
+
"GIT_INDEX_FILE",
|
|
33
|
+
"GIT_OBJECT_DIRECTORY",
|
|
34
|
+
"GIT_ALTERNATE_OBJECT_DIRECTORIES",
|
|
35
|
+
"GIT_NAMESPACE",
|
|
36
|
+
"GIT_CEILING_DIRECTORIES",
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
#: Bao lâu thì đẩy một mẻ output đi, kể cả khi mẻ còn ngắn. Người ngồi xem một
|
|
40
|
+
#: run 15 phút cần thấy nó đang nói gì, không phải đợi tới lúc nó im hẳn.
|
|
41
|
+
FLUSH_SECONDS = 0.5
|
|
42
|
+
#: Trần của MỘT mẻ, đếm bằng KÝ TỰ chứ không phải byte: server từ chối body quá
|
|
43
|
+
#: 64KiB, và một ký tự UTF-8 dài nhất 4 byte, nên 16k ký tự luôn lọt.
|
|
44
|
+
MAX_BATCH_CHARS = 16_000
|
|
45
|
+
#: Chờ tiến trình chết tử tế bao lâu trước khi SIGKILL cả nhóm.
|
|
46
|
+
KILL_GRACE_SECONDS = 3.0
|
|
47
|
+
|
|
48
|
+
#: Nhận ``(seq, stream, content)``. ``seq`` đánh từ 0, tăng dần theo từng run.
|
|
49
|
+
OutputSink = Callable[[int, str, str], None]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def child_env() -> dict[str, str]:
|
|
53
|
+
"""Môi trường cho tiến trình con: bản sao ĐẦY ĐỦ, đã bỏ con trỏ git.
|
|
54
|
+
|
|
55
|
+
Trả về cả môi trường chứ không phải lớp phủ. Xoá một khoá khỏi lớp phủ là
|
|
56
|
+
vô nghĩa -- ``Popen`` dựng môi trường con từ ``os.environ`` rồi mới đắp lớp
|
|
57
|
+
phủ lên, nên khoá đã xoá vẫn thừa kế nguyên vẹn (CTV2-1758).
|
|
58
|
+
"""
|
|
59
|
+
env = dict(os.environ)
|
|
60
|
+
for name in GIT_POINTER_VARS:
|
|
61
|
+
env.pop(name, None)
|
|
62
|
+
return env
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _git(cwd: str, *args: str, check: bool = True) -> subprocess.CompletedProcess:
|
|
66
|
+
result = subprocess.run(
|
|
67
|
+
["git", *args],
|
|
68
|
+
cwd=cwd,
|
|
69
|
+
capture_output=True,
|
|
70
|
+
text=True,
|
|
71
|
+
errors="replace",
|
|
72
|
+
env=child_env(),
|
|
73
|
+
check=False,
|
|
74
|
+
)
|
|
75
|
+
if check and result.returncode != 0:
|
|
76
|
+
detail = (result.stderr or result.stdout or "").strip()
|
|
77
|
+
raise RuntimeError(f"`git {' '.join(args)}` hỏng: {detail}")
|
|
78
|
+
return result
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def branch_name(run_id: str) -> str:
|
|
82
|
+
return f"ct-run/{run_id}"
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def execute(
|
|
86
|
+
local_path: str,
|
|
87
|
+
run_id: str,
|
|
88
|
+
command: str,
|
|
89
|
+
timeout_seconds: float,
|
|
90
|
+
on_output: OutputSink | None = None,
|
|
91
|
+
) -> dict:
|
|
92
|
+
"""Chạy ``command`` trong worktree riêng của ``run_id``.
|
|
93
|
+
|
|
94
|
+
Trả về ``{exit_code, base_sha, head_sha, timed_out}`` -- dữ liệu thô, không
|
|
95
|
+
kèm lời phán nào. ``head_sha`` là ``None`` khi run không đổi gì cả.
|
|
96
|
+
"""
|
|
97
|
+
base_sha = _git(local_path, "rev-parse", "HEAD").stdout.strip()
|
|
98
|
+
parent = tempfile.mkdtemp(prefix=f"agmx-{run_id}-")
|
|
99
|
+
worktree = os.path.join(parent, "worktree")
|
|
100
|
+
# ``-B`` chứ không ``-b``: cùng một run được giao lại (server restart, lease
|
|
101
|
+
# hết hạn) thì làm lại từ đầu, chứ không hỏng vĩnh viễn vì nhánh đã có.
|
|
102
|
+
_git(local_path, "worktree", "add", "-B", branch_name(run_id), worktree, base_sha)
|
|
103
|
+
try:
|
|
104
|
+
exit_code, timed_out = _spawn(command, worktree, timeout_seconds, on_output)
|
|
105
|
+
head_sha = _commit(worktree, run_id)
|
|
106
|
+
finally:
|
|
107
|
+
# Gỡ worktree nhưng GIỮ nhánh: worktree chỉ là thư mục, commit nằm
|
|
108
|
+
# trong object database dùng chung của repo chính và sống tiếp.
|
|
109
|
+
_git(local_path, "worktree", "remove", "--force", worktree, check=False)
|
|
110
|
+
shutil.rmtree(parent, ignore_errors=True)
|
|
111
|
+
return {
|
|
112
|
+
"exit_code": exit_code,
|
|
113
|
+
"base_sha": base_sha,
|
|
114
|
+
"head_sha": head_sha,
|
|
115
|
+
"timed_out": timed_out,
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _commit(worktree: str, run_id: str) -> str | None:
|
|
120
|
+
"""Commit những gì run để lại. ``None`` khi nó không để lại gì."""
|
|
121
|
+
_git(worktree, "add", "-A")
|
|
122
|
+
if _git(worktree, "diff", "--cached", "--quiet", check=False).returncode == 0:
|
|
123
|
+
return None
|
|
124
|
+
|
|
125
|
+
message = f"agmx run {run_id}"
|
|
126
|
+
done = _git(worktree, "commit", "-m", message, check=False)
|
|
127
|
+
if done.returncode != 0:
|
|
128
|
+
# Máy chưa khai user.name/user.email thì git từ chối commit. Danh tính
|
|
129
|
+
# thay thế chỉ dùng ở nước này, để commit của người dùng vẫn mang tên
|
|
130
|
+
# thật của họ khi họ có khai.
|
|
131
|
+
done = _git(
|
|
132
|
+
worktree,
|
|
133
|
+
"-c", "user.name=agmx runner",
|
|
134
|
+
"-c", "user.email=agmx@localhost",
|
|
135
|
+
"commit", "-m", message,
|
|
136
|
+
check=False,
|
|
137
|
+
)
|
|
138
|
+
if done.returncode != 0:
|
|
139
|
+
raise RuntimeError(f"commit hỏng: {(done.stderr or done.stdout).strip()}")
|
|
140
|
+
return _git(worktree, "rev-parse", "HEAD").stdout.strip()
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
# --------------------------------------------------------------------------- #
|
|
144
|
+
# Spawn + stream
|
|
145
|
+
# --------------------------------------------------------------------------- #
|
|
146
|
+
|
|
147
|
+
class _Batches:
|
|
148
|
+
"""Gom dòng thành mẻ theo luồng, đánh ``seq`` tăng dần từ 0."""
|
|
149
|
+
|
|
150
|
+
def __init__(self, sink: OutputSink | None, max_chars: int = MAX_BATCH_CHARS):
|
|
151
|
+
self.sink = sink
|
|
152
|
+
self.max_chars = max_chars
|
|
153
|
+
self.seq = 0
|
|
154
|
+
self.pending: dict[str, list[str]] = {}
|
|
155
|
+
|
|
156
|
+
def add(self, stream: str, line: str) -> None:
|
|
157
|
+
batch = self.pending.setdefault(stream, [])
|
|
158
|
+
batch.append(line)
|
|
159
|
+
if sum(map(len, batch)) >= self.max_chars:
|
|
160
|
+
self.flush(stream)
|
|
161
|
+
|
|
162
|
+
def flush(self, stream: str | None = None) -> None:
|
|
163
|
+
for name in [stream] if stream else list(self.pending):
|
|
164
|
+
content = "".join(self.pending.pop(name, ()))
|
|
165
|
+
# Một dòng đơn lẻ cũng có thể vượt trần (log JSON nén một dòng);
|
|
166
|
+
# cắt ở đây chứ không để server trả 413 rồi mất cả mẻ.
|
|
167
|
+
for start in range(0, len(content), self.max_chars):
|
|
168
|
+
self._send(name, content[start:start + self.max_chars])
|
|
169
|
+
|
|
170
|
+
def _send(self, stream: str, content: str) -> None:
|
|
171
|
+
if not content:
|
|
172
|
+
return
|
|
173
|
+
if self.sink is not None:
|
|
174
|
+
self.sink(self.seq, stream, content)
|
|
175
|
+
self.seq += 1
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _pump(pipe, stream: str, lines: queue.Queue) -> None:
|
|
179
|
+
"""Đọc một ống theo DÒNG và đẩy sang hàng đợi, không chờ tiến trình xong."""
|
|
180
|
+
try:
|
|
181
|
+
for line in pipe:
|
|
182
|
+
lines.put((stream, line))
|
|
183
|
+
finally:
|
|
184
|
+
pipe.close()
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _drain(lines: queue.Queue, batches: _Batches, timeout: float) -> None:
|
|
188
|
+
try:
|
|
189
|
+
batches.add(*lines.get(timeout=max(timeout, 0)))
|
|
190
|
+
except queue.Empty:
|
|
191
|
+
return
|
|
192
|
+
while True:
|
|
193
|
+
try:
|
|
194
|
+
batches.add(*lines.get_nowait())
|
|
195
|
+
except queue.Empty:
|
|
196
|
+
return
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _kill_group(proc: subprocess.Popen, group: int) -> None:
|
|
200
|
+
"""Giết CẢ NHÓM tiến trình, không chỉ tiến trình cha.
|
|
201
|
+
|
|
202
|
+
CLI hay đẻ con; giết mỗi cha thì con sống tiếp, và nó còn giữ nguyên đầu
|
|
203
|
+
ghi của ống stdout -- tiến trình này sẽ đọc một ống không bao giờ đóng.
|
|
204
|
+
SIGKILL gửi cho cả nhóm KỂ CẢ khi cha đã chết vì SIGTERM: thứ đáng sợ là
|
|
205
|
+
đứa con, và nó không chết theo cha.
|
|
206
|
+
"""
|
|
207
|
+
_signal_group(group, signal.SIGTERM)
|
|
208
|
+
try:
|
|
209
|
+
proc.wait(timeout=KILL_GRACE_SECONDS)
|
|
210
|
+
except subprocess.TimeoutExpired:
|
|
211
|
+
pass
|
|
212
|
+
_signal_group(group, signal.SIGKILL)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _signal_group(group: int, sig: int) -> None:
|
|
216
|
+
try:
|
|
217
|
+
os.killpg(group, sig)
|
|
218
|
+
except (ProcessLookupError, PermissionError):
|
|
219
|
+
pass
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _spawn(
|
|
223
|
+
command: str,
|
|
224
|
+
cwd: str,
|
|
225
|
+
timeout_seconds: float,
|
|
226
|
+
on_output: OutputSink | None,
|
|
227
|
+
) -> tuple[int | None, bool]:
|
|
228
|
+
proc = subprocess.Popen(
|
|
229
|
+
command,
|
|
230
|
+
shell=True,
|
|
231
|
+
cwd=cwd,
|
|
232
|
+
env=child_env(),
|
|
233
|
+
stdout=subprocess.PIPE,
|
|
234
|
+
stderr=subprocess.PIPE,
|
|
235
|
+
text=True,
|
|
236
|
+
errors="replace",
|
|
237
|
+
bufsize=1,
|
|
238
|
+
# Nhóm tiến trình riêng: điều kiện để giết được cả đàn con ở trên.
|
|
239
|
+
start_new_session=True,
|
|
240
|
+
)
|
|
241
|
+
group = proc.pid # setsid: pgid == pid, và đọc ngay bây giờ thì chắc chắn đúng
|
|
242
|
+
lines: queue.Queue = queue.Queue()
|
|
243
|
+
readers = [
|
|
244
|
+
threading.Thread(target=_pump, args=(pipe, stream, lines), daemon=True)
|
|
245
|
+
for pipe, stream in ((proc.stdout, "stdout"), (proc.stderr, "stderr"))
|
|
246
|
+
]
|
|
247
|
+
for reader in readers:
|
|
248
|
+
reader.start()
|
|
249
|
+
|
|
250
|
+
batches = _Batches(on_output)
|
|
251
|
+
deadline = time.monotonic() + timeout_seconds
|
|
252
|
+
timed_out = False
|
|
253
|
+
while True:
|
|
254
|
+
remaining = deadline - time.monotonic()
|
|
255
|
+
if remaining <= 0:
|
|
256
|
+
timed_out = True
|
|
257
|
+
_kill_group(proc, group)
|
|
258
|
+
break
|
|
259
|
+
_drain(lines, batches, min(remaining, FLUSH_SECONDS))
|
|
260
|
+
batches.flush()
|
|
261
|
+
if proc.poll() is not None and not any(r.is_alive() for r in readers):
|
|
262
|
+
break
|
|
263
|
+
|
|
264
|
+
for reader in readers:
|
|
265
|
+
reader.join(timeout=KILL_GRACE_SECONDS)
|
|
266
|
+
_drain(lines, batches, 0)
|
|
267
|
+
batches.flush()
|
|
268
|
+
try:
|
|
269
|
+
proc.wait(timeout=KILL_GRACE_SECONDS)
|
|
270
|
+
except subprocess.TimeoutExpired:
|
|
271
|
+
pass
|
|
272
|
+
return proc.returncode, timed_out
|
|
@@ -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.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
agmx/__init__.py,sha256=Jv8VreW4TpZg2Q6sig5j7Z51Kq24QbNOSY90UpNwoxU,265
|
|
2
|
+
agmx/__main__.py,sha256=jvdCOHNgtVNYRsKlHH-T-JPKEeNY9WOu6YxR936e7bE,14082
|
|
3
|
+
agmx/client.py,sha256=hTivg69RFxKfX6fGTs-rqMWqKDEJm19s8Z86a6cjcGg,2829
|
|
4
|
+
agmx/config.py,sha256=deR7TjuzpuuBlTblAl8TERGE7hPxzry-mZUv7rZcsFA,3385
|
|
5
|
+
agmx/executor.py,sha256=LcjCWJNerU73aVbJM_RMQKcLILQueFlyzhyHB_pAY-o,10008
|
|
6
|
+
agmx-0.1.0.dist-info/METADATA,sha256=b9mFZ5xtJ0khSpEBzmsfJ4AEKA6Q0mZPEwRaV6hL9So,2340
|
|
7
|
+
agmx-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
8
|
+
agmx-0.1.0.dist-info/entry_points.txt,sha256=vORGlZDzZ7lwDMiF9NcoAjdiVc5dyVstT0WKTLI_JpM,44
|
|
9
|
+
agmx-0.1.0.dist-info/top_level.txt,sha256=yPHqVMtQ7iM-JK4O-zaZNnvUbOaPstG8nYmkFcEfoN4,5
|
|
10
|
+
agmx-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
agmx
|