forgexa-cli 1.3.4__tar.gz → 1.4.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.4.2}/PKG-INFO +1 -1
- {forgexa_cli-1.3.4 → forgexa_cli-1.4.2}/forgexa_cli/__init__.py +1 -1
- {forgexa_cli-1.3.4 → forgexa_cli-1.4.2}/forgexa_cli/daemon.py +481 -56
- {forgexa_cli-1.3.4 → forgexa_cli-1.4.2}/forgexa_cli.egg-info/PKG-INFO +1 -1
- {forgexa_cli-1.3.4 → forgexa_cli-1.4.2}/pyproject.toml +1 -1
- {forgexa_cli-1.3.4 → forgexa_cli-1.4.2}/README.md +0 -0
- {forgexa_cli-1.3.4 → forgexa_cli-1.4.2}/forgexa_cli/_build_config.py +0 -0
- {forgexa_cli-1.3.4 → forgexa_cli-1.4.2}/forgexa_cli/main.py +0 -0
- {forgexa_cli-1.3.4 → forgexa_cli-1.4.2}/forgexa_cli/py.typed +0 -0
- {forgexa_cli-1.3.4 → forgexa_cli-1.4.2}/forgexa_cli.egg-info/SOURCES.txt +0 -0
- {forgexa_cli-1.3.4 → forgexa_cli-1.4.2}/forgexa_cli.egg-info/dependency_links.txt +0 -0
- {forgexa_cli-1.3.4 → forgexa_cli-1.4.2}/forgexa_cli.egg-info/entry_points.txt +0 -0
- {forgexa_cli-1.3.4 → forgexa_cli-1.4.2}/forgexa_cli.egg-info/requires.txt +0 -0
- {forgexa_cli-1.3.4 → forgexa_cli-1.4.2}/forgexa_cli.egg-info/top_level.txt +0 -0
- {forgexa_cli-1.3.4 → forgexa_cli-1.4.2}/setup.cfg +0 -0
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
"""forgexa-cli — Forgexa command-line client."""
|
|
2
|
-
__version__ = "1.
|
|
2
|
+
__version__ = "1.4.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:
|
|
@@ -117,6 +299,36 @@ except (ImportError, ModuleNotFoundError):
|
|
|
117
299
|
|
|
118
300
|
settings = _StandaloneSettings() # type: ignore[assignment]
|
|
119
301
|
|
|
302
|
+
# ── Daemon version and client type ────────────────────────────────────────
|
|
303
|
+
# DAEMON_VERSION is the protocol/logic version of the daemon code.
|
|
304
|
+
# Kept in sync with pyproject.toml version via bump-version.sh.
|
|
305
|
+
# CLIENT_TYPE identifies which packaging/distribution this daemon runs in.
|
|
306
|
+
DAEMON_VERSION = "1.4.2"
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def _detect_client_type() -> str:
|
|
310
|
+
"""Auto-detect client type from runtime context.
|
|
311
|
+
|
|
312
|
+
Priority:
|
|
313
|
+
1. FORGEXA_CLIENT_TYPE env var (set by desktop Tauri launcher)
|
|
314
|
+
2. Import context: app.config importable → "server"
|
|
315
|
+
3. Default: "cli" (standalone pip-installed daemon)
|
|
316
|
+
|
|
317
|
+
This allows a single daemon.py source to work correctly regardless
|
|
318
|
+
of deployment context, making the bundle-daemon.sh copy safe.
|
|
319
|
+
"""
|
|
320
|
+
env_type = os.environ.get("FORGEXA_CLIENT_TYPE", "").strip().lower()
|
|
321
|
+
if env_type in ("server", "cli", "desktop"):
|
|
322
|
+
return env_type
|
|
323
|
+
# Server: app.config was successfully imported at module level above
|
|
324
|
+
if "app.config" in sys.modules:
|
|
325
|
+
return "server"
|
|
326
|
+
# Default: standalone execution = CLI
|
|
327
|
+
return "cli"
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
_CLIENT_TYPE = _detect_client_type()
|
|
331
|
+
|
|
120
332
|
# ── Logging — self-managed file handler ────────────────────────────────
|
|
121
333
|
# The daemon configures its own FileHandler so logs are written to
|
|
122
334
|
# ~/.forgexa/daemon/daemon.log regardless of how the daemon was launched
|
|
@@ -331,6 +543,31 @@ class TaskResult:
|
|
|
331
543
|
git: dict = field(default_factory=dict)
|
|
332
544
|
|
|
333
545
|
|
|
546
|
+
# ── Type-aware analysis outputs (inline fallback for standalone daemons) ──
|
|
547
|
+
# Mirrors type_workflow_profiles.py — used when import is unavailable (CLI/Desktop).
|
|
548
|
+
_ANALYSIS_OUTPUTS_BY_TYPE: dict[str, list[str]] = {
|
|
549
|
+
"feature": ["PRD.md", "SDD.md", "TASKS.md", "analysis.json", "test-intent.json"],
|
|
550
|
+
"bugfix": ["diagnosis.md", "TASKS.md", "analysis.json", "test-intent.json"],
|
|
551
|
+
"refactor": ["refactor-plan.md", "TASKS.md", "analysis.json"],
|
|
552
|
+
"documentation": ["outline.md", "analysis.json"],
|
|
553
|
+
"improvement": ["improvement-spec.md", "TASKS.md", "analysis.json", "test-intent.json"],
|
|
554
|
+
"task": ["task-plan.md", "analysis.json"],
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
def _get_analysis_outputs_for_type(req_type: str) -> list[str]:
|
|
559
|
+
"""Get expected analysis output files for a requirement type.
|
|
560
|
+
|
|
561
|
+
Tries to use type_workflow_profiles (available in backend context),
|
|
562
|
+
falls back to inline mapping for standalone daemon execution.
|
|
563
|
+
"""
|
|
564
|
+
try:
|
|
565
|
+
from app.services.type_workflow_profiles import get_profile
|
|
566
|
+
return list(get_profile(req_type).analysis_outputs)
|
|
567
|
+
except Exception:
|
|
568
|
+
return _ANALYSIS_OUTPUTS_BY_TYPE.get(req_type, _ANALYSIS_OUTPUTS_BY_TYPE["feature"])
|
|
569
|
+
|
|
570
|
+
|
|
334
571
|
# ── Agent Discovery ──
|
|
335
572
|
|
|
336
573
|
|
|
@@ -1066,15 +1303,11 @@ class ProcessManager:
|
|
|
1066
1303
|
"usage limit",
|
|
1067
1304
|
"rate limit",
|
|
1068
1305
|
"rate_limit",
|
|
1069
|
-
"429",
|
|
1070
1306
|
"quota exceeded",
|
|
1071
1307
|
"too many requests",
|
|
1072
1308
|
"overloaded",
|
|
1073
|
-
"capacity",
|
|
1074
|
-
"try again",
|
|
1075
|
-
"credit",
|
|
1076
1309
|
"insufficient_quota",
|
|
1077
|
-
"billing",
|
|
1310
|
+
"billing hard limit",
|
|
1078
1311
|
]
|
|
1079
1312
|
|
|
1080
1313
|
# Patterns indicating the agent's API is unreachable/misconfigured —
|
|
@@ -1086,9 +1319,11 @@ class ProcessManager:
|
|
|
1086
1319
|
"connection refused",
|
|
1087
1320
|
"connection reset",
|
|
1088
1321
|
"connection timed out",
|
|
1322
|
+
"connection error",
|
|
1089
1323
|
"name or service not known",
|
|
1090
1324
|
"no such host",
|
|
1091
1325
|
"network is unreachable",
|
|
1326
|
+
"api error",
|
|
1092
1327
|
]
|
|
1093
1328
|
|
|
1094
1329
|
def __init__(self):
|
|
@@ -1139,8 +1374,12 @@ class ProcessManager:
|
|
|
1139
1374
|
elif isinstance(err, str):
|
|
1140
1375
|
error_messages.append(err)
|
|
1141
1376
|
elif ev_type == "result":
|
|
1142
|
-
|
|
1143
|
-
|
|
1377
|
+
if data.get("is_error"):
|
|
1378
|
+
err_text = str(data.get("result", "") or data.get("error", "") or "result marked as error")
|
|
1379
|
+
error_messages.append(err_text)
|
|
1380
|
+
else:
|
|
1381
|
+
has_result = True
|
|
1382
|
+
has_meaningful_content = True
|
|
1144
1383
|
elif ev_type == "error":
|
|
1145
1384
|
msg = data.get("message", "")
|
|
1146
1385
|
if msg:
|
|
@@ -1182,13 +1421,25 @@ class ProcessManager:
|
|
|
1182
1421
|
|
|
1183
1422
|
Returns True for rate/quota limits AND API unavailability errors,
|
|
1184
1423
|
since a different agent (using a different API backend) may succeed.
|
|
1424
|
+
|
|
1425
|
+
IMPORTANT: Only checks stderr, error message, and the tail of stdout.
|
|
1426
|
+
The full stdout contains the agent's work output (e.g., analysis text
|
|
1427
|
+
about APIs, retry logic, HTTP status codes) which naturally contains
|
|
1428
|
+
patterns like "429", "try again", "capacity" — these are NOT indicators
|
|
1429
|
+
of the agent CLI itself being rate-limited.
|
|
1185
1430
|
"""
|
|
1186
1431
|
if result.status == "success":
|
|
1187
1432
|
return False
|
|
1188
|
-
|
|
1433
|
+
# Search error channels: stderr (CLI errors) + error message + tail of stdout
|
|
1434
|
+
# (last 3000 chars catches any CLI-level error at the end of output)
|
|
1435
|
+
error_text = (
|
|
1436
|
+
(result.stderr or "")
|
|
1437
|
+
+ "\n" + (result.error or "")
|
|
1438
|
+
+ "\n" + (result.stdout or "")[-3000:]
|
|
1439
|
+
).lower()
|
|
1189
1440
|
return (
|
|
1190
|
-
any(p in
|
|
1191
|
-
or any(p in
|
|
1441
|
+
any(p in error_text for p in ProcessManager.RATE_LIMIT_PATTERNS)
|
|
1442
|
+
or any(p in error_text for p in ProcessManager.AGENT_UNAVAILABLE_PATTERNS)
|
|
1192
1443
|
)
|
|
1193
1444
|
|
|
1194
1445
|
@staticmethod
|
|
@@ -1205,8 +1456,16 @@ class ProcessManager:
|
|
|
1205
1456
|
if result.status != "success":
|
|
1206
1457
|
return None
|
|
1207
1458
|
|
|
1208
|
-
|
|
1209
|
-
|
|
1459
|
+
# For rate/unavailability pattern detection, only check error channels
|
|
1460
|
+
# (stderr, error field) plus the TAIL of stdout. The full stdout contains
|
|
1461
|
+
# the agent's work output (analysis text, generated docs) which naturally
|
|
1462
|
+
# mentions terms like "rate limit", "429", "capacity", "credit" etc.
|
|
1463
|
+
error_channels = (
|
|
1464
|
+
(result.stderr or "")
|
|
1465
|
+
+ "\n" + (result.error or "")
|
|
1466
|
+
+ "\n" + (result.stdout or "")[-3000:]
|
|
1467
|
+
)
|
|
1468
|
+
pattern_failure = ProcessManager._has_failure_pattern(error_channels)
|
|
1210
1469
|
if pattern_failure:
|
|
1211
1470
|
return pattern_failure
|
|
1212
1471
|
|
|
@@ -1320,18 +1579,23 @@ class ProcessManager:
|
|
|
1320
1579
|
return normalized
|
|
1321
1580
|
|
|
1322
1581
|
def _required_deliverable_paths(self, task: TaskInfo) -> set[str]:
|
|
1323
|
-
|
|
1582
|
+
# For analysis nodes, deliverables live in analysis_output_dir (docs/requirements/...)
|
|
1583
|
+
# For other nodes, use output_dir (docs/implements/...)
|
|
1584
|
+
if task.node_type == "analysis":
|
|
1585
|
+
output_dir = str(
|
|
1586
|
+
(task.input_data or {}).get("analysis_output_dir", "")
|
|
1587
|
+
or (task.input_data or {}).get("output_dir", "")
|
|
1588
|
+
or ""
|
|
1589
|
+
)
|
|
1590
|
+
else:
|
|
1591
|
+
output_dir = str((task.input_data or {}).get("output_dir", "") or "")
|
|
1324
1592
|
output_dir = output_dir.replace("\\", "/").lstrip("./").rstrip("/")
|
|
1325
1593
|
if not output_dir:
|
|
1326
1594
|
return set()
|
|
1327
1595
|
|
|
1328
1596
|
if task.node_type == "analysis":
|
|
1329
1597
|
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"]
|
|
1598
|
+
required_files = _get_analysis_outputs_for_type(req_type)
|
|
1335
1599
|
elif task.node_type == "design":
|
|
1336
1600
|
required_files = ["design.md"]
|
|
1337
1601
|
else:
|
|
@@ -1342,7 +1606,8 @@ class ProcessManager:
|
|
|
1342
1606
|
def _has_required_deliverable_updates(self, task: TaskInfo, *path_lists: list[str] | None) -> bool:
|
|
1343
1607
|
required_paths = self._required_deliverable_paths(task)
|
|
1344
1608
|
if not required_paths:
|
|
1345
|
-
|
|
1609
|
+
# Cannot determine required deliverables — skip check (don't fail)
|
|
1610
|
+
return True
|
|
1346
1611
|
|
|
1347
1612
|
changed_paths: set[str] = set()
|
|
1348
1613
|
for paths in path_lists:
|
|
@@ -1502,7 +1767,7 @@ class ProcessManager:
|
|
|
1502
1767
|
cmd = [
|
|
1503
1768
|
agent.command,
|
|
1504
1769
|
"-p",
|
|
1505
|
-
"--output-format", "json",
|
|
1770
|
+
"--output-format", "stream-json",
|
|
1506
1771
|
"--verbose",
|
|
1507
1772
|
"--dangerously-skip-permissions",
|
|
1508
1773
|
]
|
|
@@ -1706,6 +1971,9 @@ class ProcessManager:
|
|
|
1706
1971
|
data = json.loads(stdout.strip())
|
|
1707
1972
|
if isinstance(data, dict):
|
|
1708
1973
|
parsed.append(data)
|
|
1974
|
+
elif isinstance(data, list):
|
|
1975
|
+
# Handle JSON array (from --output-format json)
|
|
1976
|
+
parsed.extend(d for d in data if isinstance(d, dict))
|
|
1709
1977
|
except (json.JSONDecodeError, ValueError):
|
|
1710
1978
|
pass
|
|
1711
1979
|
|
|
@@ -2182,6 +2450,8 @@ class HeartbeatService:
|
|
|
2182
2450
|
"available_agents": self._agents,
|
|
2183
2451
|
"system_metrics": self._collect_system_metrics(),
|
|
2184
2452
|
"os_info": get_os_info(),
|
|
2453
|
+
"daemon_version": DAEMON_VERSION,
|
|
2454
|
+
"client_type": _CLIENT_TYPE,
|
|
2185
2455
|
},
|
|
2186
2456
|
timeout=10,
|
|
2187
2457
|
)
|
|
@@ -2210,6 +2480,76 @@ class HeartbeatService:
|
|
|
2210
2480
|
return {}
|
|
2211
2481
|
|
|
2212
2482
|
|
|
2483
|
+
# ── Log Uploader ──
|
|
2484
|
+
|
|
2485
|
+
|
|
2486
|
+
class LogUploader:
|
|
2487
|
+
"""Periodically uploads daemon log tail to the server for remote viewing."""
|
|
2488
|
+
|
|
2489
|
+
LOG_UPLOAD_INTERVAL = 300 # Upload every 5 minutes
|
|
2490
|
+
LOG_TAIL_LINES = 500 # Last N lines to upload
|
|
2491
|
+
|
|
2492
|
+
def __init__(
|
|
2493
|
+
self,
|
|
2494
|
+
client: httpx.AsyncClient,
|
|
2495
|
+
server_url: str,
|
|
2496
|
+
runtime_id: str,
|
|
2497
|
+
):
|
|
2498
|
+
self.client = client
|
|
2499
|
+
self.server_url = server_url.rstrip("/")
|
|
2500
|
+
self.runtime_id = runtime_id
|
|
2501
|
+
self._task: asyncio.Task | None = None
|
|
2502
|
+
|
|
2503
|
+
async def start(self):
|
|
2504
|
+
self._task = asyncio.create_task(self._loop())
|
|
2505
|
+
|
|
2506
|
+
async def stop(self):
|
|
2507
|
+
if self._task:
|
|
2508
|
+
self._task.cancel()
|
|
2509
|
+
try:
|
|
2510
|
+
await self._task
|
|
2511
|
+
except asyncio.CancelledError:
|
|
2512
|
+
pass
|
|
2513
|
+
|
|
2514
|
+
async def _loop(self):
|
|
2515
|
+
# Initial upload after 30s delay (let daemon stabilize first)
|
|
2516
|
+
await asyncio.sleep(30)
|
|
2517
|
+
while True:
|
|
2518
|
+
try:
|
|
2519
|
+
await self._upload()
|
|
2520
|
+
except asyncio.CancelledError:
|
|
2521
|
+
raise
|
|
2522
|
+
except Exception as e:
|
|
2523
|
+
logger.warning("Log upload error: %s", e)
|
|
2524
|
+
await asyncio.sleep(self.LOG_UPLOAD_INTERVAL)
|
|
2525
|
+
|
|
2526
|
+
async def _upload(self):
|
|
2527
|
+
"""Read daemon log tail and upload to server."""
|
|
2528
|
+
try:
|
|
2529
|
+
if not DAEMON_LOG_PATH.exists():
|
|
2530
|
+
return
|
|
2531
|
+
# Read last N lines efficiently
|
|
2532
|
+
with open(DAEMON_LOG_PATH, "rb") as f:
|
|
2533
|
+
# Seek from end to find last N lines
|
|
2534
|
+
f.seek(0, 2)
|
|
2535
|
+
file_size = f.tell()
|
|
2536
|
+
# Read at most 100KB from end
|
|
2537
|
+
read_size = min(file_size, 100 * 1024)
|
|
2538
|
+
f.seek(file_size - read_size)
|
|
2539
|
+
content = f.read().decode("utf-8", errors="replace")
|
|
2540
|
+
|
|
2541
|
+
# Take last N lines
|
|
2542
|
+
lines = content.split("\n")
|
|
2543
|
+
tail = "\n".join(lines[-self.LOG_TAIL_LINES:])
|
|
2544
|
+
|
|
2545
|
+
await self.client.post(
|
|
2546
|
+
f"{self.server_url}/api/v1/runtimes/{self.runtime_id}/logs",
|
|
2547
|
+
json={"log_tail": tail, "log_lines": self.LOG_TAIL_LINES},
|
|
2548
|
+
timeout=15,
|
|
2549
|
+
)
|
|
2550
|
+
except Exception as e:
|
|
2551
|
+
logger.warning("Failed to upload daemon log: %s", e)
|
|
2552
|
+
|
|
2213
2553
|
# ── Task Poller ──
|
|
2214
2554
|
|
|
2215
2555
|
|
|
@@ -2296,6 +2636,7 @@ class ServerConnection:
|
|
|
2296
2636
|
self.heartbeat: HeartbeatService | None = None
|
|
2297
2637
|
self.poller: TaskPoller | None = None
|
|
2298
2638
|
self.reporter: ProgressReporter | None = None
|
|
2639
|
+
self.log_uploader: LogUploader | None = None
|
|
2299
2640
|
self._auth_failures = 0 # Consecutive auth failure count
|
|
2300
2641
|
self._max_auth_failures = 3 # Trigger re-registration after this many
|
|
2301
2642
|
# Short label for logging
|
|
@@ -2338,6 +2679,8 @@ class ServerConnection:
|
|
|
2338
2679
|
self.poller.runtime_id = self.runtime_id
|
|
2339
2680
|
if self.reporter and self.runtime_id:
|
|
2340
2681
|
self.reporter.runtime_id = self.runtime_id
|
|
2682
|
+
if self.log_uploader and self.runtime_id:
|
|
2683
|
+
self.log_uploader.runtime_id = self.runtime_id
|
|
2341
2684
|
self._auth_failures = 0
|
|
2342
2685
|
logger.info("[%s] Re-registered successfully after token refresh", self.label)
|
|
2343
2686
|
except Exception as e:
|
|
@@ -2364,6 +2707,8 @@ class ServerConnection:
|
|
|
2364
2707
|
"hardware_id": self.hardware_id,
|
|
2365
2708
|
"device_name": platform.node(),
|
|
2366
2709
|
"os_info": get_os_info(),
|
|
2710
|
+
"daemon_version": DAEMON_VERSION,
|
|
2711
|
+
"client_type": _CLIENT_TYPE,
|
|
2367
2712
|
"available_agents": agent_dicts,
|
|
2368
2713
|
"max_concurrent_tasks": max_concurrent,
|
|
2369
2714
|
"capabilities": {
|
|
@@ -2434,15 +2779,22 @@ class ServerConnection:
|
|
|
2434
2779
|
self.reporter = ProgressReporter(
|
|
2435
2780
|
self.client, self.server_url, self.runtime_id,
|
|
2436
2781
|
)
|
|
2782
|
+
self.log_uploader = LogUploader(
|
|
2783
|
+
self.client, self.server_url, self.runtime_id,
|
|
2784
|
+
)
|
|
2437
2785
|
|
|
2438
2786
|
async def start_heartbeat(self):
|
|
2439
2787
|
if self.heartbeat:
|
|
2440
2788
|
await self.heartbeat.start()
|
|
2789
|
+
if self.log_uploader:
|
|
2790
|
+
await self.log_uploader.start()
|
|
2441
2791
|
|
|
2442
2792
|
async def stop(self):
|
|
2443
2793
|
"""Stop heartbeat and unregister."""
|
|
2444
2794
|
if self.heartbeat:
|
|
2445
2795
|
await self.heartbeat.stop()
|
|
2796
|
+
if self.log_uploader:
|
|
2797
|
+
await self.log_uploader.stop()
|
|
2446
2798
|
if self.runtime_id:
|
|
2447
2799
|
try:
|
|
2448
2800
|
# Use deregister endpoint (no admin required) instead of DELETE
|
|
@@ -2874,15 +3226,15 @@ class RuntimeDaemon:
|
|
|
2874
3226
|
_line_buffer.extend(lines)
|
|
2875
3227
|
|
|
2876
3228
|
async def _progress_ticker():
|
|
2877
|
-
"""Flush buffered output lines + update progress % every
|
|
3229
|
+
"""Flush buffered output lines + update progress % every 5 s."""
|
|
2878
3230
|
import math as _math
|
|
2879
3231
|
tick = 0
|
|
2880
3232
|
while not progress_stop.is_set():
|
|
2881
|
-
await asyncio.sleep(
|
|
3233
|
+
await asyncio.sleep(5)
|
|
2882
3234
|
if progress_stop.is_set():
|
|
2883
3235
|
break
|
|
2884
3236
|
tick += 1
|
|
2885
|
-
pct = min(int(10 + 80 * (1 - 1 / (1 + tick /
|
|
3237
|
+
pct = min(int(10 + 80 * (1 - 1 / (1 + tick / 16))), 90)
|
|
2886
3238
|
pid = self.process_manager.active_processes.get(task.task_id)
|
|
2887
3239
|
step = "running_agent"
|
|
2888
3240
|
if pid:
|
|
@@ -2919,7 +3271,26 @@ class RuntimeDaemon:
|
|
|
2919
3271
|
tried_agents.add(agent.agent_id)
|
|
2920
3272
|
|
|
2921
3273
|
# ── Agent fallback: if agent hit rate limit or API is unavailable, try next agent ──
|
|
3274
|
+
# Guard: if the agent already produced file changes in the workspace, it DID
|
|
3275
|
+
# meaningful work — don't trigger fallback even if it crashed after completing.
|
|
3276
|
+
# Let the recovery logic (step 4.1) handle non-zero exit with committed work.
|
|
3277
|
+
_skip_fallback = False
|
|
2922
3278
|
if self.process_manager.is_rate_limited(result):
|
|
3279
|
+
_pre_fallback_git = await self.process_manager._collect_git_info(workspace_path)
|
|
3280
|
+
_pre_fallback_committed = await self.process_manager._collect_git_info_vs_parent(workspace_path)
|
|
3281
|
+
has_workspace_changes = (
|
|
3282
|
+
bool(_pre_fallback_git.get("files_changed"))
|
|
3283
|
+
or bool(_pre_fallback_committed.get("files_changed"))
|
|
3284
|
+
)
|
|
3285
|
+
if has_workspace_changes:
|
|
3286
|
+
logger.info(
|
|
3287
|
+
"Agent '%s' exited non-zero for task %s but workspace has changes — "
|
|
3288
|
+
"skipping fallback, proceeding to recovery",
|
|
3289
|
+
agent.agent_id, task.task_id,
|
|
3290
|
+
)
|
|
3291
|
+
_skip_fallback = True
|
|
3292
|
+
|
|
3293
|
+
if self.process_manager.is_rate_limited(result) and not _skip_fallback:
|
|
2923
3294
|
logger.warning(
|
|
2924
3295
|
"Agent '%s' unavailable/rate-limited for task %s, attempting fallback",
|
|
2925
3296
|
agent.agent_id, task.task_id,
|
|
@@ -2949,11 +3320,11 @@ class RuntimeDaemon:
|
|
|
2949
3320
|
async def _progress_ticker2():
|
|
2950
3321
|
tick = 0
|
|
2951
3322
|
while not progress_stop2.is_set():
|
|
2952
|
-
await asyncio.sleep(
|
|
3323
|
+
await asyncio.sleep(5)
|
|
2953
3324
|
if progress_stop2.is_set():
|
|
2954
3325
|
break
|
|
2955
3326
|
tick += 1
|
|
2956
|
-
pct = min(int(10 + 80 * (1 - 1 / (1 + tick /
|
|
3327
|
+
pct = min(int(10 + 80 * (1 - 1 / (1 + tick / 16))), 90)
|
|
2957
3328
|
pid = self.process_manager.active_processes.get(task.task_id)
|
|
2958
3329
|
step = f"running_agent:{agent.agent_id}"
|
|
2959
3330
|
if pid:
|
|
@@ -3066,13 +3437,33 @@ class RuntimeDaemon:
|
|
|
3066
3437
|
# Existing files from a prior iteration are not sufficient evidence.
|
|
3067
3438
|
if result.status == "success" and task.node_type in ("analysis", "design"):
|
|
3068
3439
|
committed_git = await self.process_manager._collect_git_info_vs_parent(workspace_path)
|
|
3069
|
-
|
|
3440
|
+
git_check_passed = self.process_manager._has_required_deliverable_updates(
|
|
3070
3441
|
task,
|
|
3071
3442
|
pre_commit_git.get("files_changed"),
|
|
3072
3443
|
committed_git.get("files_changed"),
|
|
3073
3444
|
result.files_changed,
|
|
3074
3445
|
(result.git or {}).get("files_changed"),
|
|
3075
|
-
)
|
|
3446
|
+
)
|
|
3447
|
+
# Fallback: if git-based check fails (e.g., agent auto-committed and
|
|
3448
|
+
# merge-base detection failed), verify files physically exist on disk.
|
|
3449
|
+
# This prevents false failures when git state is unusual but files
|
|
3450
|
+
# are actually present.
|
|
3451
|
+
if not git_check_passed:
|
|
3452
|
+
required_paths = self.process_manager._required_deliverable_paths(task)
|
|
3453
|
+
if required_paths:
|
|
3454
|
+
files_exist = all(
|
|
3455
|
+
(workspace_path / p).exists() and (workspace_path / p).stat().st_size > 0
|
|
3456
|
+
for p in required_paths
|
|
3457
|
+
)
|
|
3458
|
+
if files_exist:
|
|
3459
|
+
logger.info(
|
|
3460
|
+
"Task %s (%s): git diff did not show deliverables but all %d "
|
|
3461
|
+
"files exist on disk — accepting as success",
|
|
3462
|
+
task.task_id, task.node_type, len(required_paths),
|
|
3463
|
+
)
|
|
3464
|
+
git_check_passed = True
|
|
3465
|
+
|
|
3466
|
+
if not git_check_passed:
|
|
3076
3467
|
logger.warning(
|
|
3077
3468
|
"Task %s (%s) reported success but did not update required deliverables",
|
|
3078
3469
|
task.task_id, task.node_type,
|
|
@@ -3099,6 +3490,16 @@ class RuntimeDaemon:
|
|
|
3099
3490
|
if commit_result:
|
|
3100
3491
|
# Propagate push/commit errors in metrics so they're visible
|
|
3101
3492
|
result.metrics.update(commit_result)
|
|
3493
|
+
# Push failure is a real problem for downstream nodes — mark
|
|
3494
|
+
# as failed so the orchestrator can retry (transient network).
|
|
3495
|
+
if commit_result.get("push_error"):
|
|
3496
|
+
push_err = commit_result["push_error"]
|
|
3497
|
+
logger.error(
|
|
3498
|
+
"Task %s: push failed, marking as failed so retry can attempt push again: %s",
|
|
3499
|
+
task.task_id, push_err,
|
|
3500
|
+
)
|
|
3501
|
+
result.status = "failed"
|
|
3502
|
+
result.error = f"Git push failed: {push_err}"
|
|
3102
3503
|
# Re-collect git info after commit (compare with parent)
|
|
3103
3504
|
post_commit_git = await self.process_manager._collect_git_info_vs_parent(workspace_path)
|
|
3104
3505
|
# Merge: use the pre-commit file list if post-commit is empty
|
|
@@ -3201,15 +3602,13 @@ class RuntimeDaemon:
|
|
|
3201
3602
|
|
|
3202
3603
|
if node_type == "analysis":
|
|
3203
3604
|
# 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"]
|
|
3605
|
+
required_files = _get_analysis_outputs_for_type(req_type)
|
|
3211
3606
|
|
|
3212
|
-
|
|
3607
|
+
# Analysis deliverables live in analysis_output_dir (docs/requirements/...)
|
|
3608
|
+
doc_dir = (
|
|
3609
|
+
(task.input_data or {}).get("analysis_output_dir", "")
|
|
3610
|
+
or (task.input_data or {}).get("output_dir", "")
|
|
3611
|
+
)
|
|
3213
3612
|
if doc_dir:
|
|
3214
3613
|
base = workspace_path / doc_dir
|
|
3215
3614
|
else:
|
|
@@ -3444,20 +3843,25 @@ class RuntimeDaemon:
|
|
|
3444
3843
|
always receives the file contents via the completion report and gate
|
|
3445
3844
|
reviewers can see the analysis documents immediately.
|
|
3446
3845
|
"""
|
|
3447
|
-
|
|
3846
|
+
# Analysis deliverables live in analysis_output_dir (docs/requirements/...)
|
|
3847
|
+
doc_dir = (
|
|
3848
|
+
(task.input_data or {}).get("analysis_output_dir", "")
|
|
3849
|
+
or (task.input_data or {}).get("output_dir", "")
|
|
3850
|
+
)
|
|
3448
3851
|
if not doc_dir:
|
|
3449
3852
|
return
|
|
3450
3853
|
|
|
3451
3854
|
base = workspace_path / doc_dir.lstrip("./")
|
|
3452
|
-
|
|
3453
|
-
|
|
3855
|
+
req_type = (task.input_data or {}).get("requirement_type", "feature")
|
|
3856
|
+
_ANALYSIS_FILES = _get_analysis_outputs_for_type(req_type)
|
|
3857
|
+
existing_artifact_paths = {a.get("path", "").replace("\\", "/") for a in result.artifacts}
|
|
3454
3858
|
|
|
3455
3859
|
for fname in _ANALYSIS_FILES:
|
|
3456
3860
|
fpath = base / fname
|
|
3457
3861
|
if not fpath.exists() or fpath.stat().st_size == 0:
|
|
3458
3862
|
continue
|
|
3459
3863
|
try:
|
|
3460
|
-
rel_path = str(fpath.relative_to(workspace_path))
|
|
3864
|
+
rel_path = str(fpath.relative_to(workspace_path)).replace("\\", "/")
|
|
3461
3865
|
if rel_path in existing_artifact_paths:
|
|
3462
3866
|
continue # already attached
|
|
3463
3867
|
content = fpath.read_text(encoding="utf-8", errors="replace")
|
|
@@ -3487,13 +3891,13 @@ class RuntimeDaemon:
|
|
|
3487
3891
|
return
|
|
3488
3892
|
|
|
3489
3893
|
base = workspace_path / doc_dir.lstrip("./")
|
|
3490
|
-
existing_artifact_paths = {a.get("path", "") for a in result.artifacts}
|
|
3894
|
+
existing_artifact_paths = {a.get("path", "").replace("\\", "/") for a in result.artifacts}
|
|
3491
3895
|
|
|
3492
3896
|
design_path = base / "design.md"
|
|
3493
3897
|
if not design_path.exists() or design_path.stat().st_size == 0:
|
|
3494
3898
|
return
|
|
3495
3899
|
try:
|
|
3496
|
-
rel_path = str(design_path.relative_to(workspace_path))
|
|
3900
|
+
rel_path = str(design_path.relative_to(workspace_path)).replace("\\", "/")
|
|
3497
3901
|
if rel_path in existing_artifact_paths:
|
|
3498
3902
|
return
|
|
3499
3903
|
content = design_path.read_text(encoding="utf-8", errors="replace")
|
|
@@ -3555,6 +3959,8 @@ class RuntimeDaemon:
|
|
|
3555
3959
|
or task.input_data.get("title")
|
|
3556
3960
|
or ""
|
|
3557
3961
|
)
|
|
3962
|
+
if not isinstance(wi_title, str):
|
|
3963
|
+
wi_title = str(wi_title)
|
|
3558
3964
|
req_key = task.requirement_key or task.work_item.get("requirement_key") or ""
|
|
3559
3965
|
if req_key and wi_title:
|
|
3560
3966
|
display_title = f"{req_key}: {wi_title}"
|
|
@@ -3565,11 +3971,15 @@ class RuntimeDaemon:
|
|
|
3565
3971
|
else:
|
|
3566
3972
|
display_title = task.task_id
|
|
3567
3973
|
|
|
3568
|
-
|
|
3569
|
-
|
|
3570
|
-
|
|
3571
|
-
|
|
3572
|
-
|
|
3974
|
+
try:
|
|
3975
|
+
commit_msg = await self._build_auto_commit_message(
|
|
3976
|
+
display_title, task.task_id, task.node_type,
|
|
3977
|
+
task.agent_type, change_summary,
|
|
3978
|
+
workspace_path=workspace_path,
|
|
3979
|
+
)
|
|
3980
|
+
except Exception as msg_err:
|
|
3981
|
+
logger.warning("Failed to build rich commit message: %s — using fallback", msg_err)
|
|
3982
|
+
commit_msg = f"{task.node_type}({task.requirement_key or task.task_id}): {display_title}"
|
|
3573
3983
|
proc = await asyncio.create_subprocess_exec(
|
|
3574
3984
|
"git", "commit", "-m", commit_msg,
|
|
3575
3985
|
cwd=str(workspace_path),
|
|
@@ -3766,7 +4176,22 @@ class RuntimeDaemon:
|
|
|
3766
4176
|
lines: list[str] = []
|
|
3767
4177
|
|
|
3768
4178
|
# Summary — word-wrap at 78 chars
|
|
3769
|
-
|
|
4179
|
+
raw_summary = data.get("summary")
|
|
4180
|
+
if isinstance(raw_summary, dict):
|
|
4181
|
+
# Some agents produce summary as a structured object; extract description
|
|
4182
|
+
summary = (
|
|
4183
|
+
raw_summary.get("description")
|
|
4184
|
+
or raw_summary.get("title")
|
|
4185
|
+
or raw_summary.get("summary")
|
|
4186
|
+
or ""
|
|
4187
|
+
)
|
|
4188
|
+
if not isinstance(summary, str):
|
|
4189
|
+
summary = str(summary) if summary else ""
|
|
4190
|
+
elif isinstance(raw_summary, str):
|
|
4191
|
+
summary = raw_summary
|
|
4192
|
+
else:
|
|
4193
|
+
summary = str(raw_summary) if raw_summary else ""
|
|
4194
|
+
summary = summary.strip()
|
|
3770
4195
|
if summary:
|
|
3771
4196
|
words = summary.split()
|
|
3772
4197
|
current = ""
|
|
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
|
|
File without changes
|