forgexa-cli 1.1.6__tar.gz → 1.2.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.1.6 → forgexa_cli-1.2.2}/PKG-INFO +1 -1
- {forgexa_cli-1.1.6 → forgexa_cli-1.2.2}/forgexa_cli/__init__.py +1 -1
- {forgexa_cli-1.1.6 → forgexa_cli-1.2.2}/forgexa_cli/daemon.py +442 -43
- {forgexa_cli-1.1.6 → forgexa_cli-1.2.2}/forgexa_cli.egg-info/PKG-INFO +1 -1
- {forgexa_cli-1.1.6 → forgexa_cli-1.2.2}/pyproject.toml +1 -1
- {forgexa_cli-1.1.6 → forgexa_cli-1.2.2}/README.md +0 -0
- {forgexa_cli-1.1.6 → forgexa_cli-1.2.2}/forgexa_cli/_build_config.py +0 -0
- {forgexa_cli-1.1.6 → forgexa_cli-1.2.2}/forgexa_cli/main.py +0 -0
- {forgexa_cli-1.1.6 → forgexa_cli-1.2.2}/forgexa_cli/py.typed +0 -0
- {forgexa_cli-1.1.6 → forgexa_cli-1.2.2}/forgexa_cli.egg-info/SOURCES.txt +0 -0
- {forgexa_cli-1.1.6 → forgexa_cli-1.2.2}/forgexa_cli.egg-info/dependency_links.txt +0 -0
- {forgexa_cli-1.1.6 → forgexa_cli-1.2.2}/forgexa_cli.egg-info/entry_points.txt +0 -0
- {forgexa_cli-1.1.6 → forgexa_cli-1.2.2}/forgexa_cli.egg-info/requires.txt +0 -0
- {forgexa_cli-1.1.6 → forgexa_cli-1.2.2}/forgexa_cli.egg-info/top_level.txt +0 -0
- {forgexa_cli-1.1.6 → forgexa_cli-1.2.2}/setup.cfg +0 -0
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
"""forgexa-cli — Forgexa command-line client."""
|
|
2
|
-
__version__ = "1.
|
|
2
|
+
__version__ = "1.2.2"
|
|
@@ -11,6 +11,7 @@ Usage:
|
|
|
11
11
|
from __future__ import annotations
|
|
12
12
|
|
|
13
13
|
import asyncio
|
|
14
|
+
import base64
|
|
14
15
|
import hashlib
|
|
15
16
|
import json
|
|
16
17
|
import logging
|
|
@@ -27,8 +28,15 @@ from dataclasses import dataclass, field
|
|
|
27
28
|
from datetime import datetime, timezone
|
|
28
29
|
from pathlib import Path
|
|
29
30
|
from typing import Any
|
|
31
|
+
from urllib.parse import urlparse
|
|
30
32
|
from uuid import UUID
|
|
31
33
|
|
|
34
|
+
# fcntl is Unix-only; on Windows we skip file locking
|
|
35
|
+
try:
|
|
36
|
+
import fcntl
|
|
37
|
+
except ImportError:
|
|
38
|
+
fcntl = None # type: ignore[assignment]
|
|
39
|
+
|
|
32
40
|
try:
|
|
33
41
|
import httpx
|
|
34
42
|
except ImportError:
|
|
@@ -104,8 +112,8 @@ except (ImportError, ModuleNotFoundError):
|
|
|
104
112
|
|
|
105
113
|
def get_daemon_server_urls(self) -> list:
|
|
106
114
|
if self.DAEMON_SERVER_URLS:
|
|
107
|
-
return
|
|
108
|
-
return [self.DAEMON_SERVER_URL] if self.DAEMON_SERVER_URL else []
|
|
115
|
+
return list(dict.fromkeys(u.strip().rstrip("/") for u in self.DAEMON_SERVER_URLS.split(",") if u.strip()))
|
|
116
|
+
return [self.DAEMON_SERVER_URL.rstrip("/")] if self.DAEMON_SERVER_URL else []
|
|
109
117
|
|
|
110
118
|
settings = _StandaloneSettings() # type: ignore[assignment]
|
|
111
119
|
|
|
@@ -444,12 +452,89 @@ class WorkspaceManager:
|
|
|
444
452
|
def __init__(self, root: str):
|
|
445
453
|
self.root = Path(root).expanduser()
|
|
446
454
|
self.root.mkdir(parents=True, exist_ok=True)
|
|
455
|
+
# Project repos live under projects/ to avoid mixing with other daemon files
|
|
456
|
+
self.projects_root = self.root / "projects"
|
|
457
|
+
self.projects_root.mkdir(parents=True, exist_ok=True)
|
|
447
458
|
self._ssh_keys: dict[str, str] = {} # project_key -> PEM key content
|
|
459
|
+
self._http_auth: dict[str, dict[str, str]] = {} # project_key -> provider/token/username
|
|
460
|
+
|
|
461
|
+
# One-time migration: move legacy project dirs from root/ to projects/
|
|
462
|
+
self._migrate_legacy_project_dirs()
|
|
448
463
|
|
|
449
464
|
def set_ssh_key(self, project_key: str, ssh_private_key: str) -> None:
|
|
450
465
|
"""Register an SSH private key for a project."""
|
|
451
466
|
self._ssh_keys[project_key] = ssh_private_key
|
|
452
467
|
|
|
468
|
+
def _migrate_legacy_project_dirs(self) -> None:
|
|
469
|
+
"""Move project directories from root/ to root/projects/ (one-time migration).
|
|
470
|
+
|
|
471
|
+
Previously project workspaces lived at e.g. ~/.forgexa/daemon/AILP/
|
|
472
|
+
Now they live at ~/.forgexa/daemon/projects/AILP/
|
|
473
|
+
Skip known non-project entries (projects/, daemon.log, daemon.lock, etc.).
|
|
474
|
+
"""
|
|
475
|
+
_skip = {"projects", "daemon.log", "daemon.lock", ".DS_Store"}
|
|
476
|
+
try:
|
|
477
|
+
for child in self.root.iterdir():
|
|
478
|
+
if child.name in _skip or child.name.startswith("."):
|
|
479
|
+
continue
|
|
480
|
+
if child.is_dir() and child != self.projects_root:
|
|
481
|
+
dest = self.projects_root / child.name
|
|
482
|
+
if not dest.exists():
|
|
483
|
+
try:
|
|
484
|
+
child.rename(dest)
|
|
485
|
+
logger.info("Migrated workspace %s → projects/%s", child.name, child.name)
|
|
486
|
+
except OSError as e:
|
|
487
|
+
logger.warning("Failed to migrate workspace %s: %s", child.name, e)
|
|
488
|
+
except Exception:
|
|
489
|
+
pass # Non-critical migration
|
|
490
|
+
|
|
491
|
+
def set_http_auth(
|
|
492
|
+
self,
|
|
493
|
+
project_key: str,
|
|
494
|
+
provider: str,
|
|
495
|
+
api_token: str,
|
|
496
|
+
username: str | None = None,
|
|
497
|
+
) -> None:
|
|
498
|
+
"""Register HTTPS git auth for a project.
|
|
499
|
+
|
|
500
|
+
This is used when repos are configured via HTTPS and no SSH key is provided.
|
|
501
|
+
"""
|
|
502
|
+
if not api_token:
|
|
503
|
+
return
|
|
504
|
+
self._http_auth[project_key] = {
|
|
505
|
+
"provider": provider,
|
|
506
|
+
"api_token": api_token,
|
|
507
|
+
"username": username or "",
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
def _build_http_git_config_args(self, project_key: str) -> list[str]:
|
|
511
|
+
"""Build git -c args for HTTP Authorization header."""
|
|
512
|
+
auth = self._http_auth.get(project_key)
|
|
513
|
+
if not auth:
|
|
514
|
+
return []
|
|
515
|
+
|
|
516
|
+
provider = (auth.get("provider") or "").strip().lower()
|
|
517
|
+
token = auth.get("api_token") or ""
|
|
518
|
+
if not token:
|
|
519
|
+
return []
|
|
520
|
+
|
|
521
|
+
username = (auth.get("username") or "").strip()
|
|
522
|
+
if not username:
|
|
523
|
+
if provider == "github":
|
|
524
|
+
username = "x-access-token"
|
|
525
|
+
elif provider == "gitlab":
|
|
526
|
+
username = "oauth2"
|
|
527
|
+
else:
|
|
528
|
+
# Works for Bitbucket Server/Data Center PAT in HTTPS git auth.
|
|
529
|
+
username = "x-token-auth"
|
|
530
|
+
|
|
531
|
+
basic = base64.b64encode(f"{username}:{token}".encode("utf-8")).decode("ascii")
|
|
532
|
+
args = ["-c", f"http.extraHeader=Authorization: Basic {basic}"]
|
|
533
|
+
# Disable SSL verification for self-signed certs (common in on-prem Bitbucket Server)
|
|
534
|
+
if provider in ("bitbucket-server", "gitlab", "gitea"):
|
|
535
|
+
args.extend(["-c", "http.sslVerify=false"])
|
|
536
|
+
return args
|
|
537
|
+
|
|
453
538
|
def _build_ssh_env(self, project_key: str) -> dict | None:
|
|
454
539
|
"""Build env dict with GIT_SSH_COMMAND if an SSH key is available."""
|
|
455
540
|
key_content = self._ssh_keys.get(project_key)
|
|
@@ -473,7 +558,15 @@ class WorkspaceManager:
|
|
|
473
558
|
if ssh_key:
|
|
474
559
|
self.set_ssh_key(project_key, ssh_key)
|
|
475
560
|
|
|
476
|
-
|
|
561
|
+
# Register HTTPS token auth for non-SSH repos.
|
|
562
|
+
# Token/provider are resolved by API server from integration config fallback chain.
|
|
563
|
+
git_provider = str(project.get("git_provider") or "")
|
|
564
|
+
git_api_token = str(project.get("git_api_token") or "")
|
|
565
|
+
git_username = str(project.get("git_username") or "")
|
|
566
|
+
if git_api_token:
|
|
567
|
+
self.set_http_auth(project_key, git_provider, git_api_token, git_username or None)
|
|
568
|
+
|
|
569
|
+
project_dir = self.projects_root / project_key
|
|
477
570
|
project_dir.mkdir(parents=True, exist_ok=True)
|
|
478
571
|
|
|
479
572
|
# Use requirement_workflow_id as workspace key when available so that
|
|
@@ -485,12 +578,12 @@ class WorkspaceManager:
|
|
|
485
578
|
else:
|
|
486
579
|
workspace_key = re.sub(r"[^a-zA-Z0-9_-]", "_", str(task.graph_id))
|
|
487
580
|
|
|
488
|
-
#
|
|
489
|
-
# when requirement_key is available; fallback to
|
|
581
|
+
# Use human-readable branch name feature/{requirement_key}
|
|
582
|
+
# when requirement_key is available; fallback to feature/{workspace_key}
|
|
490
583
|
if task.requirement_key:
|
|
491
|
-
branch_name = f"
|
|
584
|
+
branch_name = f"feature/{task.requirement_key}"
|
|
492
585
|
else:
|
|
493
|
-
branch_name = f"
|
|
586
|
+
branch_name = f"feature/{workspace_key}"
|
|
494
587
|
|
|
495
588
|
# Determine whether this node is the "first" in a new graph that needs
|
|
496
589
|
# a fresh base from the default branch. Analysis nodes always start
|
|
@@ -551,6 +644,40 @@ class WorkspaceManager:
|
|
|
551
644
|
await self._git("checkout", "-b", branch_name, cwd=ws_path)
|
|
552
645
|
return ws_path
|
|
553
646
|
|
|
647
|
+
async def _is_healthy_worktree(self, ws_path: Path) -> bool:
|
|
648
|
+
"""Check if a git worktree directory is valid and functional."""
|
|
649
|
+
git_file = ws_path / ".git"
|
|
650
|
+
if not git_file.exists():
|
|
651
|
+
return False
|
|
652
|
+
if git_file.is_dir():
|
|
653
|
+
# It's a real .git directory (init'd repo), not a worktree
|
|
654
|
+
return True
|
|
655
|
+
# Worktree: .git is a file containing "gitdir: /path/to/main/.git/worktrees/..."
|
|
656
|
+
try:
|
|
657
|
+
content = git_file.read_text().strip()
|
|
658
|
+
if content.startswith("gitdir: "):
|
|
659
|
+
gitdir = Path(content.split("gitdir: ", 1)[1].strip())
|
|
660
|
+
return gitdir.exists()
|
|
661
|
+
except OSError:
|
|
662
|
+
pass
|
|
663
|
+
return False
|
|
664
|
+
|
|
665
|
+
async def _remove_broken_worktree(self, main_repo: Path, ws_path: Path, workspace_key: str):
|
|
666
|
+
"""Remove a broken worktree directory and clean up stale worktree refs."""
|
|
667
|
+
import shutil
|
|
668
|
+
# Try to prune from main repo first
|
|
669
|
+
if main_repo.exists():
|
|
670
|
+
try:
|
|
671
|
+
await self._git("worktree", "prune", cwd=main_repo)
|
|
672
|
+
except RuntimeError:
|
|
673
|
+
pass
|
|
674
|
+
# Also remove the worktree ref if it still exists
|
|
675
|
+
wt_ref = main_repo / ".git" / "worktrees" / workspace_key
|
|
676
|
+
if wt_ref.exists():
|
|
677
|
+
shutil.rmtree(wt_ref, ignore_errors=True)
|
|
678
|
+
# Remove the broken worktree directory
|
|
679
|
+
shutil.rmtree(ws_path, ignore_errors=True)
|
|
680
|
+
|
|
554
681
|
async def _create_worktree(
|
|
555
682
|
self, project_dir: Path, repo_url: str, default_branch: str,
|
|
556
683
|
workspace_key: str, branch_name: str, *, fresh_start: bool = False,
|
|
@@ -560,31 +687,46 @@ class WorkspaceManager:
|
|
|
560
687
|
ws_path = project_dir / workspace_key
|
|
561
688
|
|
|
562
689
|
if ws_path.exists():
|
|
563
|
-
#
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
# This is the first node of a new graph (e.g. analysis).
|
|
571
|
-
# Reset the worktree to the latest origin/default_branch so
|
|
572
|
-
# that the AI works on up-to-date code. Any previous AI
|
|
573
|
-
# commits have already been pushed, so they are safe.
|
|
574
|
-
logger.info("Fresh start: resetting %s to origin/%s", ws_path, default_branch)
|
|
690
|
+
# Validate that this worktree is healthy (its .git file points to a
|
|
691
|
+
# valid main repo). Worktrees break when directories are moved.
|
|
692
|
+
if not await self._is_healthy_worktree(ws_path):
|
|
693
|
+
logger.warning("Broken worktree detected at %s — removing and recreating", ws_path)
|
|
694
|
+
await self._remove_broken_worktree(main_repo, ws_path, workspace_key)
|
|
695
|
+
else:
|
|
696
|
+
# Healthy worktree — fetch and optionally reset
|
|
575
697
|
try:
|
|
576
|
-
await self._git("
|
|
698
|
+
await self._git("fetch", "origin", cwd=ws_path, project_key=project_key)
|
|
577
699
|
except RuntimeError:
|
|
578
|
-
pass
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
700
|
+
pass
|
|
701
|
+
|
|
702
|
+
if fresh_start:
|
|
703
|
+
logger.info("Fresh start: resetting %s to origin/%s", ws_path, default_branch)
|
|
704
|
+
# Use checkout -B to create or reset the branch to origin/default_branch.
|
|
705
|
+
# Plain `checkout branch_name` fails when the branch doesn't exist locally.
|
|
706
|
+
try:
|
|
707
|
+
await self._git(
|
|
708
|
+
"checkout", "-B", branch_name, f"origin/{default_branch}",
|
|
709
|
+
cwd=ws_path,
|
|
710
|
+
)
|
|
711
|
+
except RuntimeError as exc:
|
|
712
|
+
logger.warning(
|
|
713
|
+
"checkout -B %s failed: %s — falling back to reset", branch_name, exc,
|
|
714
|
+
)
|
|
715
|
+
try:
|
|
716
|
+
await self._git("checkout", branch_name, cwd=ws_path)
|
|
717
|
+
except RuntimeError:
|
|
718
|
+
# Last resort: create branch from HEAD
|
|
719
|
+
try:
|
|
720
|
+
await self._git("checkout", "-b", branch_name, cwd=ws_path)
|
|
721
|
+
except RuntimeError:
|
|
722
|
+
pass
|
|
723
|
+
try:
|
|
724
|
+
await self._git(
|
|
725
|
+
"reset", "--hard", f"origin/{default_branch}", cwd=ws_path,
|
|
726
|
+
)
|
|
727
|
+
except RuntimeError as exc2:
|
|
728
|
+
logger.warning("Failed to reset to origin/%s: %s", default_branch, exc2)
|
|
729
|
+
return ws_path
|
|
588
730
|
|
|
589
731
|
# Ensure _main repo is present and up-to-date
|
|
590
732
|
if not main_repo.exists():
|
|
@@ -605,7 +747,7 @@ class WorkspaceManager:
|
|
|
605
747
|
except RuntimeError as exc:
|
|
606
748
|
logger.warning("Could not fast-forward _main/%s: %s", default_branch, exc)
|
|
607
749
|
|
|
608
|
-
#
|
|
750
|
+
# branch_name is passed in (feature/{requirement_key} or feature/{workspace_key})
|
|
609
751
|
# First check if the branch already exists on remote (for refine/continuation)
|
|
610
752
|
branch_exists_remote = False
|
|
611
753
|
try:
|
|
@@ -644,6 +786,11 @@ class WorkspaceManager:
|
|
|
644
786
|
except Exception:
|
|
645
787
|
ws_path.mkdir(parents=True, exist_ok=True)
|
|
646
788
|
await self._git("clone", repo_url, str(ws_path), project_key=project_key)
|
|
789
|
+
# Ensure we're on the correct branch after clone
|
|
790
|
+
try:
|
|
791
|
+
await self._git("checkout", "-B", branch_name, cwd=ws_path)
|
|
792
|
+
except RuntimeError:
|
|
793
|
+
pass
|
|
647
794
|
else:
|
|
648
795
|
try:
|
|
649
796
|
await self._git(
|
|
@@ -661,6 +808,11 @@ class WorkspaceManager:
|
|
|
661
808
|
# Fallback to simple clone
|
|
662
809
|
ws_path.mkdir(parents=True, exist_ok=True)
|
|
663
810
|
await self._git("clone", repo_url, str(ws_path), project_key=project_key)
|
|
811
|
+
# Ensure we're on the correct branch after clone
|
|
812
|
+
try:
|
|
813
|
+
await self._git("checkout", "-B", branch_name, cwd=ws_path)
|
|
814
|
+
except RuntimeError:
|
|
815
|
+
pass
|
|
664
816
|
|
|
665
817
|
return ws_path
|
|
666
818
|
|
|
@@ -676,6 +828,7 @@ class WorkspaceManager:
|
|
|
676
828
|
(clone, fetch, pull, push) will use GIT_SSH_COMMAND.
|
|
677
829
|
"""
|
|
678
830
|
env = None
|
|
831
|
+
git_prefix_args: list[str] = []
|
|
679
832
|
# Inject SSH key for commands that touch the remote
|
|
680
833
|
remote_commands = {"clone", "fetch", "pull", "push"}
|
|
681
834
|
if project_key and args and args[0] in remote_commands:
|
|
@@ -698,9 +851,20 @@ class WorkspaceManager:
|
|
|
698
851
|
os.close(fd)
|
|
699
852
|
except OSError:
|
|
700
853
|
pass
|
|
854
|
+
elif args[0] == "clone" and len(args) >= 2:
|
|
855
|
+
# Only use HTTP auth for HTTPS repos when no SSH key is configured.
|
|
856
|
+
repo_candidate = args[-2] if len(args) >= 3 else args[-1]
|
|
857
|
+
if isinstance(repo_candidate, str):
|
|
858
|
+
parsed = urlparse(repo_candidate)
|
|
859
|
+
if parsed.scheme in ("http", "https"):
|
|
860
|
+
git_prefix_args = self._build_http_git_config_args(project_key)
|
|
861
|
+
elif args[0] in {"fetch", "pull", "push"}:
|
|
862
|
+
git_prefix_args = self._build_http_git_config_args(project_key)
|
|
863
|
+
if git_prefix_args:
|
|
864
|
+
env = {**(env or os.environ), "GIT_TERMINAL_PROMPT": "0"}
|
|
701
865
|
|
|
702
866
|
proc = await asyncio.create_subprocess_exec(
|
|
703
|
-
"git", *args,
|
|
867
|
+
"git", *git_prefix_args, *args,
|
|
704
868
|
stdout=asyncio.subprocess.PIPE,
|
|
705
869
|
stderr=asyncio.subprocess.PIPE,
|
|
706
870
|
cwd=str(cwd) if cwd else None,
|
|
@@ -1553,6 +1717,8 @@ class HeartbeatService:
|
|
|
1553
1717
|
self._task: asyncio.Task | None = None
|
|
1554
1718
|
self._active_tasks = 0
|
|
1555
1719
|
self._agents: list[dict] = []
|
|
1720
|
+
self._on_success = lambda: None # Callbacks set by ServerConnection
|
|
1721
|
+
self._on_auth_failure = lambda: None
|
|
1556
1722
|
|
|
1557
1723
|
def update(self, active_tasks: int, agents: list[dict]):
|
|
1558
1724
|
self._active_tasks = active_tasks
|
|
@@ -1582,7 +1748,12 @@ class HeartbeatService:
|
|
|
1582
1748
|
},
|
|
1583
1749
|
timeout=10,
|
|
1584
1750
|
)
|
|
1585
|
-
if resp.status_code
|
|
1751
|
+
if resp.status_code == 200:
|
|
1752
|
+
self._on_success()
|
|
1753
|
+
elif resp.status_code in (401, 403):
|
|
1754
|
+
logger.warning("Heartbeat auth failed: %s %s", resp.status_code, resp.text)
|
|
1755
|
+
self._on_auth_failure()
|
|
1756
|
+
else:
|
|
1586
1757
|
logger.warning("Heartbeat failed: %s %s", resp.status_code, resp.text)
|
|
1587
1758
|
except Exception as e:
|
|
1588
1759
|
logger.warning("Heartbeat error: %s", e)
|
|
@@ -1619,6 +1790,8 @@ class TaskPoller:
|
|
|
1619
1790
|
self.server_url = server_url.rstrip("/")
|
|
1620
1791
|
self.runtime_id = runtime_id
|
|
1621
1792
|
self.interval = interval
|
|
1793
|
+
self._on_success = lambda: None # Callbacks set by ServerConnection
|
|
1794
|
+
self._on_auth_failure = lambda: None
|
|
1622
1795
|
|
|
1623
1796
|
async def poll(self) -> list[TaskInfo]:
|
|
1624
1797
|
"""Poll for tasks assigned to this daemon."""
|
|
@@ -1627,7 +1800,12 @@ class TaskPoller:
|
|
|
1627
1800
|
f"{self.server_url}/api/v1/runtimes/{self.runtime_id}/tasks/poll",
|
|
1628
1801
|
timeout=10,
|
|
1629
1802
|
)
|
|
1630
|
-
if resp.status_code
|
|
1803
|
+
if resp.status_code == 200:
|
|
1804
|
+
self._on_success()
|
|
1805
|
+
elif resp.status_code in (401, 403):
|
|
1806
|
+
self._on_auth_failure()
|
|
1807
|
+
return []
|
|
1808
|
+
else:
|
|
1631
1809
|
return []
|
|
1632
1810
|
|
|
1633
1811
|
data = resp.json()
|
|
@@ -1680,13 +1858,57 @@ class ServerConnection:
|
|
|
1680
1858
|
self.heartbeat: HeartbeatService | None = None
|
|
1681
1859
|
self.poller: TaskPoller | None = None
|
|
1682
1860
|
self.reporter: ProgressReporter | None = None
|
|
1861
|
+
self._auth_failures = 0 # Consecutive auth failure count
|
|
1862
|
+
self._max_auth_failures = 3 # Trigger re-registration after this many
|
|
1683
1863
|
# Short label for logging
|
|
1684
1864
|
from urllib.parse import urlparse
|
|
1685
1865
|
parsed = urlparse(server_url)
|
|
1686
1866
|
self.label = parsed.hostname or server_url
|
|
1687
1867
|
|
|
1868
|
+
def refresh_token(self) -> bool:
|
|
1869
|
+
"""Re-read the user JWT from ~/.forgexa/token and update client headers.
|
|
1870
|
+
|
|
1871
|
+
Returns True if a new token was loaded (different from current).
|
|
1872
|
+
"""
|
|
1873
|
+
token_path = Path.home() / ".forgexa" / "token"
|
|
1874
|
+
try:
|
|
1875
|
+
new_token = token_path.read_text().strip() if token_path.exists() else ""
|
|
1876
|
+
except OSError:
|
|
1877
|
+
new_token = ""
|
|
1878
|
+
|
|
1879
|
+
if new_token and new_token != self.api_token:
|
|
1880
|
+
self.api_token = new_token
|
|
1881
|
+
self.client.headers["Authorization"] = f"Bearer {new_token}"
|
|
1882
|
+
logger.info("[%s] Refreshed daemon token from ~/.forgexa/token", self.label)
|
|
1883
|
+
return True
|
|
1884
|
+
return False
|
|
1885
|
+
|
|
1886
|
+
async def re_register(self, agents: list[DiscoveredAgent], max_concurrent: int):
|
|
1887
|
+
"""Refresh token and re-register with the server.
|
|
1888
|
+
|
|
1889
|
+
Called when persistent auth failures indicate stale token/hash.
|
|
1890
|
+
After re-registration, update the runtime_id on heartbeat/poller/reporter
|
|
1891
|
+
in case the server assigned a different one.
|
|
1892
|
+
"""
|
|
1893
|
+
self.refresh_token()
|
|
1894
|
+
try:
|
|
1895
|
+
await self.register(agents, max_concurrent)
|
|
1896
|
+
# Sync runtime_id to all services (may change after re-registration)
|
|
1897
|
+
if self.heartbeat and self.runtime_id:
|
|
1898
|
+
self.heartbeat.runtime_id = self.runtime_id
|
|
1899
|
+
if self.poller and self.runtime_id:
|
|
1900
|
+
self.poller.runtime_id = self.runtime_id
|
|
1901
|
+
if self.reporter and self.runtime_id:
|
|
1902
|
+
self.reporter.runtime_id = self.runtime_id
|
|
1903
|
+
self._auth_failures = 0
|
|
1904
|
+
logger.info("[%s] Re-registered successfully after token refresh", self.label)
|
|
1905
|
+
except Exception as e:
|
|
1906
|
+
logger.warning("[%s] Re-registration failed: %s", self.label, e)
|
|
1907
|
+
|
|
1688
1908
|
async def register(self, agents: list[DiscoveredAgent], max_concurrent: int):
|
|
1689
1909
|
"""Register this daemon with the server."""
|
|
1910
|
+
self._agents = agents
|
|
1911
|
+
self._max_concurrent_cache = max_concurrent
|
|
1690
1912
|
agent_dicts = [
|
|
1691
1913
|
{
|
|
1692
1914
|
"agent_id": a.agent_id,
|
|
@@ -1729,10 +1951,33 @@ class ServerConnection:
|
|
|
1729
1951
|
agents: list[DiscoveredAgent],
|
|
1730
1952
|
):
|
|
1731
1953
|
"""Initialize heartbeat, poller, and reporter services."""
|
|
1954
|
+
self._agents = agents
|
|
1955
|
+
self._max_concurrent = getattr(self, '_max_concurrent_cache', 5)
|
|
1956
|
+
|
|
1957
|
+
def _on_auth_success():
|
|
1958
|
+
self._auth_failures = 0
|
|
1959
|
+
|
|
1960
|
+
def _on_auth_failure():
|
|
1961
|
+
self._auth_failures += 1
|
|
1962
|
+
if self._auth_failures >= self._max_auth_failures:
|
|
1963
|
+
logger.warning(
|
|
1964
|
+
"[%s] %d consecutive auth failures — scheduling re-registration",
|
|
1965
|
+
self.label, self._auth_failures,
|
|
1966
|
+
)
|
|
1967
|
+
# Schedule re-registration in the event loop (non-blocking)
|
|
1968
|
+
try:
|
|
1969
|
+
loop = asyncio.get_event_loop()
|
|
1970
|
+
if loop.is_running():
|
|
1971
|
+
loop.create_task(self.re_register(self._agents, self._max_concurrent))
|
|
1972
|
+
except Exception:
|
|
1973
|
+
pass
|
|
1974
|
+
|
|
1732
1975
|
self.heartbeat = HeartbeatService(
|
|
1733
1976
|
self.client, self.server_url, self.runtime_id, self.daemon_id,
|
|
1734
1977
|
heartbeat_interval,
|
|
1735
1978
|
)
|
|
1979
|
+
self.heartbeat._on_success = _on_auth_success
|
|
1980
|
+
self.heartbeat._on_auth_failure = _on_auth_failure
|
|
1736
1981
|
self.heartbeat.update(0, [
|
|
1737
1982
|
{
|
|
1738
1983
|
"agent_id": a.agent_id,
|
|
@@ -1745,6 +1990,8 @@ class ServerConnection:
|
|
|
1745
1990
|
self.poller = TaskPoller(
|
|
1746
1991
|
self.client, self.server_url, self.runtime_id, poll_interval,
|
|
1747
1992
|
)
|
|
1993
|
+
self.poller._on_success = _on_auth_success
|
|
1994
|
+
self.poller._on_auth_failure = _on_auth_failure
|
|
1748
1995
|
self.reporter = ProgressReporter(
|
|
1749
1996
|
self.client, self.server_url, self.runtime_id,
|
|
1750
1997
|
)
|
|
@@ -1759,8 +2006,9 @@ class ServerConnection:
|
|
|
1759
2006
|
await self.heartbeat.stop()
|
|
1760
2007
|
if self.runtime_id:
|
|
1761
2008
|
try:
|
|
1762
|
-
|
|
1763
|
-
|
|
2009
|
+
# Use deregister endpoint (no admin required) instead of DELETE
|
|
2010
|
+
await self.client.post(
|
|
2011
|
+
f"{self.server_url}/api/v1/runtimes/{self.runtime_id}/deregister",
|
|
1764
2012
|
timeout=5,
|
|
1765
2013
|
)
|
|
1766
2014
|
except Exception:
|
|
@@ -1791,6 +2039,14 @@ class RuntimeDaemon:
|
|
|
1791
2039
|
self.daemon_id = settings.DAEMON_ID or self.hardware_id or platform.node()
|
|
1792
2040
|
self.server_urls = settings.get_daemon_server_urls()
|
|
1793
2041
|
self.api_token = settings.DAEMON_API_TOKEN
|
|
2042
|
+
# If no explicit token, try user JWT from ~/.forgexa/token (written by `forgexa login`)
|
|
2043
|
+
if not self.api_token:
|
|
2044
|
+
token_path = Path.home() / ".forgexa" / "token"
|
|
2045
|
+
try:
|
|
2046
|
+
self.api_token = token_path.read_text().strip() if token_path.exists() else ""
|
|
2047
|
+
except OSError:
|
|
2048
|
+
self.api_token = ""
|
|
2049
|
+
# _mint_local_dev_token() is called in start() if still empty
|
|
1794
2050
|
self.max_concurrent = settings.DAEMON_MAX_CONCURRENT
|
|
1795
2051
|
self.poll_interval = settings.DAEMON_POLL_INTERVAL
|
|
1796
2052
|
self.heartbeat_interval = settings.DAEMON_HEARTBEAT_INTERVAL
|
|
@@ -1806,9 +2062,120 @@ class RuntimeDaemon:
|
|
|
1806
2062
|
self.agent_discovery = AgentDiscovery()
|
|
1807
2063
|
self.workspace_manager = WorkspaceManager(self.workspaces_root)
|
|
1808
2064
|
self.process_manager = ProcessManager()
|
|
2065
|
+
self._lock_file = None # File lock to prevent multiple daemon instances
|
|
2066
|
+
|
|
2067
|
+
@staticmethod
|
|
2068
|
+
async def _mint_local_dev_token() -> str:
|
|
2069
|
+
"""Mint a JWT for local development when no explicit token is configured.
|
|
2070
|
+
|
|
2071
|
+
Only works when running in-process with the backend package (``make daemon``).
|
|
2072
|
+
Queries the first active user and creates a long-lived access token so the
|
|
2073
|
+
runtime gets proper owner_id + organization_id assignment.
|
|
2074
|
+
"""
|
|
2075
|
+
try:
|
|
2076
|
+
from app.core.security import create_access_token
|
|
2077
|
+
from app.database import engine
|
|
2078
|
+
from datetime import timedelta
|
|
2079
|
+
from sqlalchemy import text
|
|
2080
|
+
|
|
2081
|
+
async with engine.connect() as conn:
|
|
2082
|
+
row = await conn.execute(
|
|
2083
|
+
text("SELECT id FROM users WHERE is_active = true ORDER BY created_at LIMIT 1")
|
|
2084
|
+
)
|
|
2085
|
+
r = row.first()
|
|
2086
|
+
uid = str(r[0]) if r else None
|
|
2087
|
+
|
|
2088
|
+
if uid:
|
|
2089
|
+
token = create_access_token({"sub": uid}, expires_delta=timedelta(days=30))
|
|
2090
|
+
logger.info("Minted local-dev JWT for user %s (no DAEMON_API_TOKEN configured)", uid)
|
|
2091
|
+
return token
|
|
2092
|
+
logger.warning("No active users in database; cannot mint local-dev token")
|
|
2093
|
+
return ""
|
|
2094
|
+
except Exception as e:
|
|
2095
|
+
logger.debug("Cannot mint local-dev token (standalone mode?): %s", e)
|
|
2096
|
+
return ""
|
|
2097
|
+
|
|
2098
|
+
def _acquire_lock(self):
|
|
2099
|
+
"""Acquire an exclusive file lock to prevent multiple daemon instances.
|
|
2100
|
+
|
|
2101
|
+
If another daemon is already running, try to kill it and take over.
|
|
2102
|
+
This handles orphaned processes from crashed desktop apps, duplicate
|
|
2103
|
+
CLI starts, etc.
|
|
2104
|
+
"""
|
|
2105
|
+
if fcntl is None:
|
|
2106
|
+
# Windows: skip file locking (fcntl not available)
|
|
2107
|
+
logger.info("File locking not available on this platform; skipping")
|
|
2108
|
+
return
|
|
2109
|
+
|
|
2110
|
+
lock_path = Path.home() / ".forgexa" / "daemon" / "daemon.lock"
|
|
2111
|
+
lock_path.parent.mkdir(parents=True, exist_ok=True)
|
|
2112
|
+
|
|
2113
|
+
self._lock_file = open(lock_path, "w")
|
|
2114
|
+
|
|
2115
|
+
try:
|
|
2116
|
+
fcntl.flock(self._lock_file.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
2117
|
+
except (IOError, OSError):
|
|
2118
|
+
# Lock held by another process — try to read its PID and kill it
|
|
2119
|
+
try:
|
|
2120
|
+
old_pid = int(lock_path.read_text().strip())
|
|
2121
|
+
logger.warning("Another daemon is running (PID %d). Sending SIGTERM...", old_pid)
|
|
2122
|
+
os.kill(old_pid, signal.SIGTERM)
|
|
2123
|
+
# Wait up to 5 seconds for it to exit
|
|
2124
|
+
for _ in range(50):
|
|
2125
|
+
try:
|
|
2126
|
+
os.kill(old_pid, 0) # Check if still alive
|
|
2127
|
+
time.sleep(0.1)
|
|
2128
|
+
except ProcessLookupError:
|
|
2129
|
+
break
|
|
2130
|
+
else:
|
|
2131
|
+
# Still alive after 5s — force kill
|
|
2132
|
+
logger.warning("Daemon PID %d did not exit, sending SIGKILL", old_pid)
|
|
2133
|
+
try:
|
|
2134
|
+
os.kill(old_pid, signal.SIGKILL)
|
|
2135
|
+
time.sleep(0.2)
|
|
2136
|
+
except ProcessLookupError:
|
|
2137
|
+
pass
|
|
2138
|
+
except (ValueError, ProcessLookupError, FileNotFoundError, PermissionError):
|
|
2139
|
+
pass
|
|
2140
|
+
|
|
2141
|
+
# Retry the lock
|
|
2142
|
+
try:
|
|
2143
|
+
fcntl.flock(self._lock_file.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
2144
|
+
except (IOError, OSError):
|
|
2145
|
+
logger.error("Cannot acquire daemon lock — another instance may still be running")
|
|
2146
|
+
raise SystemExit(1)
|
|
2147
|
+
|
|
2148
|
+
# Write our PID to the lock file for reference
|
|
2149
|
+
self._lock_file.seek(0)
|
|
2150
|
+
self._lock_file.truncate()
|
|
2151
|
+
self._lock_file.write(str(os.getpid()))
|
|
2152
|
+
self._lock_file.flush()
|
|
2153
|
+
logger.info("Acquired exclusive daemon lock (pid=%d)", os.getpid())
|
|
2154
|
+
|
|
2155
|
+
# Also clean up CLI daemon PID file if it points to a dead process
|
|
2156
|
+
cli_pid_file = Path.home() / ".forgexa-daemon.pid"
|
|
2157
|
+
if cli_pid_file.exists():
|
|
2158
|
+
try:
|
|
2159
|
+
cli_pid = int(cli_pid_file.read_text().strip())
|
|
2160
|
+
if cli_pid != os.getpid():
|
|
2161
|
+
os.kill(cli_pid, signal.SIGTERM)
|
|
2162
|
+
logger.info("Killed stale CLI daemon (PID %d)", cli_pid)
|
|
2163
|
+
except (ValueError, ProcessLookupError):
|
|
2164
|
+
pass
|
|
2165
|
+
try:
|
|
2166
|
+
cli_pid_file.unlink(missing_ok=True)
|
|
2167
|
+
except OSError:
|
|
2168
|
+
pass
|
|
1809
2169
|
|
|
1810
2170
|
async def start(self):
|
|
1811
2171
|
"""Main entry point."""
|
|
2172
|
+
# Prevent multiple daemon instances on the same machine
|
|
2173
|
+
self._acquire_lock()
|
|
2174
|
+
|
|
2175
|
+
# Mint a local-dev token if no token is available yet
|
|
2176
|
+
if not self.api_token:
|
|
2177
|
+
self.api_token = await self._mint_local_dev_token()
|
|
2178
|
+
|
|
1812
2179
|
logger.info("Starting RuntimeDaemon (id=%s)", self.daemon_id)
|
|
1813
2180
|
logger.info("Server URLs: %s", ", ".join(self.server_urls))
|
|
1814
2181
|
logger.info("Workspaces root: %s", self.workspaces_root)
|
|
@@ -2097,7 +2464,10 @@ class RuntimeDaemon:
|
|
|
2097
2464
|
|
|
2098
2465
|
# 5. Auto-commit and push if changes exist
|
|
2099
2466
|
if result.status == "success":
|
|
2100
|
-
await self._auto_commit(workspace_path, task)
|
|
2467
|
+
commit_result = await self._auto_commit(workspace_path, task)
|
|
2468
|
+
if commit_result:
|
|
2469
|
+
# Propagate push/commit errors in metrics so they're visible
|
|
2470
|
+
result.metrics.update(commit_result)
|
|
2101
2471
|
# Re-collect git info after commit (compare with parent)
|
|
2102
2472
|
post_commit_git = await self.process_manager._collect_git_info_vs_parent(workspace_path)
|
|
2103
2473
|
# Merge: use the pre-commit file list if post-commit is empty
|
|
@@ -2425,7 +2795,7 @@ class RuntimeDaemon:
|
|
|
2425
2795
|
except Exception as e:
|
|
2426
2796
|
logger.warning("Failed to read analysis artifact %s: %s", fname, e)
|
|
2427
2797
|
|
|
2428
|
-
async def _auto_commit(self, workspace_path: Path, task: TaskInfo):
|
|
2798
|
+
async def _auto_commit(self, workspace_path: Path, task: TaskInfo) -> dict:
|
|
2429
2799
|
"""Auto-commit and push agent changes.
|
|
2430
2800
|
|
|
2431
2801
|
Some agents (e.g. claude-code) commit changes internally, so we must
|
|
@@ -2436,6 +2806,8 @@ class RuntimeDaemon:
|
|
|
2436
2806
|
merge conflicts. If rebase fails (true conflict), we fall back to a
|
|
2437
2807
|
merge. If the merge also fails, we invoke the same AI agent to
|
|
2438
2808
|
resolve the remaining conflicts automatically.
|
|
2809
|
+
|
|
2810
|
+
Returns a dict with commit/push status info (empty on success).
|
|
2439
2811
|
"""
|
|
2440
2812
|
git = self.workspace_manager._git
|
|
2441
2813
|
default_branch = task.project.get("default_branch", "main")
|
|
@@ -2506,11 +2878,35 @@ class RuntimeDaemon:
|
|
|
2506
2878
|
# changes, dramatically reducing PR merge conflicts.
|
|
2507
2879
|
await self._rebase_onto_latest(workspace_path, default_branch, task, project_key)
|
|
2508
2880
|
|
|
2881
|
+
# ── Verify we're on the correct branch before pushing ──
|
|
2882
|
+
current_branch = ""
|
|
2883
|
+
try:
|
|
2884
|
+
current_branch = (await git(
|
|
2885
|
+
"rev-parse", "--abbrev-ref", "HEAD", cwd=workspace_path,
|
|
2886
|
+
)).strip()
|
|
2887
|
+
except RuntimeError:
|
|
2888
|
+
pass
|
|
2889
|
+
|
|
2890
|
+
expected_branch = ""
|
|
2891
|
+
if task.requirement_key:
|
|
2892
|
+
expected_branch = f"feature/{task.requirement_key}"
|
|
2893
|
+
if current_branch == default_branch:
|
|
2894
|
+
logger.error(
|
|
2895
|
+
"CRITICAL: About to push to default branch '%s' instead of "
|
|
2896
|
+
"feature branch '%s' — aborting push to prevent pollution",
|
|
2897
|
+
current_branch, expected_branch or "(unknown)",
|
|
2898
|
+
)
|
|
2899
|
+
return {"push_error": f"Would push to default branch {current_branch}"}
|
|
2900
|
+
|
|
2509
2901
|
# ── Push ──
|
|
2510
|
-
await self._push_branch(workspace_path, project_key)
|
|
2902
|
+
push_error = await self._push_branch(workspace_path, project_key)
|
|
2903
|
+
if push_error:
|
|
2904
|
+
return {"push_error": push_error}
|
|
2905
|
+
return {}
|
|
2511
2906
|
|
|
2512
2907
|
except Exception as e:
|
|
2513
2908
|
logger.warning("Auto-commit failed: %s", e)
|
|
2909
|
+
return {"commit_error": str(e)}
|
|
2514
2910
|
|
|
2515
2911
|
async def _collect_staged_diff_stats(self, cwd: Path) -> dict:
|
|
2516
2912
|
"""Collect staged diff stats for building a rich commit message."""
|
|
@@ -2973,8 +3369,8 @@ class RuntimeDaemon:
|
|
|
2973
3369
|
except RuntimeError:
|
|
2974
3370
|
await git("reset", "--hard", "HEAD", cwd=workspace_path)
|
|
2975
3371
|
|
|
2976
|
-
async def _push_branch(self, workspace_path: Path, project_key: str = "default"):
|
|
2977
|
-
"""Push the current branch to origin."""
|
|
3372
|
+
async def _push_branch(self, workspace_path: Path, project_key: str = "default") -> str | None:
|
|
3373
|
+
"""Push the current branch to origin. Returns error message on failure, None on success."""
|
|
2978
3374
|
git = self.workspace_manager._git
|
|
2979
3375
|
try:
|
|
2980
3376
|
# Get current branch name
|
|
@@ -2999,11 +3395,14 @@ class RuntimeDaemon:
|
|
|
2999
3395
|
)
|
|
3000
3396
|
logger.info("Pushed branch %s to origin", branch)
|
|
3001
3397
|
except RuntimeError as exc:
|
|
3002
|
-
logger.
|
|
3398
|
+
logger.error("Push failed for branch %s: %s", branch, exc)
|
|
3399
|
+
return f"Push failed: {exc}"
|
|
3003
3400
|
else:
|
|
3004
3401
|
logger.info("No unpushed commits on %s", branch)
|
|
3402
|
+
return None
|
|
3005
3403
|
except Exception as e:
|
|
3006
|
-
logger.
|
|
3404
|
+
logger.error("Push failed: %s", e)
|
|
3405
|
+
return f"Push failed: {e}"
|
|
3007
3406
|
|
|
3008
3407
|
def _agents_as_dicts(self) -> list[dict]:
|
|
3009
3408
|
return [
|
|
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
|