forgexa-cli 1.3.4__tar.gz → 1.5.2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {forgexa_cli-1.3.4 → forgexa_cli-1.5.2}/PKG-INFO +1 -1
- {forgexa_cli-1.3.4 → forgexa_cli-1.5.2}/forgexa_cli/__init__.py +1 -1
- {forgexa_cli-1.3.4 → forgexa_cli-1.5.2}/forgexa_cli/daemon.py +512 -60
- {forgexa_cli-1.3.4 → forgexa_cli-1.5.2}/forgexa_cli/main.py +139 -25
- {forgexa_cli-1.3.4 → forgexa_cli-1.5.2}/forgexa_cli.egg-info/PKG-INFO +1 -1
- {forgexa_cli-1.3.4 → forgexa_cli-1.5.2}/pyproject.toml +1 -1
- {forgexa_cli-1.3.4 → forgexa_cli-1.5.2}/README.md +0 -0
- {forgexa_cli-1.3.4 → forgexa_cli-1.5.2}/forgexa_cli/_build_config.py +0 -0
- {forgexa_cli-1.3.4 → forgexa_cli-1.5.2}/forgexa_cli/py.typed +0 -0
- {forgexa_cli-1.3.4 → forgexa_cli-1.5.2}/forgexa_cli.egg-info/SOURCES.txt +0 -0
- {forgexa_cli-1.3.4 → forgexa_cli-1.5.2}/forgexa_cli.egg-info/dependency_links.txt +0 -0
- {forgexa_cli-1.3.4 → forgexa_cli-1.5.2}/forgexa_cli.egg-info/entry_points.txt +0 -0
- {forgexa_cli-1.3.4 → forgexa_cli-1.5.2}/forgexa_cli.egg-info/requires.txt +0 -0
- {forgexa_cli-1.3.4 → forgexa_cli-1.5.2}/forgexa_cli.egg-info/top_level.txt +0 -0
- {forgexa_cli-1.3.4 → forgexa_cli-1.5.2}/setup.cfg +0 -0
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
"""forgexa-cli — Forgexa command-line client."""
|
|
2
|
-
__version__ = "1.
|
|
2
|
+
__version__ = "1.5.2"
|
|
@@ -37,16 +37,198 @@ try:
|
|
|
37
37
|
except ImportError:
|
|
38
38
|
fcntl = None # type: ignore[assignment]
|
|
39
39
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
40
|
+
# ── httpx dependency — robust auto-install for standalone environments ──
|
|
41
|
+
# When running inside the backend package, httpx is a declared dependency and
|
|
42
|
+
# always available. In standalone contexts (desktop AppImage/DMG/MSI, CLI
|
|
43
|
+
# without [daemon] extra), httpx may be missing. We try multiple strategies:
|
|
44
|
+
#
|
|
45
|
+
# 1. Direct import (works for backend & CLI[daemon])
|
|
46
|
+
# 2. Import from cached deps dir (~/.forgexa/daemon/deps)
|
|
47
|
+
# 3. Auto-install via pip --target to the cached deps dir
|
|
48
|
+
# (bypasses PEP 668 / externally-managed-environment on modern distros)
|
|
49
|
+
# 4. Friendly error with OS-specific instructions if all else fails
|
|
50
|
+
_HTTPX_DEPS_DIR = os.path.join(str(Path.home()), ".forgexa", "daemon", "deps")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _try_install_httpx(deps_dir: str) -> tuple[bool, str]:
|
|
54
|
+
"""Try to install httpx to a user-writable directory.
|
|
55
|
+
|
|
56
|
+
Uses pip --target which works on:
|
|
57
|
+
- AppImage (read-only squashfs, system Python)
|
|
58
|
+
- PEP 668 systems (Ubuntu 23.04+, Fedora 38+) — bypasses externally-managed check
|
|
59
|
+
- macOS .app bundles (sandboxed Python)
|
|
60
|
+
- Windows portable installs
|
|
61
|
+
- Docker containers with read-only system dirs
|
|
62
|
+
|
|
63
|
+
Returns (success, error_detail).
|
|
64
|
+
"""
|
|
65
|
+
os.makedirs(deps_dir, exist_ok=True)
|
|
66
|
+
python = sys.executable or "python3"
|
|
67
|
+
|
|
68
|
+
# Try pip --target first (most universally compatible).
|
|
69
|
+
# Falls back to --user, then --break-system-packages as last resort.
|
|
70
|
+
# We explicitly list httpcore alongside httpx because pip --target may
|
|
71
|
+
# skip transitive deps it finds in system site-packages, even though
|
|
72
|
+
# they won't be importable from the isolated deps directory.
|
|
73
|
+
strategies: list[tuple[str, list[str]]] = [
|
|
74
|
+
(
|
|
75
|
+
"pip install --target (isolated deps)",
|
|
76
|
+
[python, "-m", "pip", "install", "--target", deps_dir,
|
|
77
|
+
"--quiet", "--upgrade", "httpx>=0.24", "httpcore"],
|
|
78
|
+
),
|
|
79
|
+
(
|
|
80
|
+
"pip install --user",
|
|
81
|
+
[python, "-m", "pip", "install", "--user", "--quiet",
|
|
82
|
+
"httpx>=0.24", "httpcore"],
|
|
83
|
+
),
|
|
84
|
+
(
|
|
85
|
+
"pip install --break-system-packages",
|
|
86
|
+
[python, "-m", "pip", "install", "--quiet",
|
|
87
|
+
"--break-system-packages", "httpx>=0.24", "httpcore"],
|
|
88
|
+
),
|
|
89
|
+
]
|
|
90
|
+
|
|
91
|
+
last_error = ""
|
|
92
|
+
for label, cmd in strategies:
|
|
93
|
+
try:
|
|
94
|
+
result = subprocess.run(
|
|
95
|
+
cmd,
|
|
96
|
+
stdout=subprocess.DEVNULL,
|
|
97
|
+
stderr=subprocess.PIPE,
|
|
98
|
+
text=True,
|
|
99
|
+
timeout=120,
|
|
100
|
+
)
|
|
101
|
+
if result.returncode == 0:
|
|
102
|
+
return True, ""
|
|
103
|
+
last_error = f"[{label}] exit code {result.returncode}"
|
|
104
|
+
stderr_text = (result.stderr or "").strip()
|
|
105
|
+
if stderr_text:
|
|
106
|
+
# Keep last 5 lines of stderr for diagnostics
|
|
107
|
+
stderr_lines = stderr_text.splitlines()[-5:]
|
|
108
|
+
last_error += ": " + " | ".join(stderr_lines)
|
|
109
|
+
except FileNotFoundError:
|
|
110
|
+
last_error = f"[{label}] Python not found: {cmd[0]}"
|
|
111
|
+
except subprocess.TimeoutExpired:
|
|
112
|
+
last_error = f"[{label}] timed out after 120s"
|
|
113
|
+
except Exception as exc:
|
|
114
|
+
last_error = f"[{label}] {type(exc).__name__}: {exc}"
|
|
115
|
+
|
|
116
|
+
return False, last_error
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _die_missing_httpx(detail: str) -> None:
|
|
120
|
+
"""Print a clear, actionable error and exit when httpx cannot be loaded."""
|
|
121
|
+
os_name = platform.system()
|
|
122
|
+
python_path = sys.executable or "(unknown)"
|
|
123
|
+
|
|
124
|
+
if os_name == "Linux":
|
|
125
|
+
hints = [
|
|
126
|
+
"pip3 install --user httpx",
|
|
127
|
+
"sudo apt install python3-httpx # Debian/Ubuntu",
|
|
128
|
+
"sudo dnf install python3-httpx # Fedora/RHEL",
|
|
129
|
+
"pip3 install forgexa-cli[daemon]",
|
|
130
|
+
]
|
|
131
|
+
elif os_name == "Darwin":
|
|
132
|
+
hints = [
|
|
133
|
+
"pip3 install httpx",
|
|
134
|
+
"brew install python3 && pip3 install httpx",
|
|
135
|
+
"pip3 install forgexa-cli[daemon]",
|
|
136
|
+
]
|
|
137
|
+
elif os_name == "Windows":
|
|
138
|
+
hints = [
|
|
139
|
+
"pip install httpx",
|
|
140
|
+
"pip install forgexa-cli[daemon]",
|
|
141
|
+
]
|
|
142
|
+
else:
|
|
143
|
+
hints = [
|
|
144
|
+
"pip3 install httpx",
|
|
145
|
+
"pip3 install forgexa-cli[daemon]",
|
|
146
|
+
]
|
|
147
|
+
|
|
148
|
+
hint_lines = "\n".join(f" {h}" for h in hints)
|
|
149
|
+
msg = (
|
|
150
|
+
"\n"
|
|
151
|
+
"┌─────────────────────────────────────────────────────────────────────┐\n"
|
|
152
|
+
"│ Forgexa Daemon: missing required dependency 'httpx' │\n"
|
|
153
|
+
"└─────────────────────────────────────────────────────────────────────┘\n"
|
|
154
|
+
"\n"
|
|
155
|
+
" The daemon requires the 'httpx' HTTP client library but it could\n"
|
|
156
|
+
" not be imported, and automatic installation failed.\n"
|
|
157
|
+
"\n"
|
|
158
|
+
f" Python: {python_path}\n"
|
|
159
|
+
f" Platform: {os_name} ({platform.machine()})\n"
|
|
160
|
+
f" Detail: {detail}\n"
|
|
161
|
+
"\n"
|
|
162
|
+
" Please install it manually with one of these commands:\n"
|
|
163
|
+
"\n"
|
|
164
|
+
f"{hint_lines}\n"
|
|
165
|
+
"\n"
|
|
166
|
+
" Then restart the daemon.\n"
|
|
167
|
+
"─────────────────────────────────────────────────────────────────────\n"
|
|
48
168
|
)
|
|
49
|
-
|
|
169
|
+
print(msg, file=sys.stderr)
|
|
170
|
+
# Machine-readable summary for the desktop app to parse and show as a toast.
|
|
171
|
+
print(f"DAEMON_ERROR: Missing required Python package 'httpx'. {detail}", file=sys.stderr)
|
|
172
|
+
sys.exit(1)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _validate_httpx_imports() -> tuple[bool, str]:
|
|
176
|
+
"""Validate that httpx and its critical transitive deps are importable.
|
|
177
|
+
|
|
178
|
+
A bare ``import httpx`` can succeed even when httpcore is missing,
|
|
179
|
+
because httpx lazily imports its transport layer. We eagerly check
|
|
180
|
+
the full chain so the daemon fails fast with a clear message instead
|
|
181
|
+
of crashing mid-operation when ``httpx.AsyncClient()`` tries to load
|
|
182
|
+
the transport.
|
|
183
|
+
|
|
184
|
+
Returns (ok, missing_module_name).
|
|
185
|
+
"""
|
|
186
|
+
for mod_name in ("httpx", "httpcore"):
|
|
187
|
+
try:
|
|
188
|
+
__import__(mod_name)
|
|
189
|
+
except ImportError:
|
|
190
|
+
return False, mod_name
|
|
191
|
+
return True, ""
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
# Actual import sequence
|
|
195
|
+
_httpx_ok, _httpx_missing = _validate_httpx_imports()
|
|
196
|
+
|
|
197
|
+
if not _httpx_ok:
|
|
198
|
+
# Check cached deps directory (previous auto-install)
|
|
199
|
+
if _HTTPX_DEPS_DIR not in sys.path:
|
|
200
|
+
sys.path.insert(0, _HTTPX_DEPS_DIR)
|
|
201
|
+
_httpx_ok, _httpx_missing = _validate_httpx_imports()
|
|
202
|
+
|
|
203
|
+
if not _httpx_ok:
|
|
204
|
+
# If httpx is present but a sub-dependency (httpcore) is missing,
|
|
205
|
+
# the deps directory has a partial/stale installation. Clear it and
|
|
206
|
+
# purge cached modules so pip does a clean install with all transitive
|
|
207
|
+
# dependencies.
|
|
208
|
+
if _httpx_missing != "httpx":
|
|
209
|
+
shutil.rmtree(_HTTPX_DEPS_DIR, ignore_errors=True)
|
|
210
|
+
for _mod_key in list(sys.modules):
|
|
211
|
+
if _mod_key in ("httpx", "httpcore") or \
|
|
212
|
+
_mod_key.startswith(("httpx.", "httpcore.")):
|
|
213
|
+
del sys.modules[_mod_key]
|
|
214
|
+
|
|
215
|
+
# Attempt auto-install to user-writable deps directory
|
|
216
|
+
_ok, _err = _try_install_httpx(_HTTPX_DEPS_DIR)
|
|
217
|
+
if _ok:
|
|
218
|
+
if _HTTPX_DEPS_DIR not in sys.path:
|
|
219
|
+
sys.path.insert(0, _HTTPX_DEPS_DIR)
|
|
220
|
+
_httpx_ok, _httpx_missing = _validate_httpx_imports()
|
|
221
|
+
if not _httpx_ok:
|
|
222
|
+
_die_missing_httpx(
|
|
223
|
+
f"pip install succeeded but '{_httpx_missing}' still cannot "
|
|
224
|
+
"be imported — check Python version compatibility"
|
|
225
|
+
)
|
|
226
|
+
else:
|
|
227
|
+
_die_missing_httpx(_err)
|
|
228
|
+
|
|
229
|
+
import httpx # noqa: E402 — guaranteed available after validation above
|
|
230
|
+
|
|
231
|
+
del _httpx_ok, _httpx_missing
|
|
50
232
|
|
|
51
233
|
# ── Settings: graceful fallback when running standalone (outside backend package) ──
|
|
52
234
|
try:
|
|
@@ -97,6 +279,10 @@ except (ImportError, ModuleNotFoundError):
|
|
|
97
279
|
def AGENT_TIMEOUT(self) -> int:
|
|
98
280
|
return int(os.environ.get("AGENT_TIMEOUT", "3600"))
|
|
99
281
|
|
|
282
|
+
@property
|
|
283
|
+
def GIT_CLONE_TIMEOUT(self) -> int:
|
|
284
|
+
return int(os.environ.get("GIT_CLONE_TIMEOUT", "600"))
|
|
285
|
+
|
|
100
286
|
@property
|
|
101
287
|
def AGENT_MAX_OUTPUT_SIZE(self) -> int:
|
|
102
288
|
return int(os.environ.get("AGENT_MAX_OUTPUT_SIZE", "100000"))
|
|
@@ -117,6 +303,36 @@ except (ImportError, ModuleNotFoundError):
|
|
|
117
303
|
|
|
118
304
|
settings = _StandaloneSettings() # type: ignore[assignment]
|
|
119
305
|
|
|
306
|
+
# ── Daemon version and client type ────────────────────────────────────────
|
|
307
|
+
# DAEMON_VERSION is the protocol/logic version of the daemon code.
|
|
308
|
+
# Kept in sync with pyproject.toml version via bump-version.sh.
|
|
309
|
+
# CLIENT_TYPE identifies which packaging/distribution this daemon runs in.
|
|
310
|
+
DAEMON_VERSION = "1.5.2"
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def _detect_client_type() -> str:
|
|
314
|
+
"""Auto-detect client type from runtime context.
|
|
315
|
+
|
|
316
|
+
Priority:
|
|
317
|
+
1. FORGEXA_CLIENT_TYPE env var (set by desktop Tauri launcher)
|
|
318
|
+
2. Import context: app.config importable → "server"
|
|
319
|
+
3. Default: "cli" (standalone pip-installed daemon)
|
|
320
|
+
|
|
321
|
+
This allows a single daemon.py source to work correctly regardless
|
|
322
|
+
of deployment context, making the bundle-daemon.sh copy safe.
|
|
323
|
+
"""
|
|
324
|
+
env_type = os.environ.get("FORGEXA_CLIENT_TYPE", "").strip().lower()
|
|
325
|
+
if env_type in ("server", "cli", "desktop"):
|
|
326
|
+
return env_type
|
|
327
|
+
# Server: app.config was successfully imported at module level above
|
|
328
|
+
if "app.config" in sys.modules:
|
|
329
|
+
return "server"
|
|
330
|
+
# Default: standalone execution = CLI
|
|
331
|
+
return "cli"
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
_CLIENT_TYPE = _detect_client_type()
|
|
335
|
+
|
|
120
336
|
# ── Logging — self-managed file handler ────────────────────────────────
|
|
121
337
|
# The daemon configures its own FileHandler so logs are written to
|
|
122
338
|
# ~/.forgexa/daemon/daemon.log regardless of how the daemon was launched
|
|
@@ -331,6 +547,31 @@ class TaskResult:
|
|
|
331
547
|
git: dict = field(default_factory=dict)
|
|
332
548
|
|
|
333
549
|
|
|
550
|
+
# ── Type-aware analysis outputs (inline fallback for standalone daemons) ──
|
|
551
|
+
# Mirrors type_workflow_profiles.py — used when import is unavailable (CLI/Desktop).
|
|
552
|
+
_ANALYSIS_OUTPUTS_BY_TYPE: dict[str, list[str]] = {
|
|
553
|
+
"feature": ["PRD.md", "SDD.md", "TASKS.md", "analysis.json", "test-intent.json"],
|
|
554
|
+
"bugfix": ["diagnosis.md", "TASKS.md", "analysis.json", "test-intent.json"],
|
|
555
|
+
"refactor": ["refactor-plan.md", "TASKS.md", "analysis.json"],
|
|
556
|
+
"documentation": ["outline.md", "analysis.json"],
|
|
557
|
+
"improvement": ["improvement-spec.md", "TASKS.md", "analysis.json", "test-intent.json"],
|
|
558
|
+
"task": ["task-plan.md", "analysis.json"],
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
|
|
562
|
+
def _get_analysis_outputs_for_type(req_type: str) -> list[str]:
|
|
563
|
+
"""Get expected analysis output files for a requirement type.
|
|
564
|
+
|
|
565
|
+
Tries to use type_workflow_profiles (available in backend context),
|
|
566
|
+
falls back to inline mapping for standalone daemon execution.
|
|
567
|
+
"""
|
|
568
|
+
try:
|
|
569
|
+
from app.services.type_workflow_profiles import get_profile
|
|
570
|
+
return list(get_profile(req_type).analysis_outputs)
|
|
571
|
+
except Exception:
|
|
572
|
+
return _ANALYSIS_OUTPUTS_BY_TYPE.get(req_type, _ANALYSIS_OUTPUTS_BY_TYPE["feature"])
|
|
573
|
+
|
|
574
|
+
|
|
334
575
|
# ── Agent Discovery ──
|
|
335
576
|
|
|
336
577
|
|
|
@@ -848,7 +1089,10 @@ class WorkspaceManager:
|
|
|
848
1089
|
|
|
849
1090
|
# Ensure _main repo is present and up-to-date
|
|
850
1091
|
if not main_repo.exists():
|
|
851
|
-
await self._git(
|
|
1092
|
+
await self._git(
|
|
1093
|
+
"clone", "--single-branch", "--no-tags",
|
|
1094
|
+
repo_url, str(main_repo), timeout=settings.GIT_CLONE_TIMEOUT, project_key=project_key,
|
|
1095
|
+
)
|
|
852
1096
|
else:
|
|
853
1097
|
await self._git("fetch", "--all", cwd=main_repo, timeout=300, project_key=project_key)
|
|
854
1098
|
|
|
@@ -936,7 +1180,10 @@ class WorkspaceManager:
|
|
|
936
1180
|
)
|
|
937
1181
|
except Exception:
|
|
938
1182
|
ws_path.mkdir(parents=True, exist_ok=True)
|
|
939
|
-
await self._git(
|
|
1183
|
+
await self._git(
|
|
1184
|
+
"clone", "--single-branch", "--no-tags",
|
|
1185
|
+
repo_url, str(ws_path), timeout=settings.GIT_CLONE_TIMEOUT, project_key=project_key,
|
|
1186
|
+
)
|
|
940
1187
|
# Ensure we're on the correct branch after clone
|
|
941
1188
|
try:
|
|
942
1189
|
await self._git("checkout", "-B", branch_name, cwd=ws_path)
|
|
@@ -958,7 +1205,10 @@ class WorkspaceManager:
|
|
|
958
1205
|
except Exception:
|
|
959
1206
|
# Fallback to simple clone
|
|
960
1207
|
ws_path.mkdir(parents=True, exist_ok=True)
|
|
961
|
-
await self._git(
|
|
1208
|
+
await self._git(
|
|
1209
|
+
"clone", "--single-branch", "--no-tags",
|
|
1210
|
+
repo_url, str(ws_path), timeout=settings.GIT_CLONE_TIMEOUT, project_key=project_key,
|
|
1211
|
+
)
|
|
962
1212
|
# Ensure we're on the correct branch after clone
|
|
963
1213
|
try:
|
|
964
1214
|
await self._git("checkout", "-B", branch_name, cwd=ws_path)
|
|
@@ -1066,15 +1316,11 @@ class ProcessManager:
|
|
|
1066
1316
|
"usage limit",
|
|
1067
1317
|
"rate limit",
|
|
1068
1318
|
"rate_limit",
|
|
1069
|
-
"429",
|
|
1070
1319
|
"quota exceeded",
|
|
1071
1320
|
"too many requests",
|
|
1072
1321
|
"overloaded",
|
|
1073
|
-
"capacity",
|
|
1074
|
-
"try again",
|
|
1075
|
-
"credit",
|
|
1076
1322
|
"insufficient_quota",
|
|
1077
|
-
"billing",
|
|
1323
|
+
"billing hard limit",
|
|
1078
1324
|
]
|
|
1079
1325
|
|
|
1080
1326
|
# Patterns indicating the agent's API is unreachable/misconfigured —
|
|
@@ -1086,9 +1332,11 @@ class ProcessManager:
|
|
|
1086
1332
|
"connection refused",
|
|
1087
1333
|
"connection reset",
|
|
1088
1334
|
"connection timed out",
|
|
1335
|
+
"connection error",
|
|
1089
1336
|
"name or service not known",
|
|
1090
1337
|
"no such host",
|
|
1091
1338
|
"network is unreachable",
|
|
1339
|
+
"api error",
|
|
1092
1340
|
]
|
|
1093
1341
|
|
|
1094
1342
|
def __init__(self):
|
|
@@ -1139,8 +1387,12 @@ class ProcessManager:
|
|
|
1139
1387
|
elif isinstance(err, str):
|
|
1140
1388
|
error_messages.append(err)
|
|
1141
1389
|
elif ev_type == "result":
|
|
1142
|
-
|
|
1143
|
-
|
|
1390
|
+
if data.get("is_error"):
|
|
1391
|
+
err_text = str(data.get("result", "") or data.get("error", "") or "result marked as error")
|
|
1392
|
+
error_messages.append(err_text)
|
|
1393
|
+
else:
|
|
1394
|
+
has_result = True
|
|
1395
|
+
has_meaningful_content = True
|
|
1144
1396
|
elif ev_type == "error":
|
|
1145
1397
|
msg = data.get("message", "")
|
|
1146
1398
|
if msg:
|
|
@@ -1182,13 +1434,33 @@ class ProcessManager:
|
|
|
1182
1434
|
|
|
1183
1435
|
Returns True for rate/quota limits AND API unavailability errors,
|
|
1184
1436
|
since a different agent (using a different API backend) may succeed.
|
|
1437
|
+
|
|
1438
|
+
IMPORTANT: Only checks stderr and error message. When exit code is
|
|
1439
|
+
non-zero, also checks the tail of stdout (last 3000 chars) since the
|
|
1440
|
+
error is likely at the end. When exit code is 0 (agent reported
|
|
1441
|
+
success but _detect_agent_output_failure set status to failed), do
|
|
1442
|
+
NOT scan stdout — it contains the agent's work output (configs, code)
|
|
1443
|
+
which naturally has terms like "rate_limit", "API_RATE_LIMIT_PER_MINUTE"
|
|
1444
|
+
that trigger false positives.
|
|
1185
1445
|
"""
|
|
1186
1446
|
if result.status == "success":
|
|
1187
1447
|
return False
|
|
1188
|
-
|
|
1448
|
+
# When exit code is 0, _detect_agent_output_failure already checked
|
|
1449
|
+
# stderr+error for rate-limit patterns. Don't re-scan stdout here.
|
|
1450
|
+
if result.exit_code == 0:
|
|
1451
|
+
error_text = (
|
|
1452
|
+
(result.stderr or "")
|
|
1453
|
+
+ "\n" + (result.error or "")
|
|
1454
|
+
).lower()
|
|
1455
|
+
else:
|
|
1456
|
+
error_text = (
|
|
1457
|
+
(result.stderr or "")
|
|
1458
|
+
+ "\n" + (result.error or "")
|
|
1459
|
+
+ "\n" + (result.stdout or "")[-3000:]
|
|
1460
|
+
).lower()
|
|
1189
1461
|
return (
|
|
1190
|
-
any(p in
|
|
1191
|
-
or any(p in
|
|
1462
|
+
any(p in error_text for p in ProcessManager.RATE_LIMIT_PATTERNS)
|
|
1463
|
+
or any(p in error_text for p in ProcessManager.AGENT_UNAVAILABLE_PATTERNS)
|
|
1192
1464
|
)
|
|
1193
1465
|
|
|
1194
1466
|
@staticmethod
|
|
@@ -1205,8 +1477,16 @@ class ProcessManager:
|
|
|
1205
1477
|
if result.status != "success":
|
|
1206
1478
|
return None
|
|
1207
1479
|
|
|
1208
|
-
|
|
1209
|
-
|
|
1480
|
+
# For exit-code-0 (success) cases, only scan stderr and the error field
|
|
1481
|
+
# for rate-limit / unavailability patterns. Stdout contains the agent's
|
|
1482
|
+
# actual task output (code, configs, analysis docs) which may legitimately
|
|
1483
|
+
# contain substrings like "rate_limit", "429", "quota", etc. — e.g. writing
|
|
1484
|
+
# a config file with API_RATE_LIMIT_PER_MINUTE=1000 would previously trigger
|
|
1485
|
+
# a false "quota exhaustion" failure even though the agent succeeded.
|
|
1486
|
+
# stdout[-N:] is only safe to scan when the agent already failed (exit != 0),
|
|
1487
|
+
# which is handled by is_rate_limited() called at the orchestrator level.
|
|
1488
|
+
error_only_channels = (result.stderr or "") + "\n" + (result.error or "")
|
|
1489
|
+
pattern_failure = ProcessManager._has_failure_pattern(error_only_channels)
|
|
1210
1490
|
if pattern_failure:
|
|
1211
1491
|
return pattern_failure
|
|
1212
1492
|
|
|
@@ -1320,18 +1600,23 @@ class ProcessManager:
|
|
|
1320
1600
|
return normalized
|
|
1321
1601
|
|
|
1322
1602
|
def _required_deliverable_paths(self, task: TaskInfo) -> set[str]:
|
|
1323
|
-
|
|
1603
|
+
# For analysis nodes, deliverables live in analysis_output_dir (docs/requirements/...)
|
|
1604
|
+
# For other nodes, use output_dir (docs/implements/...)
|
|
1605
|
+
if task.node_type == "analysis":
|
|
1606
|
+
output_dir = str(
|
|
1607
|
+
(task.input_data or {}).get("analysis_output_dir", "")
|
|
1608
|
+
or (task.input_data or {}).get("output_dir", "")
|
|
1609
|
+
or ""
|
|
1610
|
+
)
|
|
1611
|
+
else:
|
|
1612
|
+
output_dir = str((task.input_data or {}).get("output_dir", "") or "")
|
|
1324
1613
|
output_dir = output_dir.replace("\\", "/").lstrip("./").rstrip("/")
|
|
1325
1614
|
if not output_dir:
|
|
1326
1615
|
return set()
|
|
1327
1616
|
|
|
1328
1617
|
if task.node_type == "analysis":
|
|
1329
1618
|
req_type = (task.input_data or {}).get("requirement_type", "feature")
|
|
1330
|
-
|
|
1331
|
-
from app.services.type_workflow_profiles import get_profile
|
|
1332
|
-
required_files = list(get_profile(req_type).analysis_outputs)
|
|
1333
|
-
except Exception:
|
|
1334
|
-
required_files = ["PRD.md", "SDD.md", "TASKS.md", "analysis.json", "test-intent.json"]
|
|
1619
|
+
required_files = _get_analysis_outputs_for_type(req_type)
|
|
1335
1620
|
elif task.node_type == "design":
|
|
1336
1621
|
required_files = ["design.md"]
|
|
1337
1622
|
else:
|
|
@@ -1342,7 +1627,8 @@ class ProcessManager:
|
|
|
1342
1627
|
def _has_required_deliverable_updates(self, task: TaskInfo, *path_lists: list[str] | None) -> bool:
|
|
1343
1628
|
required_paths = self._required_deliverable_paths(task)
|
|
1344
1629
|
if not required_paths:
|
|
1345
|
-
|
|
1630
|
+
# Cannot determine required deliverables — skip check (don't fail)
|
|
1631
|
+
return True
|
|
1346
1632
|
|
|
1347
1633
|
changed_paths: set[str] = set()
|
|
1348
1634
|
for paths in path_lists:
|
|
@@ -1502,7 +1788,7 @@ class ProcessManager:
|
|
|
1502
1788
|
cmd = [
|
|
1503
1789
|
agent.command,
|
|
1504
1790
|
"-p",
|
|
1505
|
-
"--output-format", "json",
|
|
1791
|
+
"--output-format", "stream-json",
|
|
1506
1792
|
"--verbose",
|
|
1507
1793
|
"--dangerously-skip-permissions",
|
|
1508
1794
|
]
|
|
@@ -1584,7 +1870,13 @@ class ProcessManager:
|
|
|
1584
1870
|
on_chunk: Any = None,
|
|
1585
1871
|
) -> TaskResult:
|
|
1586
1872
|
"""Run OpenCode CLI in non-interactive mode."""
|
|
1587
|
-
cmd = [
|
|
1873
|
+
cmd = [
|
|
1874
|
+
agent.command, "run",
|
|
1875
|
+
"--format", "json",
|
|
1876
|
+
"--dangerously-skip-permissions",
|
|
1877
|
+
"--cwd", str(cwd),
|
|
1878
|
+
prompt,
|
|
1879
|
+
]
|
|
1588
1880
|
result = await self._run_cli(cmd, cwd, timeout, task_id, on_chunk=on_chunk)
|
|
1589
1881
|
parsed_metrics = self._parse_agent_jsonl_output(result.stdout)
|
|
1590
1882
|
result.metrics.update(parsed_metrics)
|
|
@@ -1706,6 +1998,9 @@ class ProcessManager:
|
|
|
1706
1998
|
data = json.loads(stdout.strip())
|
|
1707
1999
|
if isinstance(data, dict):
|
|
1708
2000
|
parsed.append(data)
|
|
2001
|
+
elif isinstance(data, list):
|
|
2002
|
+
# Handle JSON array (from --output-format json)
|
|
2003
|
+
parsed.extend(d for d in data if isinstance(d, dict))
|
|
1709
2004
|
except (json.JSONDecodeError, ValueError):
|
|
1710
2005
|
pass
|
|
1711
2006
|
|
|
@@ -2182,6 +2477,8 @@ class HeartbeatService:
|
|
|
2182
2477
|
"available_agents": self._agents,
|
|
2183
2478
|
"system_metrics": self._collect_system_metrics(),
|
|
2184
2479
|
"os_info": get_os_info(),
|
|
2480
|
+
"daemon_version": DAEMON_VERSION,
|
|
2481
|
+
"client_type": _CLIENT_TYPE,
|
|
2185
2482
|
},
|
|
2186
2483
|
timeout=10,
|
|
2187
2484
|
)
|
|
@@ -2210,6 +2507,76 @@ class HeartbeatService:
|
|
|
2210
2507
|
return {}
|
|
2211
2508
|
|
|
2212
2509
|
|
|
2510
|
+
# ── Log Uploader ──
|
|
2511
|
+
|
|
2512
|
+
|
|
2513
|
+
class LogUploader:
|
|
2514
|
+
"""Periodically uploads daemon log tail to the server for remote viewing."""
|
|
2515
|
+
|
|
2516
|
+
LOG_UPLOAD_INTERVAL = 300 # Upload every 5 minutes
|
|
2517
|
+
LOG_TAIL_LINES = 500 # Last N lines to upload
|
|
2518
|
+
|
|
2519
|
+
def __init__(
|
|
2520
|
+
self,
|
|
2521
|
+
client: httpx.AsyncClient,
|
|
2522
|
+
server_url: str,
|
|
2523
|
+
runtime_id: str,
|
|
2524
|
+
):
|
|
2525
|
+
self.client = client
|
|
2526
|
+
self.server_url = server_url.rstrip("/")
|
|
2527
|
+
self.runtime_id = runtime_id
|
|
2528
|
+
self._task: asyncio.Task | None = None
|
|
2529
|
+
|
|
2530
|
+
async def start(self):
|
|
2531
|
+
self._task = asyncio.create_task(self._loop())
|
|
2532
|
+
|
|
2533
|
+
async def stop(self):
|
|
2534
|
+
if self._task:
|
|
2535
|
+
self._task.cancel()
|
|
2536
|
+
try:
|
|
2537
|
+
await self._task
|
|
2538
|
+
except asyncio.CancelledError:
|
|
2539
|
+
pass
|
|
2540
|
+
|
|
2541
|
+
async def _loop(self):
|
|
2542
|
+
# Initial upload after 30s delay (let daemon stabilize first)
|
|
2543
|
+
await asyncio.sleep(30)
|
|
2544
|
+
while True:
|
|
2545
|
+
try:
|
|
2546
|
+
await self._upload()
|
|
2547
|
+
except asyncio.CancelledError:
|
|
2548
|
+
raise
|
|
2549
|
+
except Exception as e:
|
|
2550
|
+
logger.warning("Log upload error: %s", e)
|
|
2551
|
+
await asyncio.sleep(self.LOG_UPLOAD_INTERVAL)
|
|
2552
|
+
|
|
2553
|
+
async def _upload(self):
|
|
2554
|
+
"""Read daemon log tail and upload to server."""
|
|
2555
|
+
try:
|
|
2556
|
+
if not DAEMON_LOG_PATH.exists():
|
|
2557
|
+
return
|
|
2558
|
+
# Read last N lines efficiently
|
|
2559
|
+
with open(DAEMON_LOG_PATH, "rb") as f:
|
|
2560
|
+
# Seek from end to find last N lines
|
|
2561
|
+
f.seek(0, 2)
|
|
2562
|
+
file_size = f.tell()
|
|
2563
|
+
# Read at most 100KB from end
|
|
2564
|
+
read_size = min(file_size, 100 * 1024)
|
|
2565
|
+
f.seek(file_size - read_size)
|
|
2566
|
+
content = f.read().decode("utf-8", errors="replace")
|
|
2567
|
+
|
|
2568
|
+
# Take last N lines
|
|
2569
|
+
lines = content.split("\n")
|
|
2570
|
+
tail = "\n".join(lines[-self.LOG_TAIL_LINES:])
|
|
2571
|
+
|
|
2572
|
+
await self.client.post(
|
|
2573
|
+
f"{self.server_url}/api/v1/runtimes/{self.runtime_id}/logs",
|
|
2574
|
+
json={"log_tail": tail, "log_lines": self.LOG_TAIL_LINES},
|
|
2575
|
+
timeout=15,
|
|
2576
|
+
)
|
|
2577
|
+
except Exception as e:
|
|
2578
|
+
logger.warning("Failed to upload daemon log: %s", e)
|
|
2579
|
+
|
|
2213
2580
|
# ── Task Poller ──
|
|
2214
2581
|
|
|
2215
2582
|
|
|
@@ -2296,6 +2663,7 @@ class ServerConnection:
|
|
|
2296
2663
|
self.heartbeat: HeartbeatService | None = None
|
|
2297
2664
|
self.poller: TaskPoller | None = None
|
|
2298
2665
|
self.reporter: ProgressReporter | None = None
|
|
2666
|
+
self.log_uploader: LogUploader | None = None
|
|
2299
2667
|
self._auth_failures = 0 # Consecutive auth failure count
|
|
2300
2668
|
self._max_auth_failures = 3 # Trigger re-registration after this many
|
|
2301
2669
|
# Short label for logging
|
|
@@ -2338,6 +2706,8 @@ class ServerConnection:
|
|
|
2338
2706
|
self.poller.runtime_id = self.runtime_id
|
|
2339
2707
|
if self.reporter and self.runtime_id:
|
|
2340
2708
|
self.reporter.runtime_id = self.runtime_id
|
|
2709
|
+
if self.log_uploader and self.runtime_id:
|
|
2710
|
+
self.log_uploader.runtime_id = self.runtime_id
|
|
2341
2711
|
self._auth_failures = 0
|
|
2342
2712
|
logger.info("[%s] Re-registered successfully after token refresh", self.label)
|
|
2343
2713
|
except Exception as e:
|
|
@@ -2364,6 +2734,8 @@ class ServerConnection:
|
|
|
2364
2734
|
"hardware_id": self.hardware_id,
|
|
2365
2735
|
"device_name": platform.node(),
|
|
2366
2736
|
"os_info": get_os_info(),
|
|
2737
|
+
"daemon_version": DAEMON_VERSION,
|
|
2738
|
+
"client_type": _CLIENT_TYPE,
|
|
2367
2739
|
"available_agents": agent_dicts,
|
|
2368
2740
|
"max_concurrent_tasks": max_concurrent,
|
|
2369
2741
|
"capabilities": {
|
|
@@ -2434,15 +2806,22 @@ class ServerConnection:
|
|
|
2434
2806
|
self.reporter = ProgressReporter(
|
|
2435
2807
|
self.client, self.server_url, self.runtime_id,
|
|
2436
2808
|
)
|
|
2809
|
+
self.log_uploader = LogUploader(
|
|
2810
|
+
self.client, self.server_url, self.runtime_id,
|
|
2811
|
+
)
|
|
2437
2812
|
|
|
2438
2813
|
async def start_heartbeat(self):
|
|
2439
2814
|
if self.heartbeat:
|
|
2440
2815
|
await self.heartbeat.start()
|
|
2816
|
+
if self.log_uploader:
|
|
2817
|
+
await self.log_uploader.start()
|
|
2441
2818
|
|
|
2442
2819
|
async def stop(self):
|
|
2443
2820
|
"""Stop heartbeat and unregister."""
|
|
2444
2821
|
if self.heartbeat:
|
|
2445
2822
|
await self.heartbeat.stop()
|
|
2823
|
+
if self.log_uploader:
|
|
2824
|
+
await self.log_uploader.stop()
|
|
2446
2825
|
if self.runtime_id:
|
|
2447
2826
|
try:
|
|
2448
2827
|
# Use deregister endpoint (no admin required) instead of DELETE
|
|
@@ -2874,15 +3253,15 @@ class RuntimeDaemon:
|
|
|
2874
3253
|
_line_buffer.extend(lines)
|
|
2875
3254
|
|
|
2876
3255
|
async def _progress_ticker():
|
|
2877
|
-
"""Flush buffered output lines + update progress % every
|
|
3256
|
+
"""Flush buffered output lines + update progress % every 5 s."""
|
|
2878
3257
|
import math as _math
|
|
2879
3258
|
tick = 0
|
|
2880
3259
|
while not progress_stop.is_set():
|
|
2881
|
-
await asyncio.sleep(
|
|
3260
|
+
await asyncio.sleep(5)
|
|
2882
3261
|
if progress_stop.is_set():
|
|
2883
3262
|
break
|
|
2884
3263
|
tick += 1
|
|
2885
|
-
pct = min(int(10 + 80 * (1 - 1 / (1 + tick /
|
|
3264
|
+
pct = min(int(10 + 80 * (1 - 1 / (1 + tick / 16))), 90)
|
|
2886
3265
|
pid = self.process_manager.active_processes.get(task.task_id)
|
|
2887
3266
|
step = "running_agent"
|
|
2888
3267
|
if pid:
|
|
@@ -2919,7 +3298,26 @@ class RuntimeDaemon:
|
|
|
2919
3298
|
tried_agents.add(agent.agent_id)
|
|
2920
3299
|
|
|
2921
3300
|
# ── Agent fallback: if agent hit rate limit or API is unavailable, try next agent ──
|
|
3301
|
+
# Guard: if the agent already produced file changes in the workspace, it DID
|
|
3302
|
+
# meaningful work — don't trigger fallback even if it crashed after completing.
|
|
3303
|
+
# Let the recovery logic (step 4.1) handle non-zero exit with committed work.
|
|
3304
|
+
_skip_fallback = False
|
|
2922
3305
|
if self.process_manager.is_rate_limited(result):
|
|
3306
|
+
_pre_fallback_git = await self.process_manager._collect_git_info(workspace_path)
|
|
3307
|
+
_pre_fallback_committed = await self.process_manager._collect_git_info_vs_parent(workspace_path)
|
|
3308
|
+
has_workspace_changes = (
|
|
3309
|
+
bool(_pre_fallback_git.get("files_changed"))
|
|
3310
|
+
or bool(_pre_fallback_committed.get("files_changed"))
|
|
3311
|
+
)
|
|
3312
|
+
if has_workspace_changes:
|
|
3313
|
+
logger.info(
|
|
3314
|
+
"Agent '%s' exited non-zero for task %s but workspace has changes — "
|
|
3315
|
+
"skipping fallback, proceeding to recovery",
|
|
3316
|
+
agent.agent_id, task.task_id,
|
|
3317
|
+
)
|
|
3318
|
+
_skip_fallback = True
|
|
3319
|
+
|
|
3320
|
+
if self.process_manager.is_rate_limited(result) and not _skip_fallback:
|
|
2923
3321
|
logger.warning(
|
|
2924
3322
|
"Agent '%s' unavailable/rate-limited for task %s, attempting fallback",
|
|
2925
3323
|
agent.agent_id, task.task_id,
|
|
@@ -2949,11 +3347,11 @@ class RuntimeDaemon:
|
|
|
2949
3347
|
async def _progress_ticker2():
|
|
2950
3348
|
tick = 0
|
|
2951
3349
|
while not progress_stop2.is_set():
|
|
2952
|
-
await asyncio.sleep(
|
|
3350
|
+
await asyncio.sleep(5)
|
|
2953
3351
|
if progress_stop2.is_set():
|
|
2954
3352
|
break
|
|
2955
3353
|
tick += 1
|
|
2956
|
-
pct = min(int(10 + 80 * (1 - 1 / (1 + tick /
|
|
3354
|
+
pct = min(int(10 + 80 * (1 - 1 / (1 + tick / 16))), 90)
|
|
2957
3355
|
pid = self.process_manager.active_processes.get(task.task_id)
|
|
2958
3356
|
step = f"running_agent:{agent.agent_id}"
|
|
2959
3357
|
if pid:
|
|
@@ -3066,13 +3464,33 @@ class RuntimeDaemon:
|
|
|
3066
3464
|
# Existing files from a prior iteration are not sufficient evidence.
|
|
3067
3465
|
if result.status == "success" and task.node_type in ("analysis", "design"):
|
|
3068
3466
|
committed_git = await self.process_manager._collect_git_info_vs_parent(workspace_path)
|
|
3069
|
-
|
|
3467
|
+
git_check_passed = self.process_manager._has_required_deliverable_updates(
|
|
3070
3468
|
task,
|
|
3071
3469
|
pre_commit_git.get("files_changed"),
|
|
3072
3470
|
committed_git.get("files_changed"),
|
|
3073
3471
|
result.files_changed,
|
|
3074
3472
|
(result.git or {}).get("files_changed"),
|
|
3075
|
-
)
|
|
3473
|
+
)
|
|
3474
|
+
# Fallback: if git-based check fails (e.g., agent auto-committed and
|
|
3475
|
+
# merge-base detection failed), verify files physically exist on disk.
|
|
3476
|
+
# This prevents false failures when git state is unusual but files
|
|
3477
|
+
# are actually present.
|
|
3478
|
+
if not git_check_passed:
|
|
3479
|
+
required_paths = self.process_manager._required_deliverable_paths(task)
|
|
3480
|
+
if required_paths:
|
|
3481
|
+
files_exist = all(
|
|
3482
|
+
(workspace_path / p).exists() and (workspace_path / p).stat().st_size > 0
|
|
3483
|
+
for p in required_paths
|
|
3484
|
+
)
|
|
3485
|
+
if files_exist:
|
|
3486
|
+
logger.info(
|
|
3487
|
+
"Task %s (%s): git diff did not show deliverables but all %d "
|
|
3488
|
+
"files exist on disk — accepting as success",
|
|
3489
|
+
task.task_id, task.node_type, len(required_paths),
|
|
3490
|
+
)
|
|
3491
|
+
git_check_passed = True
|
|
3492
|
+
|
|
3493
|
+
if not git_check_passed:
|
|
3076
3494
|
logger.warning(
|
|
3077
3495
|
"Task %s (%s) reported success but did not update required deliverables",
|
|
3078
3496
|
task.task_id, task.node_type,
|
|
@@ -3099,6 +3517,16 @@ class RuntimeDaemon:
|
|
|
3099
3517
|
if commit_result:
|
|
3100
3518
|
# Propagate push/commit errors in metrics so they're visible
|
|
3101
3519
|
result.metrics.update(commit_result)
|
|
3520
|
+
# Push failure is a real problem for downstream nodes — mark
|
|
3521
|
+
# as failed so the orchestrator can retry (transient network).
|
|
3522
|
+
if commit_result.get("push_error"):
|
|
3523
|
+
push_err = commit_result["push_error"]
|
|
3524
|
+
logger.error(
|
|
3525
|
+
"Task %s: push failed, marking as failed so retry can attempt push again: %s",
|
|
3526
|
+
task.task_id, push_err,
|
|
3527
|
+
)
|
|
3528
|
+
result.status = "failed"
|
|
3529
|
+
result.error = f"Git push failed: {push_err}"
|
|
3102
3530
|
# Re-collect git info after commit (compare with parent)
|
|
3103
3531
|
post_commit_git = await self.process_manager._collect_git_info_vs_parent(workspace_path)
|
|
3104
3532
|
# Merge: use the pre-commit file list if post-commit is empty
|
|
@@ -3201,15 +3629,13 @@ class RuntimeDaemon:
|
|
|
3201
3629
|
|
|
3202
3630
|
if node_type == "analysis":
|
|
3203
3631
|
# Use type profile to determine required analysis outputs
|
|
3204
|
-
|
|
3205
|
-
from app.services.type_workflow_profiles import get_profile
|
|
3206
|
-
profile = get_profile(req_type)
|
|
3207
|
-
required_files = profile.analysis_outputs
|
|
3208
|
-
except Exception:
|
|
3209
|
-
# Fallback to full set if profile import fails
|
|
3210
|
-
required_files = ["PRD.md", "SDD.md", "TASKS.md", "analysis.json", "test-intent.json"]
|
|
3632
|
+
required_files = _get_analysis_outputs_for_type(req_type)
|
|
3211
3633
|
|
|
3212
|
-
|
|
3634
|
+
# Analysis deliverables live in analysis_output_dir (docs/requirements/...)
|
|
3635
|
+
doc_dir = (
|
|
3636
|
+
(task.input_data or {}).get("analysis_output_dir", "")
|
|
3637
|
+
or (task.input_data or {}).get("output_dir", "")
|
|
3638
|
+
)
|
|
3213
3639
|
if doc_dir:
|
|
3214
3640
|
base = workspace_path / doc_dir
|
|
3215
3641
|
else:
|
|
@@ -3444,20 +3870,25 @@ class RuntimeDaemon:
|
|
|
3444
3870
|
always receives the file contents via the completion report and gate
|
|
3445
3871
|
reviewers can see the analysis documents immediately.
|
|
3446
3872
|
"""
|
|
3447
|
-
|
|
3873
|
+
# Analysis deliverables live in analysis_output_dir (docs/requirements/...)
|
|
3874
|
+
doc_dir = (
|
|
3875
|
+
(task.input_data or {}).get("analysis_output_dir", "")
|
|
3876
|
+
or (task.input_data or {}).get("output_dir", "")
|
|
3877
|
+
)
|
|
3448
3878
|
if not doc_dir:
|
|
3449
3879
|
return
|
|
3450
3880
|
|
|
3451
3881
|
base = workspace_path / doc_dir.lstrip("./")
|
|
3452
|
-
|
|
3453
|
-
|
|
3882
|
+
req_type = (task.input_data or {}).get("requirement_type", "feature")
|
|
3883
|
+
_ANALYSIS_FILES = _get_analysis_outputs_for_type(req_type)
|
|
3884
|
+
existing_artifact_paths = {a.get("path", "").replace("\\", "/") for a in result.artifacts}
|
|
3454
3885
|
|
|
3455
3886
|
for fname in _ANALYSIS_FILES:
|
|
3456
3887
|
fpath = base / fname
|
|
3457
3888
|
if not fpath.exists() or fpath.stat().st_size == 0:
|
|
3458
3889
|
continue
|
|
3459
3890
|
try:
|
|
3460
|
-
rel_path = str(fpath.relative_to(workspace_path))
|
|
3891
|
+
rel_path = str(fpath.relative_to(workspace_path)).replace("\\", "/")
|
|
3461
3892
|
if rel_path in existing_artifact_paths:
|
|
3462
3893
|
continue # already attached
|
|
3463
3894
|
content = fpath.read_text(encoding="utf-8", errors="replace")
|
|
@@ -3487,13 +3918,13 @@ class RuntimeDaemon:
|
|
|
3487
3918
|
return
|
|
3488
3919
|
|
|
3489
3920
|
base = workspace_path / doc_dir.lstrip("./")
|
|
3490
|
-
existing_artifact_paths = {a.get("path", "") for a in result.artifacts}
|
|
3921
|
+
existing_artifact_paths = {a.get("path", "").replace("\\", "/") for a in result.artifacts}
|
|
3491
3922
|
|
|
3492
3923
|
design_path = base / "design.md"
|
|
3493
3924
|
if not design_path.exists() or design_path.stat().st_size == 0:
|
|
3494
3925
|
return
|
|
3495
3926
|
try:
|
|
3496
|
-
rel_path = str(design_path.relative_to(workspace_path))
|
|
3927
|
+
rel_path = str(design_path.relative_to(workspace_path)).replace("\\", "/")
|
|
3497
3928
|
if rel_path in existing_artifact_paths:
|
|
3498
3929
|
return
|
|
3499
3930
|
content = design_path.read_text(encoding="utf-8", errors="replace")
|
|
@@ -3555,6 +3986,8 @@ class RuntimeDaemon:
|
|
|
3555
3986
|
or task.input_data.get("title")
|
|
3556
3987
|
or ""
|
|
3557
3988
|
)
|
|
3989
|
+
if not isinstance(wi_title, str):
|
|
3990
|
+
wi_title = str(wi_title)
|
|
3558
3991
|
req_key = task.requirement_key or task.work_item.get("requirement_key") or ""
|
|
3559
3992
|
if req_key and wi_title:
|
|
3560
3993
|
display_title = f"{req_key}: {wi_title}"
|
|
@@ -3565,11 +3998,15 @@ class RuntimeDaemon:
|
|
|
3565
3998
|
else:
|
|
3566
3999
|
display_title = task.task_id
|
|
3567
4000
|
|
|
3568
|
-
|
|
3569
|
-
|
|
3570
|
-
|
|
3571
|
-
|
|
3572
|
-
|
|
4001
|
+
try:
|
|
4002
|
+
commit_msg = await self._build_auto_commit_message(
|
|
4003
|
+
display_title, task.task_id, task.node_type,
|
|
4004
|
+
task.agent_type, change_summary,
|
|
4005
|
+
workspace_path=workspace_path,
|
|
4006
|
+
)
|
|
4007
|
+
except Exception as msg_err:
|
|
4008
|
+
logger.warning("Failed to build rich commit message: %s — using fallback", msg_err)
|
|
4009
|
+
commit_msg = f"{task.node_type}({task.requirement_key or task.task_id}): {display_title}"
|
|
3573
4010
|
proc = await asyncio.create_subprocess_exec(
|
|
3574
4011
|
"git", "commit", "-m", commit_msg,
|
|
3575
4012
|
cwd=str(workspace_path),
|
|
@@ -3766,7 +4203,22 @@ class RuntimeDaemon:
|
|
|
3766
4203
|
lines: list[str] = []
|
|
3767
4204
|
|
|
3768
4205
|
# Summary — word-wrap at 78 chars
|
|
3769
|
-
|
|
4206
|
+
raw_summary = data.get("summary")
|
|
4207
|
+
if isinstance(raw_summary, dict):
|
|
4208
|
+
# Some agents produce summary as a structured object; extract description
|
|
4209
|
+
summary = (
|
|
4210
|
+
raw_summary.get("description")
|
|
4211
|
+
or raw_summary.get("title")
|
|
4212
|
+
or raw_summary.get("summary")
|
|
4213
|
+
or ""
|
|
4214
|
+
)
|
|
4215
|
+
if not isinstance(summary, str):
|
|
4216
|
+
summary = str(summary) if summary else ""
|
|
4217
|
+
elif isinstance(raw_summary, str):
|
|
4218
|
+
summary = raw_summary
|
|
4219
|
+
else:
|
|
4220
|
+
summary = str(raw_summary) if raw_summary else ""
|
|
4221
|
+
summary = summary.strip()
|
|
3770
4222
|
if summary:
|
|
3771
4223
|
words = summary.split()
|
|
3772
4224
|
current = ""
|
|
@@ -4,17 +4,20 @@ Forgexa CLI — command-line client for the Forgexa platform.
|
|
|
4
4
|
A lightweight, standalone CLI that communicates with the Forgexa server via REST API.
|
|
5
5
|
Zero external dependencies — uses only Python stdlib.
|
|
6
6
|
|
|
7
|
-
Configuration:
|
|
8
|
-
|
|
9
|
-
|
|
7
|
+
Configuration (in priority order):
|
|
8
|
+
1. --server-url flag Per-command override
|
|
9
|
+
2. FORGEXA_SERVER_URL env var Session-level override
|
|
10
|
+
3. ~/.forgexa/config Saved via `forgexa login --server <url>`
|
|
11
|
+
4. Build default https://api.forgexa.net
|
|
10
12
|
|
|
11
13
|
Usage:
|
|
12
|
-
forgexa login
|
|
14
|
+
forgexa login --server https://your-server.com
|
|
13
15
|
forgexa workspace list
|
|
14
16
|
forgexa project list --workspace <id>
|
|
15
17
|
forgexa requirement list --project <id>
|
|
16
18
|
forgexa daemon status
|
|
17
19
|
forgexa gates pending
|
|
20
|
+
forgexa config show
|
|
18
21
|
forgexa --help
|
|
19
22
|
"""
|
|
20
23
|
from __future__ import annotations
|
|
@@ -27,23 +30,56 @@ import signal
|
|
|
27
30
|
import sys
|
|
28
31
|
from pathlib import Path
|
|
29
32
|
|
|
30
|
-
# ──
|
|
33
|
+
# ── Build-time default ──
|
|
34
|
+
# Resolved in priority order at runtime (see _api_url()):
|
|
35
|
+
# 1. --server-url flag
|
|
36
|
+
# 2. FORGEXA_SERVER_URL environment variable
|
|
37
|
+
# 3. ~/.forgexa/config (saved by `forgexa login --server <url>`)
|
|
38
|
+
# 4. This build default
|
|
31
39
|
|
|
32
|
-
|
|
33
|
-
# Default server URL — resolved in priority order:
|
|
34
|
-
# 1. FORGEXA_SERVER_URL environment variable (runtime override)
|
|
35
|
-
# 2. _build_config.py — generated by publish.sh at wheel-build time
|
|
36
|
-
# 3. Hardcoded fallback — https://api.forgexa.net
|
|
37
|
-
#
|
|
38
|
-
# For local development use: FORGEXA_SERVER_URL=http://localhost:8000 forgexa ...
|
|
39
40
|
try:
|
|
40
|
-
from forgexa_cli._build_config import BUILD_SERVER_URL as
|
|
41
|
+
from forgexa_cli._build_config import BUILD_SERVER_URL as _BUILD_DEFAULT
|
|
41
42
|
except ImportError:
|
|
42
|
-
|
|
43
|
+
_BUILD_DEFAULT = "https://api.forgexa.net"
|
|
44
|
+
|
|
45
|
+
# Module-level override applied by main() when --server-url is passed.
|
|
46
|
+
_SERVER_URL_OVERRIDE: str | None = None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
# ── Config file helpers (~/.forgexa/config) ──
|
|
50
|
+
|
|
51
|
+
def _config_path() -> Path:
|
|
52
|
+
return Path.home() / ".forgexa" / "config"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _load_config() -> dict:
|
|
56
|
+
p = _config_path()
|
|
57
|
+
if p.exists():
|
|
58
|
+
try:
|
|
59
|
+
return json.loads(p.read_text())
|
|
60
|
+
except Exception:
|
|
61
|
+
return {}
|
|
62
|
+
return {}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _save_config(data: dict) -> None:
|
|
66
|
+
p = _config_path()
|
|
67
|
+
p.parent.mkdir(exist_ok=True)
|
|
68
|
+
p.write_text(json.dumps(data, indent=2))
|
|
69
|
+
p.chmod(0o600)
|
|
43
70
|
|
|
44
71
|
|
|
45
72
|
def _api_url() -> str:
|
|
46
|
-
|
|
73
|
+
"""Resolve the server URL using priority chain."""
|
|
74
|
+
if _SERVER_URL_OVERRIDE:
|
|
75
|
+
return _SERVER_URL_OVERRIDE
|
|
76
|
+
env = os.environ.get("FORGEXA_SERVER_URL")
|
|
77
|
+
if env:
|
|
78
|
+
return env
|
|
79
|
+
cfg = _load_config()
|
|
80
|
+
if cfg.get("server_url"):
|
|
81
|
+
return cfg["server_url"]
|
|
82
|
+
return _BUILD_DEFAULT
|
|
47
83
|
|
|
48
84
|
|
|
49
85
|
def _token() -> str | None:
|
|
@@ -53,7 +89,8 @@ def _token() -> str | None:
|
|
|
53
89
|
token_file = Path.home() / ".forgexa" / "token"
|
|
54
90
|
if token_file.exists():
|
|
55
91
|
return token_file.read_text().strip()
|
|
56
|
-
|
|
92
|
+
cfg = _load_config()
|
|
93
|
+
return cfg.get("token") or None
|
|
57
94
|
|
|
58
95
|
|
|
59
96
|
def _headers() -> dict[str, str]:
|
|
@@ -157,26 +194,83 @@ def _print_table(headers: list[str], rows: list[list[str]], fmt: str | None = No
|
|
|
157
194
|
|
|
158
195
|
|
|
159
196
|
def cmd_login(args: argparse.Namespace) -> None:
|
|
197
|
+
# If --server was given, apply it for this login request and save it.
|
|
198
|
+
server = getattr(args, "server", None)
|
|
199
|
+
if server:
|
|
200
|
+
global _SERVER_URL_OVERRIDE
|
|
201
|
+
_SERVER_URL_OVERRIDE = server.rstrip("/")
|
|
202
|
+
|
|
160
203
|
email = args.email or input("Email: ")
|
|
161
204
|
password = args.password or getpass.getpass("Password: ")
|
|
162
205
|
result = _post("/auth/login", {"email": email, "password": password})
|
|
163
206
|
token = result.get("access_token", "")
|
|
164
|
-
|
|
207
|
+
|
|
208
|
+
# Save to config file (server_url + token in one place)
|
|
209
|
+
cfg = _load_config()
|
|
210
|
+
if server:
|
|
211
|
+
cfg["server_url"] = _SERVER_URL_OVERRIDE
|
|
212
|
+
cfg["token"] = token
|
|
213
|
+
_save_config(cfg)
|
|
214
|
+
|
|
215
|
+
# Also keep the legacy token file for backwards compatibility
|
|
165
216
|
token_dir = Path.home() / ".forgexa"
|
|
166
217
|
token_dir.mkdir(exist_ok=True)
|
|
167
218
|
(token_dir / "token").write_text(token)
|
|
168
219
|
(token_dir / "token").chmod(0o600)
|
|
169
|
-
|
|
170
|
-
|
|
220
|
+
|
|
221
|
+
active_server = _api_url()
|
|
222
|
+
print(f"Login successful.")
|
|
223
|
+
print(f" Server : {active_server}")
|
|
224
|
+
print(f" Config : ~/.forgexa/config")
|
|
171
225
|
|
|
172
226
|
|
|
173
227
|
def cmd_logout(_args: argparse.Namespace) -> None:
|
|
228
|
+
cleared = False
|
|
174
229
|
token_file = Path.home() / ".forgexa" / "token"
|
|
175
230
|
if token_file.exists():
|
|
176
231
|
token_file.unlink()
|
|
232
|
+
cleared = True
|
|
233
|
+
cfg = _load_config()
|
|
234
|
+
if "token" in cfg:
|
|
235
|
+
del cfg["token"]
|
|
236
|
+
_save_config(cfg)
|
|
237
|
+
cleared = True
|
|
238
|
+
if cleared:
|
|
177
239
|
print("Logged out. Token removed.")
|
|
178
240
|
else:
|
|
179
|
-
print("No token
|
|
241
|
+
print("No token found.")
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def cmd_config_show(_args: argparse.Namespace) -> None:
|
|
245
|
+
"""Show current CLI configuration."""
|
|
246
|
+
cfg = _load_config()
|
|
247
|
+
token = _token()
|
|
248
|
+
active_url = _api_url()
|
|
249
|
+
source = "build default"
|
|
250
|
+
if _SERVER_URL_OVERRIDE:
|
|
251
|
+
source = "--server-url flag"
|
|
252
|
+
elif os.environ.get("FORGEXA_SERVER_URL"):
|
|
253
|
+
source = "FORGEXA_SERVER_URL env var"
|
|
254
|
+
elif cfg.get("server_url"):
|
|
255
|
+
source = f"~/.forgexa/config"
|
|
256
|
+
print(f"Server URL : {active_url} (source: {source})")
|
|
257
|
+
print(f"Auth token : {'set' if token else 'not set'}")
|
|
258
|
+
print(f"Config file: {_config_path()}")
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def cmd_config_set(args: argparse.Namespace) -> None:
|
|
262
|
+
"""Set a configuration value."""
|
|
263
|
+
cfg = _load_config()
|
|
264
|
+
if args.key == "server-url":
|
|
265
|
+
url = args.value.rstrip("/")
|
|
266
|
+
cfg["server_url"] = url
|
|
267
|
+
_save_config(cfg)
|
|
268
|
+
print(f"Server URL set to: {url}")
|
|
269
|
+
print(f"Saved to: {_config_path()}")
|
|
270
|
+
else:
|
|
271
|
+
print(f"Unknown config key: {args.key}", file=sys.stderr)
|
|
272
|
+
print("Available keys: server-url", file=sys.stderr)
|
|
273
|
+
sys.exit(1)
|
|
180
274
|
|
|
181
275
|
|
|
182
276
|
def cmd_daemon_status(_args: argparse.Namespace) -> None:
|
|
@@ -467,20 +561,28 @@ def cmd_version(_args: argparse.Namespace) -> None:
|
|
|
467
561
|
|
|
468
562
|
|
|
469
563
|
def main() -> None:
|
|
564
|
+
global _SERVER_URL_OVERRIDE, _output_format
|
|
565
|
+
|
|
566
|
+
active_url = _api_url() # resolved before parsing (for help text)
|
|
567
|
+
|
|
470
568
|
parser = argparse.ArgumentParser(
|
|
471
569
|
prog="forgexa",
|
|
472
570
|
description="Forgexa CLI — communicates with the Forgexa server via REST API.",
|
|
473
571
|
epilog=(
|
|
474
572
|
"Configuration:\n"
|
|
475
|
-
f"
|
|
476
|
-
"
|
|
573
|
+
f" Current server : {active_url}\n"
|
|
574
|
+
" Change server : forgexa login --server <url>\n"
|
|
575
|
+
" forgexa config set server-url <url>\n"
|
|
576
|
+
" export FORGEXA_SERVER_URL=<url>\n"
|
|
577
|
+
" Auth token : forgexa login (saved to ~/.forgexa/config)\n"
|
|
477
578
|
),
|
|
478
579
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
479
580
|
)
|
|
480
581
|
parser.add_argument(
|
|
481
582
|
"--server-url",
|
|
482
583
|
default=None,
|
|
483
|
-
|
|
584
|
+
metavar="URL",
|
|
585
|
+
help="Server URL override for this command (also: $FORGEXA_SERVER_URL or ~/.forgexa/config)",
|
|
484
586
|
)
|
|
485
587
|
parser.add_argument(
|
|
486
588
|
"--format",
|
|
@@ -495,10 +597,19 @@ def main() -> None:
|
|
|
495
597
|
|
|
496
598
|
# auth
|
|
497
599
|
login_p = sub.add_parser("login", help="Login and save access token")
|
|
600
|
+
login_p.add_argument("--server", metavar="URL", help="Server URL to connect to (saved to ~/.forgexa/config)")
|
|
498
601
|
login_p.add_argument("--email", help="Email address")
|
|
499
602
|
login_p.add_argument("--password", help="Password")
|
|
500
603
|
sub.add_parser("logout", help="Remove saved access token")
|
|
501
604
|
|
|
605
|
+
# config
|
|
606
|
+
config_p = sub.add_parser("config", help="View or change CLI configuration")
|
|
607
|
+
config_sub = config_p.add_subparsers(dest="config_cmd")
|
|
608
|
+
config_sub.add_parser("show", help="Show current configuration")
|
|
609
|
+
cfg_set = config_sub.add_parser("set", help="Set a configuration value")
|
|
610
|
+
cfg_set.add_argument("key", choices=["server-url"], help="Config key")
|
|
611
|
+
cfg_set.add_argument("value", help="Config value")
|
|
612
|
+
|
|
502
613
|
# daemon (remote API only — use forgexa-daemon for local daemon management)
|
|
503
614
|
daemon_p = sub.add_parser("daemon", help="Daemon management")
|
|
504
615
|
daemon_sub = daemon_p.add_subparsers(dest="daemon_cmd")
|
|
@@ -583,9 +694,8 @@ def main() -> None:
|
|
|
583
694
|
args = parser.parse_args()
|
|
584
695
|
|
|
585
696
|
if args.server_url:
|
|
586
|
-
|
|
697
|
+
_SERVER_URL_OVERRIDE = args.server_url.rstrip("/")
|
|
587
698
|
|
|
588
|
-
global _output_format
|
|
589
699
|
_output_format = args.format or "table"
|
|
590
700
|
|
|
591
701
|
cmd = args.command
|
|
@@ -597,6 +707,10 @@ def main() -> None:
|
|
|
597
707
|
"version": cmd_version,
|
|
598
708
|
"login": cmd_login,
|
|
599
709
|
"logout": cmd_logout,
|
|
710
|
+
"config": lambda a: {
|
|
711
|
+
"show": cmd_config_show,
|
|
712
|
+
"set": cmd_config_set,
|
|
713
|
+
}.get(a.config_cmd, lambda _: config_p.print_help())(a),
|
|
600
714
|
"daemon": lambda a: {
|
|
601
715
|
"start": cmd_daemon_start,
|
|
602
716
|
"status": cmd_daemon_status,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|