mulebox 1.1.1__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.
Files changed (230) hide show
  1. mulebox/__init__.py +5 -0
  2. mulebox/__main__.py +7 -0
  3. mulebox/cli.py +62 -0
  4. mulebox/client/__init__.py +1 -0
  5. mulebox/client/attention.py +156 -0
  6. mulebox/client/commands.py +304 -0
  7. mulebox/client/compose_check.py +159 -0
  8. mulebox/client/compose_yaml.py +78 -0
  9. mulebox/client/contract.py +284 -0
  10. mulebox/client/delta.py +91 -0
  11. mulebox/client/doctor.py +181 -0
  12. mulebox/client/doctor_commands.py +137 -0
  13. mulebox/client/events.py +95 -0
  14. mulebox/client/excludes.py +115 -0
  15. mulebox/client/feedback.py +165 -0
  16. mulebox/client/feedback_commands.py +172 -0
  17. mulebox/client/feedback_guide.py +56 -0
  18. mulebox/client/gitinfo.py +104 -0
  19. mulebox/client/init_ci.py +114 -0
  20. mulebox/client/init_commands.py +89 -0
  21. mulebox/client/init_detect.py +217 -0
  22. mulebox/client/init_files.py +45 -0
  23. mulebox/client/init_langs.py +124 -0
  24. mulebox/client/job_body.py +74 -0
  25. mulebox/client/job_commands.py +103 -0
  26. mulebox/client/job_controls.py +70 -0
  27. mulebox/client/machine_commands.py +125 -0
  28. mulebox/client/project_commands.py +76 -0
  29. mulebox/client/queue_view.py +133 -0
  30. mulebox/client/remote.py +180 -0
  31. mulebox/client/report.py +191 -0
  32. mulebox/client/setup.py +232 -0
  33. mulebox/client/setup_commands.py +53 -0
  34. mulebox/client/setup_queued.py +54 -0
  35. mulebox/client/setup_worker.py +131 -0
  36. mulebox/client/skill/SKILL.md +137 -0
  37. mulebox/client/skill_commands.py +87 -0
  38. mulebox/client/skill_install.py +183 -0
  39. mulebox/client/snapshot.py +153 -0
  40. mulebox/client/sshio.py +122 -0
  41. mulebox/client/stats_commands.py +98 -0
  42. mulebox/client/submit.py +303 -0
  43. mulebox/client/summary.py +180 -0
  44. mulebox/client/take_back.py +94 -0
  45. mulebox/client/tree.py +153 -0
  46. mulebox/client/tui.py +113 -0
  47. mulebox/client/tunnel.py +87 -0
  48. mulebox/client/webbuild.py +72 -0
  49. mulebox/client/wheel.py +68 -0
  50. mulebox/common/__init__.py +1 -0
  51. mulebox/common/anonymize.py +97 -0
  52. mulebox/common/compose.py +106 -0
  53. mulebox/common/config.py +300 -0
  54. mulebox/common/config_jobs.py +139 -0
  55. mulebox/common/config_project.py +67 -0
  56. mulebox/common/config_reader.py +160 -0
  57. mulebox/common/dashboard_address.py +37 -0
  58. mulebox/common/disk.py +65 -0
  59. mulebox/common/download.py +90 -0
  60. mulebox/common/env_rules.py +88 -0
  61. mulebox/common/errors.py +9 -0
  62. mulebox/common/ids.py +102 -0
  63. mulebox/common/log.py +96 -0
  64. mulebox/common/manifest.py +63 -0
  65. mulebox/common/mounts.py +113 -0
  66. mulebox/common/netdev.py +58 -0
  67. mulebox/common/network_errors.py +19 -0
  68. mulebox/common/package_cache.py +15 -0
  69. mulebox/common/proc.py +132 -0
  70. mulebox/common/protocol.py +317 -0
  71. mulebox/common/redact.py +209 -0
  72. mulebox/common/roots.py +132 -0
  73. mulebox/common/safepath.py +299 -0
  74. mulebox/common/ssh.py +285 -0
  75. mulebox/common/status.py +242 -0
  76. mulebox/common/take_paths.py +45 -0
  77. mulebox/common/upgrade.py +30 -0
  78. mulebox/common/versions.py +21 -0
  79. mulebox/coordinator/__init__.py +1 -0
  80. mulebox/coordinator/api.py +282 -0
  81. mulebox/coordinator/attendance.py +117 -0
  82. mulebox/coordinator/clients.py +50 -0
  83. mulebox/coordinator/commands.py +157 -0
  84. mulebox/coordinator/context.py +30 -0
  85. mulebox/coordinator/daemon.py +84 -0
  86. mulebox/coordinator/delta.py +110 -0
  87. mulebox/coordinator/errors.py +37 -0
  88. mulebox/coordinator/holds.py +65 -0
  89. mulebox/coordinator/job_control.py +123 -0
  90. mulebox/coordinator/leases.py +142 -0
  91. mulebox/coordinator/lineio.py +32 -0
  92. mulebox/coordinator/machine_api.py +51 -0
  93. mulebox/coordinator/migrate.py +131 -0
  94. mulebox/coordinator/needs.py +96 -0
  95. mulebox/coordinator/node.py +19 -0
  96. mulebox/coordinator/project_pause.py +54 -0
  97. mulebox/coordinator/projects.py +30 -0
  98. mulebox/coordinator/queue.py +146 -0
  99. mulebox/coordinator/reach.py +40 -0
  100. mulebox/coordinator/recovery.py +71 -0
  101. mulebox/coordinator/reservations.py +151 -0
  102. mulebox/coordinator/retention.py +56 -0
  103. mulebox/coordinator/reuse.py +61 -0
  104. mulebox/coordinator/rpc.py +131 -0
  105. mulebox/coordinator/runner_api.py +71 -0
  106. mulebox/coordinator/staging.py +291 -0
  107. mulebox/coordinator/stats.py +100 -0
  108. mulebox/coordinator/stats_api.py +44 -0
  109. mulebox/coordinator/steps.py +72 -0
  110. mulebox/coordinator/store.py +301 -0
  111. mulebox/coordinator/taken.py +118 -0
  112. mulebox/coordinator/upgrades.py +169 -0
  113. mulebox/coordinator/uploads.py +56 -0
  114. mulebox/coordinator/usual.py +101 -0
  115. mulebox/coordinator/validate.py +187 -0
  116. mulebox/coordinator/views.py +140 -0
  117. mulebox/coordinator/watch.py +151 -0
  118. mulebox/coordinator/worker_api.py +122 -0
  119. mulebox/coordinator/worker_keys.py +109 -0
  120. mulebox/coordinator/workers.py +110 -0
  121. mulebox/dashboard/__init__.py +1 -0
  122. mulebox/dashboard/app/assets/index-DWcl5Bgi.js +51 -0
  123. mulebox/dashboard/app/assets/index-vRhAVhTS.css +2 -0
  124. mulebox/dashboard/app/fonts/geist-latin-wght-normal.woff2 +0 -0
  125. mulebox/dashboard/app/fonts/geist-mono-latin-wght-normal.woff2 +0 -0
  126. mulebox/dashboard/app/index.html +16 -0
  127. mulebox/dashboard/app/login.css +2 -0
  128. mulebox/dashboard/app/login.html +25 -0
  129. mulebox/dashboard/app/theme.js +19 -0
  130. mulebox/dashboard/cancel.py +99 -0
  131. mulebox/dashboard/canceller.py +61 -0
  132. mulebox/dashboard/cert.py +86 -0
  133. mulebox/dashboard/commands.py +125 -0
  134. mulebox/dashboard/controls.py +78 -0
  135. mulebox/dashboard/data.py +157 -0
  136. mulebox/dashboard/docker.py +89 -0
  137. mulebox/dashboard/events.py +142 -0
  138. mulebox/dashboard/firewall.py +68 -0
  139. mulebox/dashboard/history_query.py +74 -0
  140. mulebox/dashboard/hub.py +154 -0
  141. mulebox/dashboard/keys.py +17 -0
  142. mulebox/dashboard/lan_commands.py +106 -0
  143. mulebox/dashboard/lanfiles.py +45 -0
  144. mulebox/dashboard/listeners.py +40 -0
  145. mulebox/dashboard/login.py +153 -0
  146. mulebox/dashboard/machine_history.py +67 -0
  147. mulebox/dashboard/machines.py +94 -0
  148. mulebox/dashboard/metrics.py +92 -0
  149. mulebox/dashboard/metrics_api.py +36 -0
  150. mulebox/dashboard/notices.py +51 -0
  151. mulebox/dashboard/pages.py +64 -0
  152. mulebox/dashboard/password.py +74 -0
  153. mulebox/dashboard/server.py +178 -0
  154. mulebox/dashboard/session.py +107 -0
  155. mulebox/dashboard/streams.py +143 -0
  156. mulebox/machine/__init__.py +0 -0
  157. mulebox/machine/client.toml.example +16 -0
  158. mulebox/machine/install.sh +322 -0
  159. mulebox/machine/node.toml.example +48 -0
  160. mulebox/machine/systemd/mulebox-coordinator.service +14 -0
  161. mulebox/machine/systemd/mulebox-dashboard.service +19 -0
  162. mulebox/machine/systemd/mulebox-runner@.service +27 -0
  163. mulebox/machine/systemd/mulebox-worker.service +15 -0
  164. mulebox/runners/__init__.py +1 -0
  165. mulebox/runners/cancel_run.py +41 -0
  166. mulebox/runners/commands.py +10 -0
  167. mulebox/runners/commands_hooks.py +42 -0
  168. mulebox/runners/commands_manage.py +202 -0
  169. mulebox/runners/diary.py +110 -0
  170. mulebox/runners/github.py +111 -0
  171. mulebox/runners/hooks.py +213 -0
  172. mulebox/runners/layout.py +120 -0
  173. mulebox/runners/live_log.py +182 -0
  174. mulebox/runners/manage.py +225 -0
  175. mulebox/runners/names.py +70 -0
  176. mulebox/runners/release.py +130 -0
  177. mulebox/runners/reservation_file.py +68 -0
  178. mulebox/runners/runner_caches.py +56 -0
  179. mulebox/runners/runner_controls.py +81 -0
  180. mulebox/runners/runner_meter.py +113 -0
  181. mulebox/runners/supervise.py +166 -0
  182. mulebox/version.py +40 -0
  183. mulebox/worker/__init__.py +1 -0
  184. mulebox/worker/activity.py +81 -0
  185. mulebox/worker/advert.py +36 -0
  186. mulebox/worker/agent.py +284 -0
  187. mulebox/worker/agent_end.py +93 -0
  188. mulebox/worker/agent_skip.py +55 -0
  189. mulebox/worker/archive.py +94 -0
  190. mulebox/worker/background.py +68 -0
  191. mulebox/worker/caches.py +124 -0
  192. mulebox/worker/cgroups.py +103 -0
  193. mulebox/worker/cleaner.py +101 -0
  194. mulebox/worker/commands.py +159 -0
  195. mulebox/worker/compose_file.py +179 -0
  196. mulebox/worker/compose_job.py +130 -0
  197. mulebox/worker/compose_policy.py +286 -0
  198. mulebox/worker/controls.py +115 -0
  199. mulebox/worker/docker.py +65 -0
  200. mulebox/worker/docker_cleanup.py +101 -0
  201. mulebox/worker/executor.py +179 -0
  202. mulebox/worker/install_step.py +52 -0
  203. mulebox/worker/job.py +57 -0
  204. mulebox/worker/job_meter.py +109 -0
  205. mulebox/worker/jobspec.py +121 -0
  206. mulebox/worker/link.py +110 -0
  207. mulebox/worker/logtail.py +143 -0
  208. mulebox/worker/offers.py +65 -0
  209. mulebox/worker/paths.py +74 -0
  210. mulebox/worker/ports.py +63 -0
  211. mulebox/worker/project_commands.py +146 -0
  212. mulebox/worker/project_disk.py +67 -0
  213. mulebox/worker/project_home.py +238 -0
  214. mulebox/worker/pull_failure.py +38 -0
  215. mulebox/worker/pullnoise.py +118 -0
  216. mulebox/worker/reclaim.py +82 -0
  217. mulebox/worker/runspec.py +127 -0
  218. mulebox/worker/serve.py +45 -0
  219. mulebox/worker/steps.py +130 -0
  220. mulebox/worker/systemd.py +180 -0
  221. mulebox/worker/take_back.py +79 -0
  222. mulebox/worker/toolchains.py +226 -0
  223. mulebox/worker/upgrade_wait.py +154 -0
  224. mulebox/worker/workspace.py +72 -0
  225. mulebox-1.1.1.dist-info/METADATA +443 -0
  226. mulebox-1.1.1.dist-info/RECORD +230 -0
  227. mulebox-1.1.1.dist-info/WHEEL +5 -0
  228. mulebox-1.1.1.dist-info/entry_points.txt +2 -0
  229. mulebox-1.1.1.dist-info/licenses/LICENSE +21 -0
  230. mulebox-1.1.1.dist-info/top_level.txt +1 -0
