forgeo-cli 0.3.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.
- forgeo/__init__.py +10 -0
- forgeo/__main__.py +6 -0
- forgeo/agent.py +332 -0
- forgeo/backlog.py +206 -0
- forgeo/central.py +620 -0
- forgeo/cli.py +759 -0
- forgeo/config.py +36 -0
- forgeo/daemon.py +209 -0
- forgeo/forgeo.py +446 -0
- forgeo/git.py +100 -0
- forgeo/instances.py +187 -0
- forgeo/io.py +30 -0
- forgeo/models.py +252 -0
- forgeo/notify.py +77 -0
- forgeo/runs.py +73 -0
- forgeo/setup.py +185 -0
- forgeo/web/central/central.css +380 -0
- forgeo/web/central/central.js +780 -0
- forgeo/web/central/index.html +41 -0
- forgeo/web/central/instance.html +189 -0
- forgeo/web/style.css +656 -0
- forgeo/web_common.py +92 -0
- forgeo_cli-0.3.0.dist-info/METADATA +146 -0
- forgeo_cli-0.3.0.dist-info/RECORD +27 -0
- forgeo_cli-0.3.0.dist-info/WHEEL +4 -0
- forgeo_cli-0.3.0.dist-info/entry_points.txt +2 -0
- forgeo_cli-0.3.0.dist-info/licenses/LICENSE +21 -0
forgeo/cli.py
ADDED
|
@@ -0,0 +1,759 @@
|
|
|
1
|
+
"""Command-line interface.
|
|
2
|
+
|
|
3
|
+
Commands:
|
|
4
|
+
|
|
5
|
+
* ``forgeo`` / ``forgeo init`` — guided first-time setup: asks for the
|
|
6
|
+
forgeo folder, the coding agent command, and the refactor prompt, then
|
|
7
|
+
writes a ``forgeo.yaml``. Running ``forgeo`` or ``forgeo start`` without
|
|
8
|
+
a config triggers it automatically.
|
|
9
|
+
* ``forgeo start --config forgeo.yaml`` — run the scheduled forgeo on one
|
|
10
|
+
repository. Every ``interval_minutes`` it picks an ``OPEN`` task from the
|
|
11
|
+
backlog, or runs a refactoring pass when the backlog is empty; everything
|
|
12
|
+
is committed and pushed on the main branch. When the agent needs human
|
|
13
|
+
input, a detailed ``BLOCKER.md`` file is written with what you must do.
|
|
14
|
+
The daemon binds no ports; live state is written to ``daemon.state.json``
|
|
15
|
+
and is served to you by ``forgeo web``.
|
|
16
|
+
* ``forgeo once --config forgeo.yaml`` — run exactly one cycle and exit.
|
|
17
|
+
Shares the per-forgeo lock with the daemon, so it never overlaps a
|
|
18
|
+
running ``start``.
|
|
19
|
+
* ``forgeo status --config forgeo.yaml`` — print a read-only summary of the
|
|
20
|
+
forgeo (config, backlog, daemon lock, last log outcome) and exit. Never
|
|
21
|
+
starts an agent.
|
|
22
|
+
* ``forgeo stop --config forgeo.yaml`` — stop a running daemon gracefully
|
|
23
|
+
(SIGTERM; a cycle in progress finishes first).
|
|
24
|
+
* ``forgeo restart --config forgeo.yaml`` — stop the daemon when running,
|
|
25
|
+
then start it again detached in the background, re-reading the config.
|
|
26
|
+
* ``forgeo instance add NAME --config PATH`` — register an existing
|
|
27
|
+
``forgeo.yaml`` under a stable instance name. Optional: ``start`` and
|
|
28
|
+
``stop`` register Forgeo automatically under its config's ``name``
|
|
29
|
+
when it is not in the registry yet.
|
|
30
|
+
* ``forgeo instance rm NAME`` — unregister an instance (never touches its
|
|
31
|
+
config file or repository).
|
|
32
|
+
* ``forgeo instance list`` / ``forgeo list`` — a table of every registered
|
|
33
|
+
instance: config path, repository, daemon state, last outcome, and
|
|
34
|
+
backlog counts.
|
|
35
|
+
* ``forgeo web [--host HOST] [--port PORT]`` — serve the central
|
|
36
|
+
multi-instance dashboard in the foreground (default ``0.0.0.0:8790``),
|
|
37
|
+
aggregating every registered instance straight from its files.
|
|
38
|
+
|
|
39
|
+
``start``, ``once``, ``status``, ``stop`` and ``restart`` each accept either
|
|
40
|
+
``--config PATH`` (a config file) or ``--name NAME`` (an instance resolved
|
|
41
|
+
from the registry); the two options are mutually exclusive.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
from __future__ import annotations
|
|
45
|
+
|
|
46
|
+
import argparse
|
|
47
|
+
import asyncio
|
|
48
|
+
import logging
|
|
49
|
+
import os
|
|
50
|
+
import signal
|
|
51
|
+
import subprocess
|
|
52
|
+
import sys
|
|
53
|
+
import time
|
|
54
|
+
from collections.abc import Callable
|
|
55
|
+
from logging.handlers import RotatingFileHandler
|
|
56
|
+
from pathlib import Path
|
|
57
|
+
from typing import Any
|
|
58
|
+
|
|
59
|
+
import yaml
|
|
60
|
+
from rich.console import Console
|
|
61
|
+
from rich.panel import Panel
|
|
62
|
+
from rich.prompt import Confirm
|
|
63
|
+
from rich.table import Table
|
|
64
|
+
|
|
65
|
+
from forgeo import __version__
|
|
66
|
+
from forgeo.agent import DockerSandboxAgent, SandboxUnavailableError, ShellAgent
|
|
67
|
+
from forgeo.backlog import JSONBacklog, backlog_status_counts, oldest_open_task
|
|
68
|
+
from forgeo.central import DEFAULT_HOST, DEFAULT_PORT
|
|
69
|
+
from forgeo.config import load_config
|
|
70
|
+
from forgeo.daemon import ForgeoDaemon, acquire_run_lock, is_lock_held, read_lock_pid
|
|
71
|
+
from forgeo.forgeo import Forgeo
|
|
72
|
+
from forgeo.git import GitManager
|
|
73
|
+
from forgeo.instances import (
|
|
74
|
+
InstanceInfo,
|
|
75
|
+
add_instance,
|
|
76
|
+
ensure_registered,
|
|
77
|
+
list_instances,
|
|
78
|
+
remove_instance,
|
|
79
|
+
resolve_instance,
|
|
80
|
+
)
|
|
81
|
+
from forgeo.models import ForgeoConfig, SandboxMode, Task
|
|
82
|
+
from forgeo.runs import RunRecorder, runs_path_for
|
|
83
|
+
from forgeo.setup import run_setup
|
|
84
|
+
|
|
85
|
+
DEFAULT_CONFIG = Path("forgeo.yaml")
|
|
86
|
+
|
|
87
|
+
STOP_TIMEOUT_SECONDS = 600.0
|
|
88
|
+
START_TIMEOUT_SECONDS = 15.0
|
|
89
|
+
_POLL_SECONDS = 0.5
|
|
90
|
+
|
|
91
|
+
console = Console()
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
95
|
+
"""Construct the CLI argument parser."""
|
|
96
|
+
parser = argparse.ArgumentParser(
|
|
97
|
+
prog="forgeo",
|
|
98
|
+
description="A scheduled software forgeo: executes backlog tasks on main, "
|
|
99
|
+
"refactors when idle, and writes BLOCKER.md when it needs human input.",
|
|
100
|
+
)
|
|
101
|
+
parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
|
|
102
|
+
sub = parser.add_subparsers(dest="action")
|
|
103
|
+
|
|
104
|
+
init_parser = sub.add_parser(
|
|
105
|
+
"init", help="Guided first-time setup: interactively write a forgeo.yaml."
|
|
106
|
+
)
|
|
107
|
+
init_parser.add_argument(
|
|
108
|
+
"--config",
|
|
109
|
+
type=Path,
|
|
110
|
+
default=DEFAULT_CONFIG,
|
|
111
|
+
help="Where to write the config (default: forgeo.yaml).",
|
|
112
|
+
)
|
|
113
|
+
init_parser.add_argument(
|
|
114
|
+
"--force", action="store_true", help="Overwrite an existing config file."
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
start_parser = sub.add_parser("start", help="Start the scheduled forgeo for a repository.")
|
|
118
|
+
_add_config_or_name(start_parser)
|
|
119
|
+
start_parser.add_argument(
|
|
120
|
+
"--interval-minutes",
|
|
121
|
+
type=int,
|
|
122
|
+
default=None,
|
|
123
|
+
help="Override the schedule interval from the config file.",
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
once_parser = sub.add_parser("once", help="Run exactly one forgeo cycle and exit.")
|
|
127
|
+
_add_config_or_name(once_parser)
|
|
128
|
+
|
|
129
|
+
status_parser = sub.add_parser(
|
|
130
|
+
"status",
|
|
131
|
+
help="Print a read-only summary of Forgeo (never starts an agent).",
|
|
132
|
+
)
|
|
133
|
+
_add_config_or_name(status_parser)
|
|
134
|
+
|
|
135
|
+
stop_parser = sub.add_parser(
|
|
136
|
+
"stop",
|
|
137
|
+
help="Stop a running forgeo daemon gracefully (SIGTERM).",
|
|
138
|
+
)
|
|
139
|
+
_add_config_or_name(stop_parser)
|
|
140
|
+
stop_parser.add_argument(
|
|
141
|
+
"--timeout",
|
|
142
|
+
type=float,
|
|
143
|
+
default=STOP_TIMEOUT_SECONDS,
|
|
144
|
+
help="Seconds to wait for the daemon to exit (default: 600); a cycle "
|
|
145
|
+
"in progress always finishes first.",
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
restart_parser = sub.add_parser(
|
|
149
|
+
"restart",
|
|
150
|
+
help="Restart Forgeo daemon in the background, re-reading the config.",
|
|
151
|
+
)
|
|
152
|
+
_add_config_or_name(restart_parser)
|
|
153
|
+
restart_parser.add_argument(
|
|
154
|
+
"--timeout",
|
|
155
|
+
type=float,
|
|
156
|
+
default=STOP_TIMEOUT_SECONDS,
|
|
157
|
+
help="Seconds to wait for the old daemon to exit (default: 600); a "
|
|
158
|
+
"cycle in progress always finishes first.",
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
instance_parser = sub.add_parser(
|
|
162
|
+
"instance",
|
|
163
|
+
help="Register, list, and unregister named forgeo instances.",
|
|
164
|
+
)
|
|
165
|
+
instance_sub = instance_parser.add_subparsers(dest="instance_action")
|
|
166
|
+
|
|
167
|
+
instance_add_parser = instance_sub.add_parser(
|
|
168
|
+
"add", help="Register an existing forgeo.yaml under a stable name."
|
|
169
|
+
)
|
|
170
|
+
instance_add_parser.add_argument(
|
|
171
|
+
"name", help="Unique instance name (must match ^[a-zA-Z0-9._-]+$)."
|
|
172
|
+
)
|
|
173
|
+
instance_add_parser.add_argument(
|
|
174
|
+
"--config",
|
|
175
|
+
type=Path,
|
|
176
|
+
required=True,
|
|
177
|
+
help="Path to Forgeo.yaml to register.",
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
instance_rm_parser = instance_sub.add_parser("rm", help="Unregister an instance.")
|
|
181
|
+
instance_rm_parser.add_argument("name", help="Instance name to unregister.")
|
|
182
|
+
|
|
183
|
+
instance_sub.add_parser(
|
|
184
|
+
"list", help="List every registered instance and its state."
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
sub.add_parser(
|
|
188
|
+
"list",
|
|
189
|
+
help="List every registered instance (alias for `forgeo instance list`).",
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
web_parser = sub.add_parser(
|
|
193
|
+
"web",
|
|
194
|
+
help="Serve the central multi-instance dashboard in the foreground.",
|
|
195
|
+
)
|
|
196
|
+
web_parser.add_argument(
|
|
197
|
+
"--host",
|
|
198
|
+
default=DEFAULT_HOST,
|
|
199
|
+
help=f"Bind address (default: {DEFAULT_HOST}).",
|
|
200
|
+
)
|
|
201
|
+
web_parser.add_argument(
|
|
202
|
+
"--port",
|
|
203
|
+
type=int,
|
|
204
|
+
default=DEFAULT_PORT,
|
|
205
|
+
help=f"Bind port (default: {DEFAULT_PORT}).",
|
|
206
|
+
)
|
|
207
|
+
return parser
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _add_config_or_name(
|
|
211
|
+
parser: argparse.ArgumentParser, *, default_config: Path = DEFAULT_CONFIG
|
|
212
|
+
) -> None:
|
|
213
|
+
"""Add a mutually-exclusive ``--config``/``--name`` option pair.
|
|
214
|
+
|
|
215
|
+
``--config`` keeps its default so plain ``forgeo start`` (etc.) keeps
|
|
216
|
+
resolving to ``forgeo.yaml``; argparse still rejects explicitly passing
|
|
217
|
+
both options together.
|
|
218
|
+
"""
|
|
219
|
+
group = parser.add_mutually_exclusive_group()
|
|
220
|
+
group.add_argument(
|
|
221
|
+
"--config",
|
|
222
|
+
type=Path,
|
|
223
|
+
default=default_config,
|
|
224
|
+
help="Forgeo YAML file (default: forgeo.yaml).",
|
|
225
|
+
)
|
|
226
|
+
group.add_argument(
|
|
227
|
+
"--name",
|
|
228
|
+
default=None,
|
|
229
|
+
help="Registered instance name resolved from the registry "
|
|
230
|
+
"(see `forgeo instance`).",
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def setup_logging(log_file: str | Path) -> None:
|
|
235
|
+
"""Configure the ``forgeo`` logger with a rotating file handler."""
|
|
236
|
+
logger = logging.getLogger("forgeo")
|
|
237
|
+
for handler in list(logger.handlers):
|
|
238
|
+
logger.removeHandler(handler)
|
|
239
|
+
logger.setLevel(logging.INFO)
|
|
240
|
+
logger.propagate = False
|
|
241
|
+
formatter = logging.Formatter(
|
|
242
|
+
"%(asctime)s %(levelname)-8s %(name)s: %(message)s",
|
|
243
|
+
datefmt="%Y-%m-%d %H:%M:%S",
|
|
244
|
+
)
|
|
245
|
+
file_path = Path(log_file)
|
|
246
|
+
file_path.parent.mkdir(parents=True, exist_ok=True)
|
|
247
|
+
handler = RotatingFileHandler(
|
|
248
|
+
file_path, maxBytes=5 * 1024 * 1024, backupCount=3, encoding="utf-8"
|
|
249
|
+
)
|
|
250
|
+
handler.setFormatter(formatter)
|
|
251
|
+
logger.addHandler(handler)
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _offer_setup(config_path: Path) -> bool:
|
|
255
|
+
"""Offer the guided setup; returns True when a config now exists."""
|
|
256
|
+
if not Confirm.ask("No config found. Run the guided first-time setup now?", default=True):
|
|
257
|
+
return False
|
|
258
|
+
return run_setup(base_dir=config_path.parent.resolve(), config_path=config_path) is not None
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _resolved_config_path(args: argparse.Namespace) -> Path | None:
|
|
262
|
+
"""Resolve the config path: ``--name`` from the registry, else ``--config``.
|
|
263
|
+
|
|
264
|
+
Prints an error and returns ``None`` when the instance name is not
|
|
265
|
+
registered.
|
|
266
|
+
"""
|
|
267
|
+
name = getattr(args, "name", None)
|
|
268
|
+
if name is None:
|
|
269
|
+
return Path(args.config)
|
|
270
|
+
config_path = resolve_instance(name)
|
|
271
|
+
if config_path is None:
|
|
272
|
+
console.print(
|
|
273
|
+
f"[red]Unknown instance: {name}. Register it with "
|
|
274
|
+
f"`forgeo instance add {name} --config PATH`.[/red]"
|
|
275
|
+
)
|
|
276
|
+
return None
|
|
277
|
+
return config_path
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def _register_if_missing(
|
|
281
|
+
args: argparse.Namespace, config_path: Path, config: ForgeoConfig
|
|
282
|
+
) -> None:
|
|
283
|
+
"""Auto-register Forgeo under ``config.name`` when not registered.
|
|
284
|
+
|
|
285
|
+
Only ``--config`` invocations register: with ``--name`` the instance
|
|
286
|
+
must already exist (``_resolved_config_path`` errors otherwise). The
|
|
287
|
+
instance name is the config's ``name`` field, so ``start``/``stop``
|
|
288
|
+
always leave Forgeo visible in the registry.
|
|
289
|
+
"""
|
|
290
|
+
if getattr(args, "name", None) is not None:
|
|
291
|
+
return
|
|
292
|
+
if ensure_registered(config.name, config_path):
|
|
293
|
+
console.print(
|
|
294
|
+
f"[green]Registered instance {config.name!r} -> "
|
|
295
|
+
f"{config_path.resolve()}.[/green]"
|
|
296
|
+
)
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def _resolve_config(args: argparse.Namespace) -> ForgeoConfig | None:
|
|
300
|
+
"""Load the config, offering the guided setup when missing.
|
|
301
|
+
|
|
302
|
+
Resolves ``--name`` through the instance registry. Applies the optional
|
|
303
|
+
``interval_minutes`` override. Returns ``None`` when no config can be
|
|
304
|
+
produced.
|
|
305
|
+
"""
|
|
306
|
+
config_path = _resolved_config_path(args)
|
|
307
|
+
if config_path is None:
|
|
308
|
+
return None
|
|
309
|
+
if not config_path.exists():
|
|
310
|
+
console.print(f"[yellow]Config file not found: {config_path}[/yellow]")
|
|
311
|
+
if not _offer_setup(config_path):
|
|
312
|
+
console.print(
|
|
313
|
+
"[yellow]Create one with `forgeo init`, or pass --config <file>.[/yellow]"
|
|
314
|
+
)
|
|
315
|
+
return None
|
|
316
|
+
config = load_config(config_path)
|
|
317
|
+
interval = getattr(args, "interval_minutes", None)
|
|
318
|
+
if interval is not None:
|
|
319
|
+
config = config.model_copy(update={"interval_minutes": interval})
|
|
320
|
+
return config
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def _acquire_run_lock(config: ForgeoConfig) -> Any | None:
|
|
324
|
+
"""Take the per-forgeo lock; prints an error and returns None when busy."""
|
|
325
|
+
lock = acquire_run_lock(config.backlog.with_suffix(".lock"))
|
|
326
|
+
if lock is None:
|
|
327
|
+
console.print(
|
|
328
|
+
f"[red]Another forgeo process (daemon or `once`) is already "
|
|
329
|
+
f"running for {config.name!r}.[/red]"
|
|
330
|
+
)
|
|
331
|
+
return lock
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def _build_agent(config: ForgeoConfig) -> ShellAgent:
|
|
335
|
+
"""Build the configured agent, sandboxed when ``agent_sandbox`` demands it."""
|
|
336
|
+
if config.agent_sandbox is SandboxMode.DOCKER:
|
|
337
|
+
return DockerSandboxAgent(
|
|
338
|
+
config.agent_command,
|
|
339
|
+
image=config.agent_sandbox_image or "",
|
|
340
|
+
network=config.agent_sandbox_network,
|
|
341
|
+
mounts=config.agent_sandbox_mounts,
|
|
342
|
+
timeout_seconds=config.agent_timeout_seconds,
|
|
343
|
+
env=config.agent_env,
|
|
344
|
+
blocked_exit_code=config.blocked_exit_code,
|
|
345
|
+
)
|
|
346
|
+
return ShellAgent(
|
|
347
|
+
config.agent_command,
|
|
348
|
+
timeout_seconds=config.agent_timeout_seconds,
|
|
349
|
+
env=config.agent_env,
|
|
350
|
+
blocked_exit_code=config.blocked_exit_code,
|
|
351
|
+
)
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def _make_forgeo(config: ForgeoConfig) -> Forgeo:
|
|
355
|
+
"""Build a :class:`Forgeo` wired to the config.
|
|
356
|
+
|
|
357
|
+
Raises:
|
|
358
|
+
SandboxUnavailableError: When the configured sandbox backend is
|
|
359
|
+
unavailable (e.g. no docker binary) — callers turn that into a
|
|
360
|
+
clear startup error.
|
|
361
|
+
"""
|
|
362
|
+
backlog = JSONBacklog(config.backlog)
|
|
363
|
+
agent = _build_agent(config)
|
|
364
|
+
return Forgeo(
|
|
365
|
+
config,
|
|
366
|
+
backlog,
|
|
367
|
+
agent,
|
|
368
|
+
GitManager(config.repo, timeout_seconds=config.git_timeout_seconds),
|
|
369
|
+
)
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def _prepare_worker(
|
|
373
|
+
args: argparse.Namespace,
|
|
374
|
+
) -> tuple[Path, ForgeoConfig, Forgeo, Any] | None:
|
|
375
|
+
"""Resolve the config, take the run lock, and build Forgeo.
|
|
376
|
+
|
|
377
|
+
Shared by ``start`` and ``once``. Returns ``None`` (after printing an
|
|
378
|
+
error) when any step fails; on success the caller owns the lock and must
|
|
379
|
+
close it.
|
|
380
|
+
"""
|
|
381
|
+
config_path = _resolved_config_path(args)
|
|
382
|
+
if config_path is None:
|
|
383
|
+
return None
|
|
384
|
+
config = _resolve_config(args)
|
|
385
|
+
if config is None:
|
|
386
|
+
return None
|
|
387
|
+
setup_logging(config.log_file)
|
|
388
|
+
log = logging.getLogger("forgeo.cli")
|
|
389
|
+
log.info("Loading forgeo config from %s", config_path)
|
|
390
|
+
lock = _acquire_run_lock(config)
|
|
391
|
+
if lock is None:
|
|
392
|
+
return None
|
|
393
|
+
try:
|
|
394
|
+
forgeo = _make_forgeo(config)
|
|
395
|
+
except SandboxUnavailableError as exc:
|
|
396
|
+
lock.close()
|
|
397
|
+
console.print(f"[red]{exc}[/red]")
|
|
398
|
+
log.error("Sandbox unavailable: %s", exc)
|
|
399
|
+
return None
|
|
400
|
+
return config_path, config, forgeo, lock
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def cmd_start(args: argparse.Namespace) -> int:
|
|
404
|
+
"""Handle ``forgeo start``: the persistent scheduled worker."""
|
|
405
|
+
prepared = _prepare_worker(args)
|
|
406
|
+
if prepared is None:
|
|
407
|
+
return 1
|
|
408
|
+
config_path, config, forgeo, lock = prepared
|
|
409
|
+
_register_if_missing(args, config_path, config)
|
|
410
|
+
|
|
411
|
+
async def _serve() -> None:
|
|
412
|
+
daemon = ForgeoDaemon(config, forgeo)
|
|
413
|
+
loop = asyncio.get_running_loop()
|
|
414
|
+
for sig in (signal.SIGINT, signal.SIGTERM):
|
|
415
|
+
try:
|
|
416
|
+
loop.add_signal_handler(sig, daemon.stop)
|
|
417
|
+
except NotImplementedError:
|
|
418
|
+
pass
|
|
419
|
+
console.print(
|
|
420
|
+
Panel.fit(
|
|
421
|
+
f"[bold]Forgeo:[/bold] {config.name}\n"
|
|
422
|
+
f"[bold]Repo:[/bold] {config.repo}\n"
|
|
423
|
+
f"[bold]Interval:[/bold] {config.interval_minutes} min\n"
|
|
424
|
+
f"[bold]Backlog:[/bold] {config.backlog}\n"
|
|
425
|
+
f"[bold]Branch:[/bold] {config.branch}\n"
|
|
426
|
+
f"[bold]Log:[/bold] {config.log_file}",
|
|
427
|
+
title="Forgeo",
|
|
428
|
+
border_style="green",
|
|
429
|
+
)
|
|
430
|
+
)
|
|
431
|
+
await daemon.run_forever()
|
|
432
|
+
|
|
433
|
+
try:
|
|
434
|
+
asyncio.run(_serve())
|
|
435
|
+
except KeyboardInterrupt:
|
|
436
|
+
pass
|
|
437
|
+
finally:
|
|
438
|
+
lock.close()
|
|
439
|
+
return 0
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
def cmd_once(args: argparse.Namespace) -> int:
|
|
443
|
+
"""Handle ``forgeo once``: run exactly one cycle and exit."""
|
|
444
|
+
prepared = _prepare_worker(args)
|
|
445
|
+
if prepared is None:
|
|
446
|
+
return 1
|
|
447
|
+
_config_path, _config, forgeo, lock = prepared
|
|
448
|
+
|
|
449
|
+
async def _run_once() -> None:
|
|
450
|
+
outcome = await forgeo.run_cycle()
|
|
451
|
+
console.print(f"[green]Cycle finished: {outcome}[/green]")
|
|
452
|
+
|
|
453
|
+
try:
|
|
454
|
+
asyncio.run(_run_once())
|
|
455
|
+
except KeyboardInterrupt:
|
|
456
|
+
pass
|
|
457
|
+
finally:
|
|
458
|
+
lock.close()
|
|
459
|
+
return 0
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
def cmd_init(args: argparse.Namespace) -> int:
|
|
463
|
+
"""Handle ``forgeo init``: the guided first-time setup."""
|
|
464
|
+
if args.config.exists() and not args.force:
|
|
465
|
+
console.print(f"[red]{args.config} already exists. Pass --force to overwrite.[/red]")
|
|
466
|
+
return 2
|
|
467
|
+
if run_setup(base_dir=args.config.parent.resolve(), config_path=args.config) is None:
|
|
468
|
+
console.print("[yellow]Setup aborted; nothing was written.[/yellow]")
|
|
469
|
+
return 130
|
|
470
|
+
return 0
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
def last_outcome_from_runs(config: ForgeoConfig) -> str | None:
|
|
474
|
+
"""Return the last run's outcome from ``runs.jsonl``, or ``None``.
|
|
475
|
+
|
|
476
|
+
Never raises on a missing or corrupt file; corrupt lines are skipped
|
|
477
|
+
with a warning.
|
|
478
|
+
"""
|
|
479
|
+
last_run = RunRecorder(runs_path_for(config.backlog)).read_last()
|
|
480
|
+
if last_run is None:
|
|
481
|
+
return None
|
|
482
|
+
return last_run.outcome.value
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
def render_status(
|
|
486
|
+
config: ForgeoConfig,
|
|
487
|
+
tasks: list[Task],
|
|
488
|
+
*,
|
|
489
|
+
daemon_running: bool,
|
|
490
|
+
last_outcome: str | None,
|
|
491
|
+
) -> str:
|
|
492
|
+
"""Render the human-readable status summary as plain text."""
|
|
493
|
+
counts = backlog_status_counts(tasks)
|
|
494
|
+
count_text = " ".join(f"{status}={counts[status]}" for status in counts)
|
|
495
|
+
nxt = oldest_open_task(tasks)
|
|
496
|
+
next_text = f"{nxt.id} — {nxt.title}" if nxt is not None else "(none)"
|
|
497
|
+
daemon_text = "running" if daemon_running else "not running"
|
|
498
|
+
outcome_text = last_outcome if last_outcome is not None else "(none)"
|
|
499
|
+
return "\n".join(
|
|
500
|
+
[
|
|
501
|
+
f"name: {config.name}",
|
|
502
|
+
f"repo: {config.repo}",
|
|
503
|
+
f"interval: {config.interval_minutes} min",
|
|
504
|
+
f"branch: {config.branch}",
|
|
505
|
+
f"backlog: {count_text}",
|
|
506
|
+
f"next: {next_text}",
|
|
507
|
+
f"daemon: {daemon_text}",
|
|
508
|
+
f"last outcome: {outcome_text}",
|
|
509
|
+
]
|
|
510
|
+
)
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
def _load_config_or_error(config_path: Path) -> ForgeoConfig | None:
|
|
514
|
+
"""Load an existing config; prints an error and returns None when missing."""
|
|
515
|
+
if not config_path.exists():
|
|
516
|
+
console.print(f"[red]Config file not found: {config_path}[/red]")
|
|
517
|
+
return None
|
|
518
|
+
return load_config(config_path)
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
def cmd_status(args: argparse.Namespace) -> int:
|
|
522
|
+
"""Handle ``forgeo status``: read-only summary; never starts an agent."""
|
|
523
|
+
config_path = _resolved_config_path(args)
|
|
524
|
+
if config_path is None:
|
|
525
|
+
return 1
|
|
526
|
+
config = _load_config_or_error(config_path)
|
|
527
|
+
if config is None:
|
|
528
|
+
return 1
|
|
529
|
+
tasks = asyncio.run(JSONBacklog(config.backlog).list_tasks())
|
|
530
|
+
daemon_running = is_lock_held(config.backlog.with_suffix(".lock"))
|
|
531
|
+
last_outcome = last_outcome_from_runs(config)
|
|
532
|
+
console.print(
|
|
533
|
+
render_status(
|
|
534
|
+
config,
|
|
535
|
+
tasks,
|
|
536
|
+
daemon_running=daemon_running,
|
|
537
|
+
last_outcome=last_outcome,
|
|
538
|
+
)
|
|
539
|
+
)
|
|
540
|
+
return 0
|
|
541
|
+
|
|
542
|
+
|
|
543
|
+
def _wait_for_lock_release(lock_path: Path, timeout: float) -> bool:
|
|
544
|
+
"""Poll until the daemon lock is released; False on timeout."""
|
|
545
|
+
deadline = time.monotonic() + timeout
|
|
546
|
+
while time.monotonic() < deadline:
|
|
547
|
+
if not is_lock_held(lock_path):
|
|
548
|
+
return True
|
|
549
|
+
time.sleep(_POLL_SECONDS)
|
|
550
|
+
return not is_lock_held(lock_path)
|
|
551
|
+
|
|
552
|
+
|
|
553
|
+
def _stop_daemon(config: ForgeoConfig, timeout: float) -> bool:
|
|
554
|
+
"""SIGTERM the running daemon and wait for it to exit; False on failure."""
|
|
555
|
+
lock_path = config.backlog.with_suffix(".lock")
|
|
556
|
+
pid = read_lock_pid(lock_path)
|
|
557
|
+
if pid is None:
|
|
558
|
+
console.print(
|
|
559
|
+
f"[red]The lock file {lock_path} records no PID; find the daemon "
|
|
560
|
+
f"with `pgrep -af forgeo` and stop it manually.[/red]"
|
|
561
|
+
)
|
|
562
|
+
return False
|
|
563
|
+
console.print(f"Stopping forgeo {config.name!r} (pid {pid})…")
|
|
564
|
+
try:
|
|
565
|
+
os.kill(pid, signal.SIGTERM)
|
|
566
|
+
except ProcessLookupError:
|
|
567
|
+
if not is_lock_held(lock_path):
|
|
568
|
+
console.print(f"[green]Forgeo {config.name!r} stopped.[/green]")
|
|
569
|
+
return True
|
|
570
|
+
console.print(
|
|
571
|
+
f"[red]Recorded pid {pid} is gone but the lock is still held; "
|
|
572
|
+
f"check with `pgrep -af forgeo`.[/red]"
|
|
573
|
+
)
|
|
574
|
+
return False
|
|
575
|
+
except PermissionError:
|
|
576
|
+
console.print(f"[red]No permission to stop process {pid}.[/red]")
|
|
577
|
+
return False
|
|
578
|
+
if _wait_for_lock_release(lock_path, timeout):
|
|
579
|
+
console.print(f"[green]Forgeo {config.name!r} stopped.[/green]")
|
|
580
|
+
return True
|
|
581
|
+
console.print(
|
|
582
|
+
f"[yellow]Forgeo is still shutting down after {timeout:.0f}s "
|
|
583
|
+
f"(a cycle in progress finishes first); giving up.[/yellow]"
|
|
584
|
+
)
|
|
585
|
+
return False
|
|
586
|
+
|
|
587
|
+
|
|
588
|
+
def cmd_stop(args: argparse.Namespace) -> int:
|
|
589
|
+
"""Handle ``forgeo stop``: graceful daemon shutdown via SIGTERM."""
|
|
590
|
+
config_path = _resolved_config_path(args)
|
|
591
|
+
if config_path is None:
|
|
592
|
+
return 1
|
|
593
|
+
config = _load_config_or_error(config_path)
|
|
594
|
+
if config is None:
|
|
595
|
+
return 1
|
|
596
|
+
_register_if_missing(args, config_path, config)
|
|
597
|
+
if not is_lock_held(config.backlog.with_suffix(".lock")):
|
|
598
|
+
console.print(f"[yellow]Forgeo {config.name!r} is not running.[/yellow]")
|
|
599
|
+
return 1
|
|
600
|
+
return 0 if _stop_daemon(config, args.timeout) else 1
|
|
601
|
+
|
|
602
|
+
|
|
603
|
+
def cmd_restart(args: argparse.Namespace) -> int:
|
|
604
|
+
"""Handle ``forgeo restart``: stop when running, then start detached."""
|
|
605
|
+
config_path = _resolved_config_path(args)
|
|
606
|
+
if config_path is None:
|
|
607
|
+
return 1
|
|
608
|
+
config = _load_config_or_error(config_path)
|
|
609
|
+
if config is None:
|
|
610
|
+
return 1
|
|
611
|
+
lock_path = config.backlog.with_suffix(".lock")
|
|
612
|
+
if is_lock_held(lock_path) and not _stop_daemon(config, args.timeout):
|
|
613
|
+
return 1
|
|
614
|
+
proc = subprocess.Popen(
|
|
615
|
+
[sys.executable, "-m", "forgeo", "start", "--config", str(config_path)],
|
|
616
|
+
stdin=subprocess.DEVNULL,
|
|
617
|
+
stdout=subprocess.DEVNULL,
|
|
618
|
+
stderr=subprocess.DEVNULL,
|
|
619
|
+
start_new_session=True,
|
|
620
|
+
)
|
|
621
|
+
deadline = time.monotonic() + START_TIMEOUT_SECONDS
|
|
622
|
+
while time.monotonic() < deadline:
|
|
623
|
+
if is_lock_held(lock_path):
|
|
624
|
+
console.print(
|
|
625
|
+
f"[green]Forgeo {config.name!r} restarted "
|
|
626
|
+
f"(pid {read_lock_pid(lock_path) or proc.pid}, "
|
|
627
|
+
f"interval {config.interval_minutes} min).[/green]"
|
|
628
|
+
)
|
|
629
|
+
return 0
|
|
630
|
+
if proc.poll() is not None:
|
|
631
|
+
break
|
|
632
|
+
time.sleep(_POLL_SECONDS)
|
|
633
|
+
console.print(f"[red]Forgeo daemon did not start; see {config.log_file} for details.[/red]")
|
|
634
|
+
return 1
|
|
635
|
+
|
|
636
|
+
|
|
637
|
+
def cmd_default() -> int:
|
|
638
|
+
"""Bare ``forgeo``: show help when configured, run the wizard otherwise."""
|
|
639
|
+
if DEFAULT_CONFIG.exists():
|
|
640
|
+
build_parser().print_help()
|
|
641
|
+
return 0
|
|
642
|
+
console.print("[yellow]No forgeo.yaml found — starting the guided setup.[/yellow]")
|
|
643
|
+
return cmd_init(argparse.Namespace(config=DEFAULT_CONFIG, force=False))
|
|
644
|
+
|
|
645
|
+
|
|
646
|
+
def cmd_instance_add(args: argparse.Namespace) -> int:
|
|
647
|
+
"""Handle ``forgeo instance add``: register an existing forgeo.yaml.
|
|
648
|
+
|
|
649
|
+
Normally unnecessary — ``forgeo start`` and ``forgeo stop`` register
|
|
650
|
+
the config under its ``name`` automatically — but handy to pre-register
|
|
651
|
+
an explicit name or one that differs from ``config.name``.
|
|
652
|
+
"""
|
|
653
|
+
try:
|
|
654
|
+
add_instance(args.name, args.config)
|
|
655
|
+
except (ValueError, FileNotFoundError, yaml.YAMLError) as exc:
|
|
656
|
+
console.print(f"[red]{exc}[/red]")
|
|
657
|
+
return 1
|
|
658
|
+
console.print(
|
|
659
|
+
f"[green]Registered instance {args.name!r} -> {args.config.resolve()}.[/green]"
|
|
660
|
+
)
|
|
661
|
+
return 0
|
|
662
|
+
|
|
663
|
+
|
|
664
|
+
def cmd_instance_rm(args: argparse.Namespace) -> int:
|
|
665
|
+
"""Handle ``forgeo instance rm``: unregister without touching the repo."""
|
|
666
|
+
if remove_instance(args.name):
|
|
667
|
+
console.print(f"[green]Unregistered instance {args.name!r}.[/green]")
|
|
668
|
+
return 0
|
|
669
|
+
console.print(f"[red]Unknown instance: {args.name}[/red]")
|
|
670
|
+
return 1
|
|
671
|
+
|
|
672
|
+
|
|
673
|
+
def _instance_row(info: InstanceInfo) -> tuple[str, ...]:
|
|
674
|
+
"""Render one instance's table row (name, daemon state, last outcome)."""
|
|
675
|
+
if info.config is None:
|
|
676
|
+
return (
|
|
677
|
+
info.name,
|
|
678
|
+
"stopped",
|
|
679
|
+
"(none)",
|
|
680
|
+
)
|
|
681
|
+
last_outcome = last_outcome_from_runs(info.config) or "(none)"
|
|
682
|
+
return (
|
|
683
|
+
info.name,
|
|
684
|
+
"running" if info.daemon_running else "stopped",
|
|
685
|
+
last_outcome,
|
|
686
|
+
)
|
|
687
|
+
|
|
688
|
+
|
|
689
|
+
def cmd_instance_list(args: argparse.Namespace) -> int:
|
|
690
|
+
"""Handle ``forgeo instance list`` / ``forgeo list``."""
|
|
691
|
+
infos = list_instances()
|
|
692
|
+
if not infos:
|
|
693
|
+
console.print("[yellow]No registered instances.[/yellow]")
|
|
694
|
+
console.print(
|
|
695
|
+
"[yellow]Register one with `forgeo instance add NAME --config PATH`.[/yellow]"
|
|
696
|
+
)
|
|
697
|
+
return 0
|
|
698
|
+
table = Table(title="Forgeo instances")
|
|
699
|
+
for column in ("Name", "Daemon", "Last outcome"):
|
|
700
|
+
table.add_column(column, overflow="fold")
|
|
701
|
+
for info in infos:
|
|
702
|
+
table.add_row(*_instance_row(info))
|
|
703
|
+
# Fits any terminal width: three short columns, no long paths.
|
|
704
|
+
console.print(table)
|
|
705
|
+
return 0
|
|
706
|
+
|
|
707
|
+
|
|
708
|
+
def cmd_instance(args: argparse.Namespace) -> int:
|
|
709
|
+
"""Handle ``forgeo instance``: the registry subcommand group."""
|
|
710
|
+
action = args.instance_action
|
|
711
|
+
if action == "add":
|
|
712
|
+
return cmd_instance_add(args)
|
|
713
|
+
if action == "rm":
|
|
714
|
+
return cmd_instance_rm(args)
|
|
715
|
+
if action == "list":
|
|
716
|
+
return cmd_instance_list(args)
|
|
717
|
+
build_parser().print_help()
|
|
718
|
+
return 0
|
|
719
|
+
|
|
720
|
+
|
|
721
|
+
def cmd_web(args: argparse.Namespace) -> int:
|
|
722
|
+
"""Handle ``forgeo web``: the central multi-instance dashboard.
|
|
723
|
+
|
|
724
|
+
Runs in the foreground like ``forgeo start``, serving an aggregate view
|
|
725
|
+
of every registered instance straight from each instance's files.
|
|
726
|
+
"""
|
|
727
|
+
from forgeo.central import run_foreground
|
|
728
|
+
|
|
729
|
+
return run_foreground(host=args.host, port=args.port)
|
|
730
|
+
|
|
731
|
+
|
|
732
|
+
_COMMANDS: dict[str, Callable[[argparse.Namespace], int]] = {
|
|
733
|
+
"start": cmd_start,
|
|
734
|
+
"once": cmd_once,
|
|
735
|
+
"init": cmd_init,
|
|
736
|
+
"status": cmd_status,
|
|
737
|
+
"stop": cmd_stop,
|
|
738
|
+
"restart": cmd_restart,
|
|
739
|
+
"instance": cmd_instance,
|
|
740
|
+
"list": cmd_instance_list,
|
|
741
|
+
"web": cmd_web,
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
|
|
745
|
+
def main(argv: list[str] | None = None) -> int:
|
|
746
|
+
"""CLI entry point used by both ``forgeo`` and ``python -m forgeo``."""
|
|
747
|
+
parser = build_parser()
|
|
748
|
+
args = parser.parse_args(argv)
|
|
749
|
+
if args.action is None:
|
|
750
|
+
return cmd_default()
|
|
751
|
+
command = _COMMANDS.get(args.action)
|
|
752
|
+
if command is None:
|
|
753
|
+
parser.error(f"unknown command: {args.action}")
|
|
754
|
+
return 2
|
|
755
|
+
return command(args)
|
|
756
|
+
|
|
757
|
+
|
|
758
|
+
if __name__ == "__main__":
|
|
759
|
+
sys.exit(main())
|