mulebox/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ """mulebox: send AI coding agents' heavy work to Linux machines on your network."""
2
+
3
+ from mulebox.version import get_version
4
+
5
+ __all__ = ["get_version"]
mulebox/__main__.py ADDED
@@ -0,0 +1,7 @@
1
+ """`python -m mulebox`."""
2
+
3
+ import sys
4
+
5
+ from mulebox.cli import main
6
+
7
+ sys.exit(main())
mulebox/cli.py ADDED
@@ -0,0 +1,62 @@
1
+ """Single entry point: `mulebox <command>`.
2
+
3
+ Commands are registered here as they are implemented (see docs/phases.md). This module only
4
+ parses arguments and dispatches; the work happens in the client, coordinator and worker
5
+ packages.
6
+ """
7
+
8
+ import argparse
9
+ import sys
10
+ from collections.abc import Sequence
11
+
12
+ from mulebox.client import commands as client_commands
13
+ from mulebox.client import (
14
+ doctor_commands,
15
+ feedback_commands,
16
+ init_commands,
17
+ job_commands,
18
+ job_controls,
19
+ machine_commands,
20
+ project_commands,
21
+ setup_commands,
22
+ skill_commands,
23
+ stats_commands,
24
+ )
25
+ from mulebox.coordinator import commands as coordinator_commands
26
+ from mulebox.dashboard import commands as dashboard_commands
27
+ from mulebox.runners import commands as runner_commands
28
+ from mulebox.version import get_version
29
+ from mulebox.worker import commands as worker_commands
30
+
31
+
32
+ def build_parser() -> argparse.ArgumentParser:
33
+ parser = argparse.ArgumentParser(
34
+ prog="mulebox",
35
+ description="Run your agents' builds and tests on build machines through one queue.",
36
+ )
37
+ parser.add_argument("--version", action="version", version=f"mulebox {get_version()}")
38
+ sub = parser.add_subparsers(dest="command", metavar="<command>")
39
+ sub.add_parser("version", help="print the mulebox version")
40
+ for package in (client_commands, init_commands, job_commands, job_controls, machine_commands,
41
+ project_commands, stats_commands,
42
+ setup_commands, doctor_commands, skill_commands, feedback_commands, coordinator_commands, worker_commands, runner_commands,
43
+ dashboard_commands):
44
+ package.register(sub)
45
+ return parser
46
+
47
+
48
+ def main(argv: Sequence[str] | None = None) -> int:
49
+ parser = build_parser()
50
+ args = parser.parse_args(argv)
51
+ if args.command == "version":
52
+ print(get_version())
53
+ return 0
54
+ handler = getattr(args, "func", None)
55
+ if handler is not None:
56
+ return handler(args)
57
+ parser.print_help()
58
+ return 0
59
+
60
+
61
+ if __name__ == "__main__":
62
+ sys.exit(main())
@@ -0,0 +1 @@
1
+ """mulebox client (docs/interfaces-m1.md)."""
@@ -0,0 +1,156 @@
1
+ """What a person or agent has to hear about a job: questions, and results from while away.
2
+
3
+ Pure text from `status_ok`, `queue_ok` and `submit_ok` bodies, except `warn_here`, which asks
4
+ the coordinator on behalf of a command that does not otherwise see the queue. All of it goes to
5
+ stderr, so `--json` on stdout stays clean. A coordinator too old for any of this sends none of
6
+ these keys and nothing is printed.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from pathlib import Path
12
+ from typing import Any, TextIO
13
+
14
+ from mulebox.client import report
15
+ from mulebox.client.remote import Remote, request
16
+ from mulebox.client.report import format_duration
17
+ from mulebox.common.config import (
18
+ ConfigError,
19
+ find_project_config,
20
+ load_client_config,
21
+ load_project_config,
22
+ )
23
+ from mulebox.common.errors import MuleboxError
24
+ from mulebox.common.network_errors import LOST
25
+ from mulebox.common.protocol import MessageType
26
+ from mulebox.common import disk, status
27
+ from mulebox.common.status import FETCHING, number
28
+
29
+ WARNING = "warning: "
30
+ QUEUE_NOTHING_FINISHED = 0
31
+
32
+
33
+ def _name(job: dict[str, Any]) -> str:
34
+ known = [str(job[key]) for key in ("project", "job") if job.get(key)]
35
+ return f" ({' '.join(known)})" if known else ""
36
+
37
+
38
+ def stops_in(view: dict[str, Any]) -> str:
39
+ """When a job stops if it stays stuck. A working job does not: this time keeps moving."""
40
+ return f"stops in {format_duration(number(view.get('left_s')))} if it stays stuck"
41
+
42
+
43
+ def question_line(job_id: str, view: dict[str, Any], job: dict[str, Any] | None = None) -> str:
44
+ """The reminder of a question not answered yet; it names both answers."""
45
+ return (f"{WARNING}job {job_id}{_name(job or {})} has run a long time: is everything OK? "
46
+ f"While it works it goes on; it {stops_in(view)}. Yes: mulebox ok {job_id}. "
47
+ f"No: mulebox cancel {job_id}")
48
+
49
+
50
+ def result_words(job: dict[str, Any]) -> str:
51
+ code = job.get("exit_code")
52
+ words = str(status.outcome_of(job)) + (f", exit {code}" if code is not None else "")
53
+ commit = job.get("base_commit")
54
+ return f"{words}, on commit {str(commit)[:10]}" if commit else words
55
+
56
+
57
+ def away_line(job: dict[str, Any]) -> str:
58
+ """A result nobody received: the agent need not run the job again."""
59
+ job_id = job.get("job_id")
60
+ return (f"job {job_id}{_name(job)} finished while you were away: {result_words(job)}. "
61
+ f"No need to retest; its log: mulebox logs {job_id}")
62
+
63
+
64
+ def already_line(job: dict[str, Any]) -> str:
65
+ """`check` found the same job on the same files."""
66
+ job_id = job.get("job_id")
67
+ if job.get("state") != "finished":
68
+ return f"this exact job is already {job.get('state')} as {job_id}: following that one"
69
+ return (f"already tested: job {job_id}{_name(job)} ran on exactly these files: "
70
+ f"{result_words(job)}. Not run again; to force a run: --again. "
71
+ f"Its log: mulebox logs {job_id}")
72
+
73
+
74
+ def activity_text(activity: dict[str, Any]) -> str:
75
+ """The readings there are; the worker leaves out what it could not measure."""
76
+ parts = []
77
+ if activity.get("stage"):
78
+ parts.append(f"stage {activity['stage']}")
79
+ quiet = number(activity.get("output_idle_s"))
80
+ if quiet is not None:
81
+ parts.append(f"last output {format_duration(quiet)} ago")
82
+ job_cpu = number(activity.get("job_cpu_pct"))
83
+ if job_cpu is not None:
84
+ parts.append(f"job CPU {job_cpu:.0f}% of a core")
85
+ cpu, net = number(activity.get("cpu_pct")), number(activity.get("net_kib_s"))
86
+ if cpu is not None:
87
+ parts.append(f"machine CPU {cpu:.0f}% of a core")
88
+ if net is not None:
89
+ parts.append(f"machine network {net:.1f} KiB/s")
90
+ if activity.get("last_line"):
91
+ parts.append(f"last line: {activity['last_line']}")
92
+ return ", ".join(parts) or "no readings from the machine"
93
+
94
+
95
+ def check_in_line(job_id: str, check_in: dict[str, Any]) -> str:
96
+ took = format_duration(number(check_in.get("elapsed_s")))
97
+ return (f"{WARNING}check-in: job {job_id} has run {took}; {activity_text(check_in)}. "
98
+ f"Is everything OK? Yes: mulebox ok {job_id}. No: mulebox cancel {job_id}")
99
+
100
+
101
+ def notice(job_id: str, body: dict[str, Any]) -> tuple[Any, str] | None:
102
+ """(a key that is the same on a reconnect, the line) for a body that carries a notice."""
103
+ check_in = body.get("check_in")
104
+ if isinstance(check_in, dict):
105
+ return ("check_in", check_in.get("number")), check_in_line(job_id, check_in)
106
+ return None
107
+
108
+
109
+ def network_trouble(activity: dict[str, Any]) -> bool:
110
+ """Whether a job that is fetching things printed a lost connection last. Not in `command`:
111
+ a project's own tests can print that on purpose."""
112
+ line = activity.get("last_line")
113
+ return activity.get("stage") in FETCHING and bool(line and LOST.search(str(line)))
114
+
115
+
116
+ def status_lines(body: dict[str, Any]) -> list[str]:
117
+ """`mulebox status`: what the job is doing and what it is waiting to hear."""
118
+ job_id, lines = str(body.get("job_id")), []
119
+ activity, view = body.get("activity"), body.get("question")
120
+ if isinstance(activity, dict):
121
+ took = format_duration(number(activity.get("elapsed_s")))
122
+ lines.append(f" activity: running {took}, {activity_text(activity)}")
123
+ if network_trouble(activity):
124
+ lines.append(" warning: its last line reads as a lost connection; downloads may be "
125
+ "retrying, and this is the machine's network, not the project")
126
+ if isinstance(view, dict):
127
+ lines.append(f" question: is everything OK? It {stops_in(view)}. "
128
+ f"Yes: mulebox ok {job_id}. No: mulebox cancel {job_id}")
129
+ return lines
130
+
131
+
132
+ def tell(body: dict[str, Any], err: TextIO) -> None:
133
+ """Questions waiting for an answer and results from while away, from a reply's body."""
134
+ for job in _dicts(body.get("questions")):
135
+ report.status(err, question_line(str(job.get("job_id")), job.get("question") or {}, job))
136
+ for job in _dicts(body.get("finished_away")):
137
+ report.status(err, away_line(job))
138
+ for low in _dicts(body.get("low_disks")):
139
+ report.status(err, WARNING + disk.warning(str(low.get("worker")), int(low.get("free") or 0)))
140
+
141
+
142
+ def _dicts(items: Any) -> list[dict[str, Any]]:
143
+ return [item for item in items if isinstance(item, dict)] if isinstance(items, list) else []
144
+
145
+
146
+ def tell_here(err: TextIO, folder: Path | None = None) -> None:
147
+ """Tell this project's news. Only a remark on another command's way out: nothing that goes
148
+ wrong here may change what that command returned."""
149
+ try:
150
+ project = load_project_config(find_project_config(folder or Path.cwd())).project
151
+ remote = Remote(load_client_config())
152
+ reply = remote.call(request(MessageType.QUEUE, limit=QUEUE_NOTHING_FINISHED,
153
+ news_for=project))
154
+ except (ConfigError, MuleboxError, OSError):
155
+ return
156
+ tell(reply.body, err)
@@ -0,0 +1,304 @@
1
+ """CLI subcommands of the client package (docs/interfaces-m1.md, CLI).
2
+
3
+ Exit codes for client failures: 2 usage/config error, 3 build machine unreachable,
4
+ 4 protocol/remote error, 5 job could not start, 76 stopped as stuck with no answer from the agent,
5
+ 130 Ctrl-C. Job failures return the job's own exit code.
6
+ A build machine that cannot be reached is reported as such; mulebox never runs the job
7
+ anywhere else instead.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import argparse
13
+ import json
14
+ import sys
15
+ from collections.abc import Callable
16
+ from pathlib import Path
17
+ from typing import Any
18
+
19
+ from mulebox.client import (attention, contract, queue_view, report, skill_install, summary,
20
+ take_back, tui)
21
+ from mulebox.client.remote import CoordinatorError, Remote, request
22
+ from mulebox.client.snapshot import SnapshotError
23
+ from mulebox.client.submit import Streams, Watcher, WatchLost, run_job
24
+ from mulebox.common.config import (
25
+ ConfigError,
26
+ ProjectConfig,
27
+ ProjectNotFound,
28
+ find_project_config,
29
+ load_client_config,
30
+ load_project_config,
31
+ )
32
+ from mulebox.common.config_jobs import BARE
33
+ from mulebox.common.errors import MuleboxError
34
+ from mulebox.common.protocol import MessageType, ProtocolError
35
+ from mulebox.common.redact import Redactor
36
+ from mulebox.common.ssh import RemoteTimeout, SSHError, SSHUnavailable
37
+ from mulebox.version import get_version
38
+
39
+ QUEUE_LIMIT = 20
40
+ Handler = Callable[[argparse.Namespace], int]
41
+
42
+
43
+ class UsageError(MuleboxError):
44
+ """The command line asks for something that does not exist."""
45
+
46
+
47
+ def register(sub: argparse._SubParsersAction) -> None:
48
+ """Add this package's subcommands. Each sets func=handler(args) -> int."""
49
+ check = sub.add_parser("check", help="run a job from .mulebox.yml on a build machine")
50
+ check.add_argument("job", nargs="?", default="check", help="job name (default: check)")
51
+ check.add_argument("--detach", action="store_true",
52
+ help="print the job id once it is queued and leave it running")
53
+ check.add_argument("--again", action="store_true",
54
+ help="run even if these exact files were already tested")
55
+ check.set_defaults(func=guarded(_check))
56
+
57
+ warm = sub.add_parser("warm", help="prepare a job ahead of time: toolchains, images, caches, "
58
+ "install; no command")
59
+ warm.add_argument("job", nargs="?", default="check", help="job name (default: check)")
60
+ warm.add_argument("--detach", action="store_true",
61
+ help="print the job id once it is queued and leave it running")
62
+ warm.set_defaults(func=guarded(_warm, notices=True))
63
+
64
+ run = sub.add_parser("run", help="run an ad-hoc command on a build machine")
65
+ run.add_argument("--detach", action="store_true",
66
+ help="print the job id once it is queued and leave it running")
67
+ run.add_argument("--in", dest="service", metavar="SERVICE",
68
+ help="run it inside this compose service")
69
+ run.add_argument("--bare", action="store_true",
70
+ help="the command alone: no containers, install or setup")
71
+ run.add_argument("--take", action="append", default=[], metavar="PATH",
72
+ help="once the command passed, copy this file back into the project "
73
+ "(repeat for more), e.g. --take api/composer.lock")
74
+ run.add_argument("argv", nargs=argparse.REMAINDER, help="-- command and arguments")
75
+ run.set_defaults(func=guarded(_run))
76
+
77
+ queue = sub.add_parser("queue", help="list running, waiting and recent jobs")
78
+ queue.add_argument("--watch", action="store_true", help="full-screen live view")
79
+ queue.set_defaults(func=guarded(_queue))
80
+
81
+ known = sub.add_parser("projects", help="list every project that has run here")
82
+ known.add_argument("--json", action="store_true", help="stable keys for tools")
83
+ known.set_defaults(func=guarded(_projects, notices=True))
84
+
85
+ logs = sub.add_parser("logs", help="show a job's log")
86
+ logs.add_argument("job_id")
87
+ logs.add_argument("-f", "--follow", action="store_true", help="follow until it finishes")
88
+ logs.add_argument("--summary", action="store_true",
89
+ help="only why it failed: stage, exit code, last relevant lines")
90
+ logs.set_defaults(func=guarded(_logs, notices=True))
91
+
92
+ cancel = sub.add_parser("cancel", help="cancel a job")
93
+ cancel.add_argument("job_id")
94
+ cancel.set_defaults(func=guarded(_cancel, notices=True))
95
+
96
+ terms = sub.add_parser("contract", help="print how to work in this project through "
97
+ "mulebox, for agents")
98
+ terms.add_argument("--json", action="store_true", help="stable keys for tools")
99
+ terms.set_defaults(func=guarded(_contract))
100
+
101
+
102
+ def guarded(handler: Handler, notices: bool = False) -> Handler:
103
+ """Turn expected errors into one clear stderr line and the contract's exit code. `notices`:
104
+ a command that reached the machine also tells this project's questions and results."""
105
+
106
+ def run(args: argparse.Namespace) -> int:
107
+ try:
108
+ code = handler(args)
109
+ if notices:
110
+ attention.tell_here(sys.stderr)
111
+ return code
112
+ except KeyboardInterrupt:
113
+ return report.EXIT_CANCELLED
114
+ except (ConfigError, SnapshotError, UsageError) as exc:
115
+ return _fail(str(exc), report.EXIT_USAGE)
116
+ except WatchLost as exc:
117
+ return _fail(str(exc), report.EXIT_UNREACHABLE)
118
+ except (SSHUnavailable, RemoteTimeout) as exc:
119
+ return _fail(
120
+ f"build machine unreachable: {exc} The job was not run; "
121
+ "fix the connection to the build machine and retry.",
122
+ report.EXIT_UNREACHABLE,
123
+ )
124
+ except (CoordinatorError, ProtocolError, SSHError) as exc:
125
+ return _fail(str(exc), report.EXIT_REMOTE)
126
+
127
+ return run
128
+
129
+
130
+ def _fail(message: str, code: int) -> int:
131
+ report.status(sys.stderr, f"error: {Redactor().redact(message)}")
132
+ return code
133
+
134
+
135
+ def project_here() -> tuple[ProjectConfig, Path]:
136
+ path = find_project_config(Path.cwd())
137
+ return load_project_config(path), path.parent
138
+
139
+
140
+ def connect() -> Remote:
141
+ return Remote(load_client_config())
142
+
143
+
144
+ def _contract(args: argparse.Namespace) -> int:
145
+ try:
146
+ config = project_here()[0]
147
+ except ProjectNotFound:
148
+ config = None
149
+ terms = contract.build(config, get_version())
150
+ if stale := skill_install.stale_here():
151
+ report.status(sys.stderr, stale)
152
+ sys.stdout.write(json.dumps(terms, indent=2) + "\n" if args.json
153
+ else contract.render(terms))
154
+ return 0
155
+
156
+
157
+ def _named_job(name: str) -> tuple[ProjectConfig, Path]:
158
+ project, root = project_here()
159
+ if name not in project.jobs:
160
+ names = ", ".join(sorted(project.jobs))
161
+ raise UsageError(f"no job {name!r} in .mulebox.yml (jobs: {names})")
162
+ return project, root
163
+
164
+
165
+ def _check(args: argparse.Namespace) -> int:
166
+ project, root = _named_job(args.job)
167
+ return run_job(project, args.job, root, connect(), detach=args.detach,
168
+ again=args.again)
169
+
170
+
171
+ def _warm(args: argparse.Namespace) -> int:
172
+ project, root = _named_job(args.job)
173
+ return run_job(project, args.job, root, connect(), detach=args.detach, warm=True)
174
+
175
+
176
+ def _run(args: argparse.Namespace) -> int:
177
+ argv = list(args.argv)
178
+ if argv and argv[0] == "--":
179
+ argv = argv[1:]
180
+ if not argv:
181
+ raise UsageError("nothing to run; use `mulebox run -- <command> [args...]`")
182
+ project, root = project_here()
183
+ if args.service:
184
+ if args.bare:
185
+ raise UsageError("--in runs in a service, which --bare does not start")
186
+ if not project.compose:
187
+ raise UsageError("--in needs `compose:` in .mulebox.yml")
188
+ argv = ["docker", "compose", "exec", "-T", args.service, *argv]
189
+ if args.take and args.detach:
190
+ raise UsageError("--take brings files back at the end, so it waits; drop --detach")
191
+ try:
192
+ take = take_back.check(args.take, project.exclude)
193
+ except ValueError as exc:
194
+ raise UsageError(str(exc)) from None
195
+ return run_job(project, argv, root, connect(), detach=args.detach,
196
+ options=BARE if args.bare else None, take=take)
197
+
198
+
199
+ def _queue(args: argparse.Namespace) -> int:
200
+ remote = connect()
201
+ latest: dict[str, Any] = {}
202
+
203
+ def fetch() -> list[dict]:
204
+ reply = remote.call(request(MessageType.QUEUE, limit=QUEUE_LIMIT))
205
+ latest.update(reply.body)
206
+ jobs = reply.body.get("jobs")
207
+ if not isinstance(jobs, list):
208
+ raise CoordinatorError("the queue reply has no job list")
209
+ return [j for j in jobs if isinstance(j, dict)]
210
+
211
+ if args.watch:
212
+ return tui.watch(fetch, remote.target)
213
+ tui.show_once(fetch, sys.stdout)
214
+ report.dashboard_hint(sys.stderr, latest)
215
+ return 0
216
+
217
+
218
+ def _with_folders(remote: Remote, rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
219
+ """`rows` with each project's folder and size; the job history alone from an older machine."""
220
+ try:
221
+ folders = json.loads(remote.project_home("list", "--json").stdout)
222
+ except (MuleboxError, ValueError):
223
+ return rows
224
+ known = {row["project"]: row for row in folders}
225
+ return [{**row, **{k: v for k, v in known.get(row["project"], {}).items() if k != "project"}}
226
+ for row in rows]
227
+
228
+
229
+ def _projects(args: argparse.Namespace) -> int:
230
+ remote = connect()
231
+ rows = remote.call(request(MessageType.PROJECTS)).body.get("projects")
232
+ if not isinstance(rows, list):
233
+ raise CoordinatorError("the projects reply has no project list")
234
+ rows = _with_folders(remote, rows)
235
+ if args.json:
236
+ sys.stdout.write(json.dumps(rows, indent=2) + "\n")
237
+ else:
238
+ sys.stdout.write("\n".join(queue_view.render_projects(rows)) + "\n")
239
+ return 0
240
+
241
+
242
+ def _logs(args: argparse.Namespace) -> int:
243
+ remote = connect()
244
+ watcher = Watcher(remote, Streams())
245
+ watcher.job_id = args.job_id
246
+ if args.summary:
247
+ return _log_summary(remote, args.job_id)
248
+ if args.follow:
249
+ result = watcher.attach()
250
+ note_expired_log(watcher)
251
+ watcher.finish(result)
252
+ return report.exit_code_for(result)
253
+ _read_log(remote, args.job_id, 0, watcher.write_log)
254
+ note_expired_log(watcher)
255
+ return 0
256
+
257
+
258
+ def note_expired_log(watcher: Watcher) -> None:
259
+ """A log that printed nothing may be gone rather than empty. Only a remark: losing the
260
+ connection here must not cost `wait` the job's exit code."""
261
+ if watcher.offset:
262
+ return
263
+ try:
264
+ body = watcher.remote.call(request(MessageType.STATUS, job_id=watcher.job_id)).body
265
+ except (MuleboxError, OSError):
266
+ return
267
+ if report.log_expired(body):
268
+ report.status(watcher.io.err, report.expired_line(watcher.job_id))
269
+
270
+
271
+ def _read_log(remote: Remote, job_id: str, offset: int, sink: Callable[[dict], None]) -> None:
272
+ while True:
273
+ reply = remote.call(request(MessageType.LOGS, job_id=job_id, offset=offset))
274
+ sink(reply.body)
275
+ next_offset = reply.body.get("next_offset", offset)
276
+ if reply.body.get("eof", True) or next_offset == offset:
277
+ return
278
+ offset = next_offset
279
+
280
+
281
+ def _log_summary(remote: Remote, job_id: str) -> int:
282
+ body = remote.call(request(MessageType.STATUS, job_id=job_id)).body
283
+ state = body.get("state")
284
+ if state != "finished":
285
+ print(f"job {job_id} is {state}; a summary needs it to end")
286
+ elif not summary.wanted(body):
287
+ print(report.outcome_line(job_id, body))
288
+ else:
289
+ tail = summary.LogTail()
290
+ size = body.get("log_size")
291
+ start = max(0, size - summary.TAIL_BYTES) if isinstance(size, int) else 0
292
+ tail.cut = start > 0
293
+ _read_log(remote, job_id, start, lambda chunk: tail.feed(chunk.get("data") or ""))
294
+ if not tail.text and report.log_expired(body):
295
+ report.status(sys.stderr, report.expired_line(job_id))
296
+ print("\n".join(summary.render(job_id, body, summary.relevant_lines(tail))))
297
+ return 0
298
+
299
+
300
+ def _cancel(args: argparse.Namespace) -> int:
301
+ reply = connect().call(request(MessageType.CANCEL, job_id=args.job_id))
302
+ state = reply.body.get("state", "unknown")
303
+ report.status(sys.stderr, f"cancel requested for job {args.job_id} (state {state})")
304
+ return 0