forgexa-cli 1.11.3__tar.gz → 1.11.5__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.11.3 → forgexa_cli-1.11.5}/PKG-INFO +13 -4
- {forgexa_cli-1.11.3 → forgexa_cli-1.11.5}/README.md +12 -3
- {forgexa_cli-1.11.3 → forgexa_cli-1.11.5}/forgexa_cli/__init__.py +1 -1
- {forgexa_cli-1.11.3 → forgexa_cli-1.11.5}/forgexa_cli/daemon.py +325 -60
- {forgexa_cli-1.11.3 → forgexa_cli-1.11.5}/forgexa_cli/main.py +123 -60
- {forgexa_cli-1.11.3 → forgexa_cli-1.11.5}/forgexa_cli.egg-info/PKG-INFO +13 -4
- {forgexa_cli-1.11.3 → forgexa_cli-1.11.5}/forgexa_cli.egg-info/SOURCES.txt +2 -1
- {forgexa_cli-1.11.3 → forgexa_cli-1.11.5}/pyproject.toml +1 -1
- forgexa_cli-1.11.5/tests/test_auth_and_runtime_commands.py +236 -0
- {forgexa_cli-1.11.3 → forgexa_cli-1.11.5}/forgexa_cli/_build_config.py +0 -0
- {forgexa_cli-1.11.3 → forgexa_cli-1.11.5}/forgexa_cli/py.typed +0 -0
- {forgexa_cli-1.11.3 → forgexa_cli-1.11.5}/forgexa_cli.egg-info/dependency_links.txt +0 -0
- {forgexa_cli-1.11.3 → forgexa_cli-1.11.5}/forgexa_cli.egg-info/entry_points.txt +0 -0
- {forgexa_cli-1.11.3 → forgexa_cli-1.11.5}/forgexa_cli.egg-info/requires.txt +0 -0
- {forgexa_cli-1.11.3 → forgexa_cli-1.11.5}/forgexa_cli.egg-info/top_level.txt +0 -0
- {forgexa_cli-1.11.3 → forgexa_cli-1.11.5}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: forgexa-cli
|
|
3
|
-
Version: 1.11.
|
|
3
|
+
Version: 1.11.5
|
|
4
4
|
Summary: Forgexa CLI — command-line client and AI agent runtime for the Forgexa platform
|
|
5
5
|
Author-email: Jason Sun <dev.winds@gmail.com>
|
|
6
6
|
License: MIT
|
|
@@ -100,9 +100,9 @@ forgexa board --project <project-id>
|
|
|
100
100
|
| `forgexa budget --workspace <id>` | Budget overview |
|
|
101
101
|
| `forgexa daemon start` | Start local daemon (discover agents, run tasks) |
|
|
102
102
|
| `forgexa daemon start -d` | Start daemon in background |
|
|
103
|
-
| `forgexa daemon status` | Show daemon statuses |
|
|
103
|
+
| `forgexa daemon status` | Show your daemon statuses |
|
|
104
104
|
| `forgexa daemon stop` | Stop local daemon |
|
|
105
|
-
| `forgexa runtimes list` | List runtimes |
|
|
105
|
+
| `forgexa runtimes list` | List your runtimes |
|
|
106
106
|
| `forgexa version` | Show CLI version |
|
|
107
107
|
|
|
108
108
|
## Configuration
|
|
@@ -144,11 +144,20 @@ forgexa-daemon
|
|
|
144
144
|
### Other Daemon Commands
|
|
145
145
|
|
|
146
146
|
```bash
|
|
147
|
-
# Check daemon status (from server)
|
|
147
|
+
# Check your daemon status (from server)
|
|
148
148
|
forgexa daemon status
|
|
149
149
|
|
|
150
|
+
# Platform admin: list all runtimes
|
|
151
|
+
forgexa daemon status --all
|
|
152
|
+
|
|
150
153
|
# Stop background daemon
|
|
151
154
|
forgexa daemon stop
|
|
155
|
+
|
|
156
|
+
# List your runtimes
|
|
157
|
+
forgexa runtimes list
|
|
158
|
+
|
|
159
|
+
# Platform admin: list all runtimes
|
|
160
|
+
forgexa runtimes list --all
|
|
152
161
|
```
|
|
153
162
|
|
|
154
163
|
### Supported AI Agents
|
|
@@ -70,9 +70,9 @@ forgexa board --project <project-id>
|
|
|
70
70
|
| `forgexa budget --workspace <id>` | Budget overview |
|
|
71
71
|
| `forgexa daemon start` | Start local daemon (discover agents, run tasks) |
|
|
72
72
|
| `forgexa daemon start -d` | Start daemon in background |
|
|
73
|
-
| `forgexa daemon status` | Show daemon statuses |
|
|
73
|
+
| `forgexa daemon status` | Show your daemon statuses |
|
|
74
74
|
| `forgexa daemon stop` | Stop local daemon |
|
|
75
|
-
| `forgexa runtimes list` | List runtimes |
|
|
75
|
+
| `forgexa runtimes list` | List your runtimes |
|
|
76
76
|
| `forgexa version` | Show CLI version |
|
|
77
77
|
|
|
78
78
|
## Configuration
|
|
@@ -114,11 +114,20 @@ forgexa-daemon
|
|
|
114
114
|
### Other Daemon Commands
|
|
115
115
|
|
|
116
116
|
```bash
|
|
117
|
-
# Check daemon status (from server)
|
|
117
|
+
# Check your daemon status (from server)
|
|
118
118
|
forgexa daemon status
|
|
119
119
|
|
|
120
|
+
# Platform admin: list all runtimes
|
|
121
|
+
forgexa daemon status --all
|
|
122
|
+
|
|
120
123
|
# Stop background daemon
|
|
121
124
|
forgexa daemon stop
|
|
125
|
+
|
|
126
|
+
# List your runtimes
|
|
127
|
+
forgexa runtimes list
|
|
128
|
+
|
|
129
|
+
# Platform admin: list all runtimes
|
|
130
|
+
forgexa runtimes list --all
|
|
122
131
|
```
|
|
123
132
|
|
|
124
133
|
### Supported AI Agents
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
"""forgexa-cli — Forgexa command-line client."""
|
|
2
|
-
__version__ = "1.11.
|
|
2
|
+
__version__ = "1.11.5"
|
|
@@ -68,6 +68,42 @@ except ImportError:
|
|
|
68
68
|
_HTTPX_DEPS_DIR = os.path.join(str(Path.home()), ".forgexa", "daemon", "deps")
|
|
69
69
|
|
|
70
70
|
|
|
71
|
+
def _cli_config_path() -> Path:
|
|
72
|
+
return Path.home() / ".forgexa" / "config"
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _load_cli_config() -> dict:
|
|
76
|
+
path = _cli_config_path()
|
|
77
|
+
if not path.exists():
|
|
78
|
+
return {}
|
|
79
|
+
try:
|
|
80
|
+
return json.loads(path.read_text())
|
|
81
|
+
except Exception:
|
|
82
|
+
return {}
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _save_cli_config(data: dict) -> None:
|
|
86
|
+
path = _cli_config_path()
|
|
87
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
88
|
+
path.write_text(json.dumps(data, indent=2))
|
|
89
|
+
path.chmod(0o600)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _save_cli_tokens(access_token: str, refresh_token: str | None = None) -> None:
|
|
93
|
+
cfg = _load_cli_config()
|
|
94
|
+
cfg["token"] = access_token
|
|
95
|
+
if refresh_token:
|
|
96
|
+
cfg["refresh_token"] = refresh_token
|
|
97
|
+
else:
|
|
98
|
+
cfg.pop("refresh_token", None)
|
|
99
|
+
_save_cli_config(cfg)
|
|
100
|
+
|
|
101
|
+
token_path = Path.home() / ".forgexa" / "token"
|
|
102
|
+
token_path.parent.mkdir(parents=True, exist_ok=True)
|
|
103
|
+
token_path.write_text(access_token)
|
|
104
|
+
token_path.chmod(0o600)
|
|
105
|
+
|
|
106
|
+
|
|
71
107
|
def _try_install_httpx(deps_dir: str) -> tuple[bool, str]:
|
|
72
108
|
"""Try to install httpx to a user-writable directory.
|
|
73
109
|
|
|
@@ -438,7 +474,7 @@ except (ImportError, ModuleNotFoundError):
|
|
|
438
474
|
# DAEMON_VERSION is the protocol/logic version of the daemon code.
|
|
439
475
|
# Kept in sync with pyproject.toml version via bump-version.sh.
|
|
440
476
|
# CLIENT_TYPE identifies which packaging/distribution this daemon runs in.
|
|
441
|
-
DAEMON_VERSION = "1.11.
|
|
477
|
+
DAEMON_VERSION = "1.11.5"
|
|
442
478
|
|
|
443
479
|
|
|
444
480
|
def _detect_client_type() -> str:
|
|
@@ -690,6 +726,100 @@ class TaskResult:
|
|
|
690
726
|
git: dict = field(default_factory=dict)
|
|
691
727
|
|
|
692
728
|
|
|
729
|
+
def _prepare_ai_job_output(
|
|
730
|
+
*,
|
|
731
|
+
task_type: str,
|
|
732
|
+
agent_id: str,
|
|
733
|
+
raw_output: str,
|
|
734
|
+
max_content: int,
|
|
735
|
+
) -> tuple[str, list[dict] | None]:
|
|
736
|
+
"""Normalize AIJob output before reporting it back to the server."""
|
|
737
|
+
normalized_output, scenario_items = _normalize_ai_job_output(
|
|
738
|
+
task_type=task_type,
|
|
739
|
+
agent_id=agent_id,
|
|
740
|
+
raw_output=raw_output,
|
|
741
|
+
)
|
|
742
|
+
output_content = normalized_output[-max_content:] if normalized_output else ""
|
|
743
|
+
return output_content, scenario_items
|
|
744
|
+
|
|
745
|
+
|
|
746
|
+
def _normalize_ai_job_output(
|
|
747
|
+
*,
|
|
748
|
+
task_type: str,
|
|
749
|
+
agent_id: str,
|
|
750
|
+
raw_output: str,
|
|
751
|
+
) -> tuple[str, list[dict] | None]:
|
|
752
|
+
"""Extract agent-specific final text before truncating for transport."""
|
|
753
|
+
if not raw_output:
|
|
754
|
+
return "", None
|
|
755
|
+
|
|
756
|
+
if task_type not in {"qa_scenario_generate_prompt", "test_script_generate"}:
|
|
757
|
+
return raw_output, None
|
|
758
|
+
|
|
759
|
+
from app.services.ai_execution_strategy import AIExecutionStrategy
|
|
760
|
+
|
|
761
|
+
extracted_output = AIExecutionStrategy._extract_agent_output(agent_id, raw_output)
|
|
762
|
+
if task_type == "test_script_generate":
|
|
763
|
+
return extracted_output or raw_output, None
|
|
764
|
+
|
|
765
|
+
from app.services.qa_ai_assistant import QAAIAssistant
|
|
766
|
+
|
|
767
|
+
parsed = QAAIAssistant._extract_json(extracted_output)
|
|
768
|
+
if isinstance(parsed, list):
|
|
769
|
+
return json.dumps(parsed), parsed
|
|
770
|
+
if isinstance(parsed, dict):
|
|
771
|
+
items = parsed.get("items")
|
|
772
|
+
if isinstance(items, list):
|
|
773
|
+
return json.dumps(items), items
|
|
774
|
+
if extracted_output and extracted_output != raw_output:
|
|
775
|
+
return extracted_output, None
|
|
776
|
+
return raw_output, None
|
|
777
|
+
|
|
778
|
+
|
|
779
|
+
def _extract_ai_job_scripts(
|
|
780
|
+
*,
|
|
781
|
+
output_text: str,
|
|
782
|
+
scenario_ids: list[str],
|
|
783
|
+
workspace_path: Path,
|
|
784
|
+
) -> dict[str, str]:
|
|
785
|
+
scripts: dict[str, str] = {}
|
|
786
|
+
if not scenario_ids or not output_text:
|
|
787
|
+
return scripts
|
|
788
|
+
|
|
789
|
+
import re as _re
|
|
790
|
+
|
|
791
|
+
for sid in scenario_ids:
|
|
792
|
+
pattern = (
|
|
793
|
+
r"##\s*SCRIPT_START::" + _re.escape(sid)
|
|
794
|
+
+ r"\s*\n(.*?)\n##\s*SCRIPT_END::" + _re.escape(sid)
|
|
795
|
+
)
|
|
796
|
+
match = _re.search(pattern, output_text, _re.DOTALL)
|
|
797
|
+
if match:
|
|
798
|
+
scripts[sid] = match.group(1).strip()
|
|
799
|
+
|
|
800
|
+
if not scripts and len(scenario_ids) == 1:
|
|
801
|
+
scripts[scenario_ids[0]] = output_text.strip()
|
|
802
|
+
|
|
803
|
+
if scripts:
|
|
804
|
+
return scripts
|
|
805
|
+
|
|
806
|
+
import glob as _glob
|
|
807
|
+
|
|
808
|
+
for sid in scenario_ids:
|
|
809
|
+
test_files = _glob.glob(
|
|
810
|
+
str(workspace_path / "tests" / "**" / f"*{sid[:8]}*"),
|
|
811
|
+
recursive=True,
|
|
812
|
+
)
|
|
813
|
+
if test_files:
|
|
814
|
+
try:
|
|
815
|
+
with open(test_files[0], "r") as f:
|
|
816
|
+
scripts[sid] = f.read()
|
|
817
|
+
except Exception:
|
|
818
|
+
pass
|
|
819
|
+
|
|
820
|
+
return scripts
|
|
821
|
+
|
|
822
|
+
|
|
693
823
|
def _resolve_git_author(project: dict) -> tuple[str, str]:
|
|
694
824
|
"""Resolve git commit author (name, email) using a 5-tier fallback chain.
|
|
695
825
|
|
|
@@ -1171,6 +1301,20 @@ class WorkspaceManager:
|
|
|
1171
1301
|
return None
|
|
1172
1302
|
return key_content # Return raw key content, _git will handle it
|
|
1173
1303
|
|
|
1304
|
+
@staticmethod
|
|
1305
|
+
def _clone_args(*, branch: str | None = None) -> tuple[str, ...]:
|
|
1306
|
+
"""Return the standard clone args for daemon workspaces.
|
|
1307
|
+
|
|
1308
|
+
Runtime workspaces only need the current branch contents. They already
|
|
1309
|
+
avoid tags and non-target branches, so making the clone shallow is
|
|
1310
|
+
consistent and prevents very large repositories from timing out while
|
|
1311
|
+
transferring years of irrelevant history.
|
|
1312
|
+
"""
|
|
1313
|
+
args: list[str] = ["clone", "--depth", "1", "--single-branch", "--no-tags"]
|
|
1314
|
+
if branch:
|
|
1315
|
+
args.extend(["--branch", branch])
|
|
1316
|
+
return tuple(args)
|
|
1317
|
+
|
|
1174
1318
|
async def prepare_workspace(self, project: dict, task: TaskInfo) -> Path:
|
|
1175
1319
|
"""Create or reuse a workspace for the given task.
|
|
1176
1320
|
|
|
@@ -1916,8 +2060,7 @@ class WorkspaceManager:
|
|
|
1916
2060
|
# Ensure _main repo is present and up-to-date
|
|
1917
2061
|
if not main_repo.exists():
|
|
1918
2062
|
await self._git(
|
|
1919
|
-
|
|
1920
|
-
"--branch", default_branch,
|
|
2063
|
+
*self._clone_args(branch=default_branch),
|
|
1921
2064
|
repo_url, str(main_repo), timeout=settings.GIT_CLONE_TIMEOUT, project_key=project_key,
|
|
1922
2065
|
)
|
|
1923
2066
|
else:
|
|
@@ -1947,8 +2090,7 @@ class WorkspaceManager:
|
|
|
1947
2090
|
await self._safe_rmtree_main(main_repo)
|
|
1948
2091
|
try:
|
|
1949
2092
|
await self._git(
|
|
1950
|
-
|
|
1951
|
-
"--branch", default_branch,
|
|
2093
|
+
*self._clone_args(branch=default_branch),
|
|
1952
2094
|
repo_url, str(main_repo), timeout=settings.GIT_CLONE_TIMEOUT, project_key=project_key,
|
|
1953
2095
|
)
|
|
1954
2096
|
except Exception:
|
|
@@ -1980,8 +2122,7 @@ class WorkspaceManager:
|
|
|
1980
2122
|
await self._safe_rmtree_main(main_repo)
|
|
1981
2123
|
try:
|
|
1982
2124
|
await self._git(
|
|
1983
|
-
|
|
1984
|
-
"--branch", default_branch,
|
|
2125
|
+
*self._clone_args(branch=default_branch),
|
|
1985
2126
|
repo_url, str(main_repo), timeout=settings.GIT_CLONE_TIMEOUT,
|
|
1986
2127
|
project_key=project_key,
|
|
1987
2128
|
)
|
|
@@ -2093,7 +2234,7 @@ class WorkspaceManager:
|
|
|
2093
2234
|
except Exception:
|
|
2094
2235
|
ws_path.mkdir(parents=True, exist_ok=True)
|
|
2095
2236
|
await self._git(
|
|
2096
|
-
|
|
2237
|
+
*self._clone_args(),
|
|
2097
2238
|
repo_url, str(ws_path), timeout=settings.GIT_CLONE_TIMEOUT, project_key=project_key,
|
|
2098
2239
|
)
|
|
2099
2240
|
# Ensure we're on the correct branch after clone
|
|
@@ -2118,7 +2259,7 @@ class WorkspaceManager:
|
|
|
2118
2259
|
# Fallback to simple clone
|
|
2119
2260
|
ws_path.mkdir(parents=True, exist_ok=True)
|
|
2120
2261
|
await self._git(
|
|
2121
|
-
|
|
2262
|
+
*self._clone_args(),
|
|
2122
2263
|
repo_url, str(ws_path), timeout=settings.GIT_CLONE_TIMEOUT, project_key=project_key,
|
|
2123
2264
|
)
|
|
2124
2265
|
# Ensure we're on the correct branch after clone
|
|
@@ -2844,6 +2985,10 @@ class ProcessManager:
|
|
|
2844
2985
|
or (task.input_data or {}).get("output_dir", "")
|
|
2845
2986
|
or ""
|
|
2846
2987
|
)
|
|
2988
|
+
elif task.node_type == "fix":
|
|
2989
|
+
bugfix_doc_path = str((task.input_data or {}).get("bugfix_doc_path", "") or "")
|
|
2990
|
+
bugfix_doc_path = bugfix_doc_path.replace("\\", "/").lstrip("./")
|
|
2991
|
+
return {bugfix_doc_path} if bugfix_doc_path else set()
|
|
2847
2992
|
else:
|
|
2848
2993
|
output_dir = str((task.input_data or {}).get("output_dir", "") or "")
|
|
2849
2994
|
output_dir = output_dir.replace("\\", "/").lstrip("./").rstrip("/")
|
|
@@ -3564,6 +3709,50 @@ class ProcessManager:
|
|
|
3564
3709
|
result.error = error_clean or result.error
|
|
3565
3710
|
return result
|
|
3566
3711
|
|
|
3712
|
+
@staticmethod
|
|
3713
|
+
def _prepare_copilot_home() -> str:
|
|
3714
|
+
"""Prepare a writable Copilot home under ~/.forgexa for sandboxed runs.
|
|
3715
|
+
|
|
3716
|
+
The systemd daemon runs with ProtectSystem=strict and can only write to
|
|
3717
|
+
~/.forgexa plus the workspace root. Copilot CLI 1.0.64 persists runtime
|
|
3718
|
+
state under ~/.copilot/session-state and its SQLite session store. That
|
|
3719
|
+
causes EROFS failures under the daemon even when interactive shell runs
|
|
3720
|
+
work fine.
|
|
3721
|
+
|
|
3722
|
+
Mirror the user-facing ~/.copilot state into a writable daemon-owned
|
|
3723
|
+
directory, then point COPILOT_HOME there for non-interactive runs.
|
|
3724
|
+
"""
|
|
3725
|
+
source_root = Path.home() / ".copilot"
|
|
3726
|
+
target_root = Path.home() / ".forgexa" / "copilot-home"
|
|
3727
|
+
target_root.mkdir(parents=True, exist_ok=True)
|
|
3728
|
+
|
|
3729
|
+
if not source_root.exists():
|
|
3730
|
+
return str(target_root)
|
|
3731
|
+
|
|
3732
|
+
for child in source_root.iterdir():
|
|
3733
|
+
if child.name in {"logs", "session-state"}:
|
|
3734
|
+
continue
|
|
3735
|
+
|
|
3736
|
+
dest = target_root / child.name
|
|
3737
|
+
try:
|
|
3738
|
+
if child.is_dir():
|
|
3739
|
+
if child.name != "ide":
|
|
3740
|
+
continue
|
|
3741
|
+
dest.mkdir(parents=True, exist_ok=True)
|
|
3742
|
+
for sub in child.iterdir():
|
|
3743
|
+
if not sub.is_file() or sub.name.endswith(".lock"):
|
|
3744
|
+
continue
|
|
3745
|
+
shutil.copy2(sub, dest / sub.name)
|
|
3746
|
+
elif child.is_file():
|
|
3747
|
+
shutil.copy2(child, dest)
|
|
3748
|
+
except Exception as exc:
|
|
3749
|
+
logger.debug(
|
|
3750
|
+
"Copilot home mirror skipped %s -> %s: %s",
|
|
3751
|
+
child, dest, exc,
|
|
3752
|
+
)
|
|
3753
|
+
|
|
3754
|
+
return str(target_root)
|
|
3755
|
+
|
|
3567
3756
|
async def _run_copilot(
|
|
3568
3757
|
self, agent: DiscoveredAgent, prompt: str, cwd: Path, timeout: int, task_id: str,
|
|
3569
3758
|
on_chunk: Any = None,
|
|
@@ -3583,6 +3772,7 @@ class ProcessManager:
|
|
|
3583
3772
|
"""
|
|
3584
3773
|
env = os.environ.copy()
|
|
3585
3774
|
env["TERM"] = "dumb" # suppress TTY-detection that suspends the process
|
|
3775
|
+
env["COPILOT_HOME"] = self._prepare_copilot_home()
|
|
3586
3776
|
|
|
3587
3777
|
model_override = os.environ.get("FACTORY_COPILOT_MODEL")
|
|
3588
3778
|
reasoning = os.environ.get("FACTORY_COPILOT_REASONING", "medium")
|
|
@@ -4646,23 +4836,76 @@ class ServerConnection:
|
|
|
4646
4836
|
parsed = urlparse(server_url)
|
|
4647
4837
|
self.label = parsed.hostname or server_url
|
|
4648
4838
|
|
|
4649
|
-
def
|
|
4650
|
-
|
|
4839
|
+
def _apply_api_token(self, token: str) -> bool:
|
|
4840
|
+
token = str(token or "").strip()
|
|
4841
|
+
if not token or token == self.api_token:
|
|
4842
|
+
return False
|
|
4843
|
+
self.api_token = token
|
|
4844
|
+
self.client.headers["Authorization"] = f"Bearer {token}"
|
|
4845
|
+
return True
|
|
4651
4846
|
|
|
4652
|
-
|
|
4653
|
-
"""
|
|
4847
|
+
def _load_token_from_disk(self) -> str:
|
|
4654
4848
|
token_path = Path.home() / ".forgexa" / "token"
|
|
4655
4849
|
try:
|
|
4656
|
-
|
|
4850
|
+
return token_path.read_text().strip() if token_path.exists() else ""
|
|
4657
4851
|
except OSError:
|
|
4658
|
-
|
|
4852
|
+
return ""
|
|
4853
|
+
|
|
4854
|
+
async def refresh_access_token(self) -> bool:
|
|
4855
|
+
"""Refresh the daemon's user session token.
|
|
4659
4856
|
|
|
4660
|
-
|
|
4661
|
-
|
|
4662
|
-
|
|
4857
|
+
First re-read ~/.forgexa/token in case an interactive CLI login already
|
|
4858
|
+
rotated the access token. If that did not change anything, fall back to
|
|
4859
|
+
~/.forgexa/config refresh_token and call /api/v1/auth/refresh.
|
|
4860
|
+
"""
|
|
4861
|
+
if self._apply_api_token(self._load_token_from_disk()):
|
|
4663
4862
|
logger.info("[%s] Refreshed daemon token from ~/.forgexa/token", self.label)
|
|
4664
4863
|
return True
|
|
4665
|
-
|
|
4864
|
+
|
|
4865
|
+
if self.api_token.startswith("pat_"):
|
|
4866
|
+
return False
|
|
4867
|
+
|
|
4868
|
+
cfg = _load_cli_config()
|
|
4869
|
+
refresh_token = str(cfg.get("refresh_token") or "").strip()
|
|
4870
|
+
if not refresh_token:
|
|
4871
|
+
return False
|
|
4872
|
+
|
|
4873
|
+
try:
|
|
4874
|
+
resp = await self.client.post(
|
|
4875
|
+
f"{self.server_url}/api/v1/auth/refresh",
|
|
4876
|
+
json={"refresh_token": refresh_token},
|
|
4877
|
+
headers={"Content-Type": "application/json"},
|
|
4878
|
+
timeout=15,
|
|
4879
|
+
)
|
|
4880
|
+
except Exception as exc:
|
|
4881
|
+
logger.warning("[%s] Token refresh request failed: %s", self.label, exc)
|
|
4882
|
+
return False
|
|
4883
|
+
|
|
4884
|
+
if resp.status_code != 200:
|
|
4885
|
+
logger.warning(
|
|
4886
|
+
"[%s] Token refresh rejected: HTTP %s",
|
|
4887
|
+
self.label,
|
|
4888
|
+
resp.status_code,
|
|
4889
|
+
)
|
|
4890
|
+
return False
|
|
4891
|
+
|
|
4892
|
+
try:
|
|
4893
|
+
data = resp.json()
|
|
4894
|
+
except Exception as exc:
|
|
4895
|
+
logger.warning("[%s] Token refresh payload invalid: %s", self.label, exc)
|
|
4896
|
+
return False
|
|
4897
|
+
|
|
4898
|
+
access_token = str(data.get("access_token") or "").strip()
|
|
4899
|
+
next_refresh_token = str(data.get("refresh_token") or refresh_token).strip()
|
|
4900
|
+
if not access_token:
|
|
4901
|
+
logger.warning("[%s] Token refresh payload missing access_token", self.label)
|
|
4902
|
+
return False
|
|
4903
|
+
|
|
4904
|
+
self.api_token = access_token
|
|
4905
|
+
self.client.headers["Authorization"] = f"Bearer {access_token}"
|
|
4906
|
+
_save_cli_tokens(access_token, next_refresh_token or None)
|
|
4907
|
+
logger.info("[%s] Refreshed daemon token via /auth/refresh", self.label)
|
|
4908
|
+
return True
|
|
4666
4909
|
|
|
4667
4910
|
async def re_register(self, agents: list[DiscoveredAgent], max_concurrent: int):
|
|
4668
4911
|
"""Refresh token and re-register with the server.
|
|
@@ -4671,7 +4914,7 @@ class ServerConnection:
|
|
|
4671
4914
|
After re-registration, update the runtime_id on heartbeat/poller/reporter
|
|
4672
4915
|
in case the server assigned a different one.
|
|
4673
4916
|
"""
|
|
4674
|
-
self.
|
|
4917
|
+
await self.refresh_access_token()
|
|
4675
4918
|
try:
|
|
4676
4919
|
await self.register(agents, max_concurrent)
|
|
4677
4920
|
# Sync runtime_id to all services (may change after re-registration)
|
|
@@ -4701,8 +4944,8 @@ class ServerConnection:
|
|
|
4701
4944
|
}
|
|
4702
4945
|
for a in agents
|
|
4703
4946
|
]
|
|
4704
|
-
|
|
4705
|
-
|
|
4947
|
+
async def _register_once():
|
|
4948
|
+
return await self.client.post(
|
|
4706
4949
|
f"{self.server_url}/api/v1/runtimes/register",
|
|
4707
4950
|
json={
|
|
4708
4951
|
"daemon_id": self.daemon_id,
|
|
@@ -4722,6 +4965,11 @@ class ServerConnection:
|
|
|
4722
4965
|
},
|
|
4723
4966
|
timeout=15,
|
|
4724
4967
|
)
|
|
4968
|
+
|
|
4969
|
+
try:
|
|
4970
|
+
resp = await _register_once()
|
|
4971
|
+
if resp.status_code == 401 and await self.refresh_access_token():
|
|
4972
|
+
resp = await _register_once()
|
|
4725
4973
|
resp.raise_for_status()
|
|
4726
4974
|
data = resp.json()
|
|
4727
4975
|
self.runtime_id = data["runtime_id"]
|
|
@@ -5719,9 +5967,9 @@ class RuntimeDaemon:
|
|
|
5719
5967
|
result.status = "failed"
|
|
5720
5968
|
result.failure_code = "all_agents_rate_limited"
|
|
5721
5969
|
|
|
5722
|
-
# 4.55 Analysis/design nodes must update their deliverables in THIS run.
|
|
5970
|
+
# 4.55 Analysis/design/fix nodes must update their deliverables in THIS run.
|
|
5723
5971
|
# Existing files from a prior iteration are not sufficient evidence.
|
|
5724
|
-
if result.status == "success" and task.node_type in ("analysis", "design"):
|
|
5972
|
+
if result.status == "success" and task.node_type in ("analysis", "design", "fix"):
|
|
5725
5973
|
committed_git = await self.process_manager._collect_git_info_vs_parent(workspace_path)
|
|
5726
5974
|
git_check_passed = self.process_manager._has_required_deliverable_updates(
|
|
5727
5975
|
task,
|
|
@@ -5770,6 +6018,11 @@ class RuntimeDaemon:
|
|
|
5770
6018
|
if result.status == "success" and task.node_type == "design":
|
|
5771
6019
|
await self._collect_design_artifacts(workspace_path, task, result)
|
|
5772
6020
|
|
|
6021
|
+
# 4.8 For fix nodes: attach the bugfix report inline so knowledge
|
|
6022
|
+
# extraction can use the exact root-cause and verification text.
|
|
6023
|
+
if result.status == "success" and task.node_type == "fix":
|
|
6024
|
+
await self._collect_bugfix_artifacts(workspace_path, task, result)
|
|
6025
|
+
|
|
5773
6026
|
# 5. Auto-commit and push if changes exist
|
|
5774
6027
|
if result.status == "success":
|
|
5775
6028
|
commit_result = await self._auto_commit(workspace_path, task)
|
|
@@ -6257,42 +6510,24 @@ class RuntimeDaemon:
|
|
|
6257
6510
|
# 5. Report completion
|
|
6258
6511
|
# For deliverables: allow up to 200K chars (full document); others: last 20K
|
|
6259
6512
|
max_content = 200000 if task_type == "deliverable_generate" else 20000
|
|
6260
|
-
|
|
6261
|
-
|
|
6262
|
-
|
|
6263
|
-
|
|
6264
|
-
|
|
6265
|
-
|
|
6266
|
-
|
|
6267
|
-
|
|
6268
|
-
|
|
6269
|
-
|
|
6270
|
-
|
|
6271
|
-
|
|
6272
|
-
|
|
6273
|
-
|
|
6274
|
-
|
|
6275
|
-
|
|
6276
|
-
|
|
6277
|
-
|
|
6278
|
-
# the entire output as that scenario's script.
|
|
6279
|
-
if not scripts and len(scenario_ids) == 1:
|
|
6280
|
-
scripts[scenario_ids[0]] = output_content.strip()
|
|
6281
|
-
|
|
6282
|
-
# Fallback: check workspace for test files named after scenario
|
|
6283
|
-
if not scripts:
|
|
6284
|
-
import glob as _glob
|
|
6285
|
-
for sid in scenario_ids:
|
|
6286
|
-
test_files = _glob.glob(
|
|
6287
|
-
str(workspace_path / "tests" / "**" / f"*{sid[:8]}*"),
|
|
6288
|
-
recursive=True,
|
|
6289
|
-
)
|
|
6290
|
-
if test_files:
|
|
6291
|
-
try:
|
|
6292
|
-
with open(test_files[0], "r") as f:
|
|
6293
|
-
scripts[sid] = f.read()
|
|
6294
|
-
except Exception:
|
|
6295
|
-
pass
|
|
6513
|
+
normalized_output, scenario_items = _normalize_ai_job_output(
|
|
6514
|
+
task_type=task_type,
|
|
6515
|
+
agent_id=agent.agent_id,
|
|
6516
|
+
raw_output=result.stdout or "",
|
|
6517
|
+
)
|
|
6518
|
+
output_content = normalized_output[-max_content:] if normalized_output else ""
|
|
6519
|
+
scripts = _extract_ai_job_scripts(
|
|
6520
|
+
output_text=normalized_output,
|
|
6521
|
+
scenario_ids=input_ctx.get("scenario_ids", []),
|
|
6522
|
+
workspace_path=workspace_path,
|
|
6523
|
+
)
|
|
6524
|
+
if task_type == "qa_scenario_generate_prompt":
|
|
6525
|
+
output_content, scenario_items = _prepare_ai_job_output(
|
|
6526
|
+
task_type=task_type,
|
|
6527
|
+
agent_id=agent.agent_id,
|
|
6528
|
+
raw_output=result.stdout or "",
|
|
6529
|
+
max_content=max_content,
|
|
6530
|
+
)
|
|
6296
6531
|
|
|
6297
6532
|
# Preserve all_agents_rate_limited so the server does NOT re-enqueue.
|
|
6298
6533
|
_failure_code = result.failure_code if result.failure_code else (
|
|
@@ -6313,6 +6548,8 @@ class RuntimeDaemon:
|
|
|
6313
6548
|
"error": result.error if result.status != "success" else "",
|
|
6314
6549
|
"failure_code": _failure_code,
|
|
6315
6550
|
}
|
|
6551
|
+
if scenario_items:
|
|
6552
|
+
complete_payload["output_result"]["items"] = scenario_items
|
|
6316
6553
|
|
|
6317
6554
|
await conn.client.post(
|
|
6318
6555
|
f"{reporter_url}/complete",
|
|
@@ -6547,6 +6784,34 @@ class RuntimeDaemon:
|
|
|
6547
6784
|
except Exception as e:
|
|
6548
6785
|
logger.warning("Failed to read design artifact: %s", e)
|
|
6549
6786
|
|
|
6787
|
+
async def _collect_bugfix_artifacts(
|
|
6788
|
+
self, workspace_path: Path, task: TaskInfo, result: TaskResult
|
|
6789
|
+
) -> None:
|
|
6790
|
+
"""Attach the bugfix markdown report as an inline artifact."""
|
|
6791
|
+
bugfix_doc_path = str((task.input_data or {}).get("bugfix_doc_path", "") or "")
|
|
6792
|
+
bugfix_doc_path = bugfix_doc_path.replace("\\", "/").lstrip("./")
|
|
6793
|
+
if not bugfix_doc_path:
|
|
6794
|
+
return
|
|
6795
|
+
|
|
6796
|
+
artifact_paths = {a.get("path", "").replace("\\", "/") for a in result.artifacts}
|
|
6797
|
+
if bugfix_doc_path in artifact_paths:
|
|
6798
|
+
return
|
|
6799
|
+
|
|
6800
|
+
full_path = workspace_path / bugfix_doc_path
|
|
6801
|
+
if not full_path.exists() or full_path.stat().st_size == 0:
|
|
6802
|
+
return
|
|
6803
|
+
|
|
6804
|
+
try:
|
|
6805
|
+
content = full_path.read_text(encoding="utf-8", errors="replace")
|
|
6806
|
+
result.artifacts.append({
|
|
6807
|
+
"path": bugfix_doc_path,
|
|
6808
|
+
"content": content,
|
|
6809
|
+
"type": "text/markdown",
|
|
6810
|
+
})
|
|
6811
|
+
logger.debug("Attached bugfix artifact inline: %s (%d bytes)", bugfix_doc_path, len(content))
|
|
6812
|
+
except Exception as e:
|
|
6813
|
+
logger.warning("Failed to read bugfix artifact %s: %s", bugfix_doc_path, e)
|
|
6814
|
+
|
|
6550
6815
|
async def _remove_root_scratch_files(
|
|
6551
6816
|
self, workspace_path: Path, task: TaskInfo, git_status_output: str
|
|
6552
6817
|
) -> None:
|
|
@@ -69,6 +69,29 @@ def _save_config(data: dict) -> None:
|
|
|
69
69
|
p.chmod(0o600)
|
|
70
70
|
|
|
71
71
|
|
|
72
|
+
def _save_tokens(
|
|
73
|
+
access_token: str,
|
|
74
|
+
refresh_token: str | None = None,
|
|
75
|
+
*,
|
|
76
|
+
server_url: str | None = None,
|
|
77
|
+
) -> None:
|
|
78
|
+
cfg = _load_config()
|
|
79
|
+
if server_url:
|
|
80
|
+
cfg["server_url"] = server_url.rstrip("/")
|
|
81
|
+
cfg["token"] = access_token
|
|
82
|
+
if refresh_token:
|
|
83
|
+
cfg["refresh_token"] = refresh_token
|
|
84
|
+
else:
|
|
85
|
+
cfg.pop("refresh_token", None)
|
|
86
|
+
_save_config(cfg)
|
|
87
|
+
|
|
88
|
+
token_dir = Path.home() / ".forgexa"
|
|
89
|
+
token_dir.mkdir(exist_ok=True)
|
|
90
|
+
token_file = token_dir / "token"
|
|
91
|
+
token_file.write_text(access_token)
|
|
92
|
+
token_file.chmod(0o600)
|
|
93
|
+
|
|
94
|
+
|
|
72
95
|
def _api_url() -> str:
|
|
73
96
|
"""Resolve the server URL using priority chain."""
|
|
74
97
|
if _SERVER_URL_OVERRIDE:
|
|
@@ -93,70 +116,111 @@ def _token() -> str | None:
|
|
|
93
116
|
return cfg.get("token") or None
|
|
94
117
|
|
|
95
118
|
|
|
96
|
-
def
|
|
97
|
-
|
|
119
|
+
def _can_refresh_session() -> bool:
|
|
120
|
+
if os.environ.get("FORGEXA_TOKEN"):
|
|
121
|
+
return False
|
|
98
122
|
token = _token()
|
|
99
|
-
if token:
|
|
100
|
-
|
|
101
|
-
|
|
123
|
+
if not token or token.startswith("pat_"):
|
|
124
|
+
return False
|
|
125
|
+
cfg = _load_config()
|
|
126
|
+
return bool(str(cfg.get("refresh_token") or "").strip())
|
|
102
127
|
|
|
103
128
|
|
|
104
|
-
def
|
|
129
|
+
def _refresh_access_token() -> bool:
|
|
130
|
+
if not _can_refresh_session():
|
|
131
|
+
return False
|
|
132
|
+
|
|
133
|
+
cfg = _load_config()
|
|
134
|
+
refresh_token = str(cfg.get("refresh_token") or "").strip()
|
|
135
|
+
if not refresh_token:
|
|
136
|
+
return False
|
|
137
|
+
|
|
105
138
|
import urllib.request
|
|
106
139
|
import urllib.error
|
|
107
140
|
|
|
108
|
-
url = f"{_api_url()}/api/v1
|
|
109
|
-
|
|
141
|
+
url = f"{_api_url()}/api/v1/auth/refresh"
|
|
142
|
+
body = json.dumps({"refresh_token": refresh_token}).encode()
|
|
143
|
+
req = urllib.request.Request(
|
|
144
|
+
url,
|
|
145
|
+
data=body,
|
|
146
|
+
headers={"Content-Type": "application/json"},
|
|
147
|
+
method="POST",
|
|
148
|
+
)
|
|
110
149
|
try:
|
|
111
150
|
with urllib.request.urlopen(req, timeout=15) as resp:
|
|
112
|
-
|
|
113
|
-
except urllib.error.HTTPError
|
|
114
|
-
|
|
115
|
-
print(f"Error {e.code}: {body}", file=sys.stderr)
|
|
116
|
-
sys.exit(1)
|
|
117
|
-
except urllib.error.URLError as e:
|
|
118
|
-
print(f"Connection error: {e.reason}\nServer: {_api_url()}", file=sys.stderr)
|
|
119
|
-
sys.exit(1)
|
|
151
|
+
payload = json.loads(resp.read())
|
|
152
|
+
except (urllib.error.HTTPError, urllib.error.URLError, json.JSONDecodeError, OSError):
|
|
153
|
+
return False
|
|
120
154
|
|
|
155
|
+
access_token = str(payload.get("access_token") or "").strip()
|
|
156
|
+
next_refresh_token = str(payload.get("refresh_token") or refresh_token).strip()
|
|
157
|
+
if not access_token:
|
|
158
|
+
return False
|
|
121
159
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
import urllib.error
|
|
160
|
+
_save_tokens(access_token, next_refresh_token or None)
|
|
161
|
+
return True
|
|
125
162
|
|
|
126
|
-
url = f"{_api_url()}/api/v1{path}"
|
|
127
|
-
body = json.dumps(data or {}).encode()
|
|
128
|
-
req = urllib.request.Request(url, data=body, headers=_headers(), method="POST")
|
|
129
|
-
try:
|
|
130
|
-
with urllib.request.urlopen(req, timeout=30) as resp:
|
|
131
|
-
return json.loads(resp.read())
|
|
132
|
-
except urllib.error.HTTPError as e:
|
|
133
|
-
body_text = e.read().decode(errors="replace")
|
|
134
|
-
print(f"Error {e.code}: {body_text}", file=sys.stderr)
|
|
135
|
-
sys.exit(1)
|
|
136
|
-
except urllib.error.URLError as e:
|
|
137
|
-
print(f"Connection error: {e.reason}\nServer: {_api_url()}", file=sys.stderr)
|
|
138
|
-
sys.exit(1)
|
|
139
163
|
|
|
164
|
+
def _headers() -> dict[str, str]:
|
|
165
|
+
h: dict[str, str] = {"Content-Type": "application/json"}
|
|
166
|
+
token = _token()
|
|
167
|
+
if token:
|
|
168
|
+
h["Authorization"] = f"Bearer {token}"
|
|
169
|
+
return h
|
|
140
170
|
|
|
141
|
-
|
|
171
|
+
|
|
172
|
+
def _request_json(
|
|
173
|
+
path: str,
|
|
174
|
+
*,
|
|
175
|
+
method: str = "GET",
|
|
176
|
+
data: dict | None = None,
|
|
177
|
+
timeout: int = 15,
|
|
178
|
+
allow_refresh: bool = True,
|
|
179
|
+
) -> dict | list | None:
|
|
142
180
|
import urllib.request
|
|
143
181
|
import urllib.error
|
|
144
182
|
|
|
145
183
|
url = f"{_api_url()}/api/v1{path}"
|
|
146
|
-
|
|
184
|
+
body = json.dumps(data).encode() if data is not None else None
|
|
185
|
+
req = urllib.request.Request(url, data=body, headers=_headers(), method=method)
|
|
147
186
|
try:
|
|
148
|
-
with urllib.request.urlopen(req, timeout=
|
|
187
|
+
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
149
188
|
content = resp.read()
|
|
150
189
|
return json.loads(content) if content else None
|
|
151
190
|
except urllib.error.HTTPError as e:
|
|
152
|
-
|
|
153
|
-
|
|
191
|
+
body_text = e.read().decode(errors="replace")
|
|
192
|
+
if e.code == 401 and allow_refresh and _refresh_access_token():
|
|
193
|
+
return _request_json(
|
|
194
|
+
path,
|
|
195
|
+
method=method,
|
|
196
|
+
data=data,
|
|
197
|
+
timeout=timeout,
|
|
198
|
+
allow_refresh=False,
|
|
199
|
+
)
|
|
200
|
+
print(f"Error {e.code}: {body_text}", file=sys.stderr)
|
|
154
201
|
sys.exit(1)
|
|
155
202
|
except urllib.error.URLError as e:
|
|
156
203
|
print(f"Connection error: {e.reason}\nServer: {_api_url()}", file=sys.stderr)
|
|
157
204
|
sys.exit(1)
|
|
158
205
|
|
|
159
206
|
|
|
207
|
+
def _get(path: str) -> dict | list:
|
|
208
|
+
result = _request_json(path, method="GET", timeout=15)
|
|
209
|
+
if result is None:
|
|
210
|
+
return {}
|
|
211
|
+
return result
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _post(path: str, data: dict | None = None) -> dict:
|
|
215
|
+
result = _request_json(path, method="POST", data=data or {}, timeout=30)
|
|
216
|
+
return result if isinstance(result, dict) else {}
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _delete(path: str) -> dict | None:
|
|
220
|
+
result = _request_json(path, method="DELETE", timeout=15)
|
|
221
|
+
return result if isinstance(result, dict) else None
|
|
222
|
+
|
|
223
|
+
|
|
160
224
|
# ── Output helpers ──
|
|
161
225
|
|
|
162
226
|
_output_format = "table"
|
|
@@ -203,20 +267,9 @@ def cmd_login(args: argparse.Namespace) -> None:
|
|
|
203
267
|
email = args.email or input("Email: ")
|
|
204
268
|
password = args.password or getpass.getpass("Password: ")
|
|
205
269
|
result = _post("/auth/login", {"email": email, "password": password})
|
|
206
|
-
token = result.get("access_token"
|
|
207
|
-
|
|
208
|
-
|
|
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
|
|
216
|
-
token_dir = Path.home() / ".forgexa"
|
|
217
|
-
token_dir.mkdir(exist_ok=True)
|
|
218
|
-
(token_dir / "token").write_text(token)
|
|
219
|
-
(token_dir / "token").chmod(0o600)
|
|
270
|
+
token = str(result.get("access_token") or "").strip()
|
|
271
|
+
refresh_token = str(result.get("refresh_token") or "").strip()
|
|
272
|
+
_save_tokens(token, refresh_token or None, server_url=_SERVER_URL_OVERRIDE if server else None)
|
|
220
273
|
|
|
221
274
|
active_server = _api_url()
|
|
222
275
|
print(f"Login successful.")
|
|
@@ -233,10 +286,11 @@ def cmd_logout(_args: argparse.Namespace) -> None:
|
|
|
233
286
|
cfg = _load_config()
|
|
234
287
|
if "token" in cfg:
|
|
235
288
|
del cfg["token"]
|
|
289
|
+
cfg.pop("refresh_token", None)
|
|
236
290
|
_save_config(cfg)
|
|
237
291
|
cleared = True
|
|
238
292
|
if cleared:
|
|
239
|
-
print("Logged out.
|
|
293
|
+
print("Logged out. Tokens removed.")
|
|
240
294
|
else:
|
|
241
295
|
print("No token found.")
|
|
242
296
|
|
|
@@ -255,6 +309,7 @@ def cmd_config_show(_args: argparse.Namespace) -> None:
|
|
|
255
309
|
source = f"~/.forgexa/config"
|
|
256
310
|
print(f"Server URL : {active_url} (source: {source})")
|
|
257
311
|
print(f"Auth token : {'set' if token else 'not set'}")
|
|
312
|
+
print(f"Refresh tok: {'set' if cfg.get('refresh_token') else 'not set'}")
|
|
258
313
|
print(f"Config file: {_config_path()}")
|
|
259
314
|
|
|
260
315
|
|
|
@@ -273,8 +328,14 @@ def cmd_config_set(args: argparse.Namespace) -> None:
|
|
|
273
328
|
sys.exit(1)
|
|
274
329
|
|
|
275
330
|
|
|
276
|
-
def
|
|
277
|
-
|
|
331
|
+
def _get_runtimes(include_all: bool = False) -> list[dict]:
|
|
332
|
+
path = "/runtimes" if include_all else "/runtimes/me"
|
|
333
|
+
runtimes = _get(path)
|
|
334
|
+
return runtimes if isinstance(runtimes, list) else []
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def cmd_daemon_status(args: argparse.Namespace) -> None:
|
|
338
|
+
runtimes = _get_runtimes(include_all=getattr(args, "all", False))
|
|
278
339
|
if not runtimes:
|
|
279
340
|
print("No daemons registered.")
|
|
280
341
|
return
|
|
@@ -363,8 +424,8 @@ def cmd_daemon_start(args: argparse.Namespace) -> None:
|
|
|
363
424
|
main_sync()
|
|
364
425
|
|
|
365
426
|
|
|
366
|
-
def cmd_runtimes_list(
|
|
367
|
-
runtimes =
|
|
427
|
+
def cmd_runtimes_list(args: argparse.Namespace) -> None:
|
|
428
|
+
runtimes = _get_runtimes(include_all=getattr(args, "all", False))
|
|
368
429
|
if not runtimes:
|
|
369
430
|
print("No runtimes registered.")
|
|
370
431
|
return
|
|
@@ -596,11 +657,11 @@ def main() -> None:
|
|
|
596
657
|
sub.add_parser("version", help="Show CLI version")
|
|
597
658
|
|
|
598
659
|
# auth
|
|
599
|
-
login_p = sub.add_parser("login", help="Login and save
|
|
660
|
+
login_p = sub.add_parser("login", help="Login and save session tokens")
|
|
600
661
|
login_p.add_argument("--server", metavar="URL", help="Server URL to connect to (saved to ~/.forgexa/config)")
|
|
601
662
|
login_p.add_argument("--email", help="Email address")
|
|
602
663
|
login_p.add_argument("--password", help="Password")
|
|
603
|
-
sub.add_parser("logout", help="Remove saved
|
|
664
|
+
sub.add_parser("logout", help="Remove saved session tokens")
|
|
604
665
|
|
|
605
666
|
# config
|
|
606
667
|
config_p = sub.add_parser("config", help="View or change CLI configuration")
|
|
@@ -616,13 +677,15 @@ def main() -> None:
|
|
|
616
677
|
daemon_start_p = daemon_sub.add_parser("start", help="Start local daemon (discovers and registers AI agents)")
|
|
617
678
|
daemon_start_p.add_argument("-d", "--detach", action="store_true", help="Run in background")
|
|
618
679
|
daemon_start_p.add_argument("--server-url", default=None, help="Server URL to connect to")
|
|
619
|
-
daemon_sub.add_parser("status", help="Show
|
|
680
|
+
daemon_status_p = daemon_sub.add_parser("status", help="Show your daemon statuses (from server)")
|
|
681
|
+
daemon_status_p.add_argument("--all", action="store_true", help="List all runtimes (platform admin only)")
|
|
620
682
|
daemon_sub.add_parser("stop", help="Stop local daemon (sends SIGTERM)")
|
|
621
683
|
|
|
622
684
|
# runtimes
|
|
623
685
|
rt_p = sub.add_parser("runtimes", help="Runtime management")
|
|
624
686
|
rt_sub = rt_p.add_subparsers(dest="rt_cmd")
|
|
625
|
-
rt_sub.add_parser("list", help="List
|
|
687
|
+
rt_list_p = rt_sub.add_parser("list", help="List your runtimes")
|
|
688
|
+
rt_list_p.add_argument("--all", action="store_true", help="List all runtimes (platform admin only)")
|
|
626
689
|
|
|
627
690
|
# workspace
|
|
628
691
|
ws_p = sub.add_parser("workspace", help="Workspace management")
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: forgexa-cli
|
|
3
|
-
Version: 1.11.
|
|
3
|
+
Version: 1.11.5
|
|
4
4
|
Summary: Forgexa CLI — command-line client and AI agent runtime for the Forgexa platform
|
|
5
5
|
Author-email: Jason Sun <dev.winds@gmail.com>
|
|
6
6
|
License: MIT
|
|
@@ -100,9 +100,9 @@ forgexa board --project <project-id>
|
|
|
100
100
|
| `forgexa budget --workspace <id>` | Budget overview |
|
|
101
101
|
| `forgexa daemon start` | Start local daemon (discover agents, run tasks) |
|
|
102
102
|
| `forgexa daemon start -d` | Start daemon in background |
|
|
103
|
-
| `forgexa daemon status` | Show daemon statuses |
|
|
103
|
+
| `forgexa daemon status` | Show your daemon statuses |
|
|
104
104
|
| `forgexa daemon stop` | Stop local daemon |
|
|
105
|
-
| `forgexa runtimes list` | List runtimes |
|
|
105
|
+
| `forgexa runtimes list` | List your runtimes |
|
|
106
106
|
| `forgexa version` | Show CLI version |
|
|
107
107
|
|
|
108
108
|
## Configuration
|
|
@@ -144,11 +144,20 @@ forgexa-daemon
|
|
|
144
144
|
### Other Daemon Commands
|
|
145
145
|
|
|
146
146
|
```bash
|
|
147
|
-
# Check daemon status (from server)
|
|
147
|
+
# Check your daemon status (from server)
|
|
148
148
|
forgexa daemon status
|
|
149
149
|
|
|
150
|
+
# Platform admin: list all runtimes
|
|
151
|
+
forgexa daemon status --all
|
|
152
|
+
|
|
150
153
|
# Stop background daemon
|
|
151
154
|
forgexa daemon stop
|
|
155
|
+
|
|
156
|
+
# List your runtimes
|
|
157
|
+
forgexa runtimes list
|
|
158
|
+
|
|
159
|
+
# Platform admin: list all runtimes
|
|
160
|
+
forgexa runtimes list --all
|
|
152
161
|
```
|
|
153
162
|
|
|
154
163
|
### Supported AI Agents
|
|
@@ -10,4 +10,5 @@ forgexa_cli.egg-info/SOURCES.txt
|
|
|
10
10
|
forgexa_cli.egg-info/dependency_links.txt
|
|
11
11
|
forgexa_cli.egg-info/entry_points.txt
|
|
12
12
|
forgexa_cli.egg-info/requires.txt
|
|
13
|
-
forgexa_cli.egg-info/top_level.txt
|
|
13
|
+
forgexa_cli.egg-info/top_level.txt
|
|
14
|
+
tests/test_auth_and_runtime_commands.py
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import io
|
|
4
|
+
import json
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from types import SimpleNamespace
|
|
7
|
+
import urllib.error
|
|
8
|
+
|
|
9
|
+
import httpx
|
|
10
|
+
import pytest
|
|
11
|
+
|
|
12
|
+
from forgexa_cli import daemon, main
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class UrlopenResponse:
|
|
16
|
+
def __init__(self, payload):
|
|
17
|
+
self._payload = payload
|
|
18
|
+
|
|
19
|
+
def read(self) -> bytes:
|
|
20
|
+
return json.dumps(self._payload).encode("utf-8")
|
|
21
|
+
|
|
22
|
+
def __enter__(self):
|
|
23
|
+
return self
|
|
24
|
+
|
|
25
|
+
def __exit__(self, exc_type, exc, tb):
|
|
26
|
+
return False
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class HttpxResponse:
|
|
30
|
+
def __init__(self, status_code: int, payload: dict):
|
|
31
|
+
self.status_code = status_code
|
|
32
|
+
self._payload = payload
|
|
33
|
+
self.request = httpx.Request("POST", "https://api.example.com/api/v1/runtimes/register")
|
|
34
|
+
|
|
35
|
+
def json(self) -> dict:
|
|
36
|
+
return self._payload
|
|
37
|
+
|
|
38
|
+
def raise_for_status(self) -> None:
|
|
39
|
+
if self.status_code >= 400:
|
|
40
|
+
raise httpx.HTTPStatusError(
|
|
41
|
+
f"HTTP {self.status_code}",
|
|
42
|
+
request=self.request,
|
|
43
|
+
response=self,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _http_error(url: str, status_code: int, payload: dict) -> urllib.error.HTTPError:
|
|
48
|
+
return urllib.error.HTTPError(
|
|
49
|
+
url,
|
|
50
|
+
status_code,
|
|
51
|
+
payload.get("detail", "error"),
|
|
52
|
+
hdrs=None,
|
|
53
|
+
fp=io.BytesIO(json.dumps(payload).encode("utf-8")),
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def test_login_persists_refresh_token(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
58
|
+
monkeypatch.setenv("HOME", str(tmp_path))
|
|
59
|
+
monkeypatch.setattr(main, "_SERVER_URL_OVERRIDE", None)
|
|
60
|
+
monkeypatch.setattr(
|
|
61
|
+
main,
|
|
62
|
+
"_post",
|
|
63
|
+
lambda path, data=None: {
|
|
64
|
+
"access_token": "access-1",
|
|
65
|
+
"refresh_token": "refresh-1",
|
|
66
|
+
},
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
args = SimpleNamespace(
|
|
70
|
+
server="https://api.example.com",
|
|
71
|
+
email="user@example.com",
|
|
72
|
+
password="secret",
|
|
73
|
+
)
|
|
74
|
+
main.cmd_login(args)
|
|
75
|
+
|
|
76
|
+
cfg = json.loads((tmp_path / ".forgexa" / "config").read_text())
|
|
77
|
+
assert cfg["token"] == "access-1"
|
|
78
|
+
assert cfg["refresh_token"] == "refresh-1"
|
|
79
|
+
assert cfg["server_url"] == "https://api.example.com"
|
|
80
|
+
assert (tmp_path / ".forgexa" / "token").read_text() == "access-1"
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def test_get_retries_once_after_refresh_on_401(
|
|
84
|
+
tmp_path: Path,
|
|
85
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
86
|
+
) -> None:
|
|
87
|
+
monkeypatch.setenv("HOME", str(tmp_path))
|
|
88
|
+
monkeypatch.setattr(main, "_SERVER_URL_OVERRIDE", None)
|
|
89
|
+
config_dir = tmp_path / ".forgexa"
|
|
90
|
+
config_dir.mkdir()
|
|
91
|
+
(config_dir / "config").write_text(
|
|
92
|
+
json.dumps(
|
|
93
|
+
{
|
|
94
|
+
"server_url": "https://api.example.com",
|
|
95
|
+
"token": "old-access",
|
|
96
|
+
"refresh_token": "refresh-1",
|
|
97
|
+
}
|
|
98
|
+
)
|
|
99
|
+
)
|
|
100
|
+
(config_dir / "token").write_text("old-access")
|
|
101
|
+
|
|
102
|
+
import urllib.request
|
|
103
|
+
|
|
104
|
+
runtime_called = {"count": 0}
|
|
105
|
+
|
|
106
|
+
def fake_urlopen(req, timeout=0):
|
|
107
|
+
url = req.full_url
|
|
108
|
+
if url.endswith("/api/v1/runtimes/me"):
|
|
109
|
+
runtime_called["count"] += 1
|
|
110
|
+
if runtime_called["count"] == 1:
|
|
111
|
+
raise _http_error(url, 401, {"detail": "Invalid token"})
|
|
112
|
+
assert req.headers.get("Authorization") == "Bearer new-access"
|
|
113
|
+
return UrlopenResponse([
|
|
114
|
+
{
|
|
115
|
+
"id": "12345678",
|
|
116
|
+
"daemon_id": "demo-daemon",
|
|
117
|
+
"status": "online",
|
|
118
|
+
}
|
|
119
|
+
])
|
|
120
|
+
if url.endswith("/api/v1/auth/refresh"):
|
|
121
|
+
assert json.loads(req.data.decode("utf-8")) == {"refresh_token": "refresh-1"}
|
|
122
|
+
return UrlopenResponse(
|
|
123
|
+
{
|
|
124
|
+
"access_token": "new-access",
|
|
125
|
+
"refresh_token": "refresh-2",
|
|
126
|
+
}
|
|
127
|
+
)
|
|
128
|
+
raise AssertionError(f"Unexpected URL: {url}")
|
|
129
|
+
|
|
130
|
+
monkeypatch.setattr(urllib.request, "urlopen", fake_urlopen)
|
|
131
|
+
|
|
132
|
+
result = main._get("/runtimes/me")
|
|
133
|
+
|
|
134
|
+
assert isinstance(result, list)
|
|
135
|
+
assert runtime_called["count"] == 2
|
|
136
|
+
cfg = json.loads((config_dir / "config").read_text())
|
|
137
|
+
assert cfg["token"] == "new-access"
|
|
138
|
+
assert cfg["refresh_token"] == "refresh-2"
|
|
139
|
+
assert (config_dir / "token").read_text() == "new-access"
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def test_runtime_commands_use_user_scope_by_default(
|
|
143
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
144
|
+
) -> None:
|
|
145
|
+
calls: list[bool] = []
|
|
146
|
+
monkeypatch.setattr(
|
|
147
|
+
main,
|
|
148
|
+
"_get_runtimes",
|
|
149
|
+
lambda include_all=False: calls.append(include_all) or [],
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
main.cmd_daemon_status(SimpleNamespace(all=False))
|
|
153
|
+
main.cmd_runtimes_list(SimpleNamespace(all=False))
|
|
154
|
+
main.cmd_runtimes_list(SimpleNamespace(all=True))
|
|
155
|
+
|
|
156
|
+
assert calls == [False, False, True]
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
@pytest.mark.asyncio
|
|
160
|
+
async def test_daemon_refreshes_access_token_from_refresh_token(
|
|
161
|
+
tmp_path: Path,
|
|
162
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
163
|
+
) -> None:
|
|
164
|
+
monkeypatch.setenv("HOME", str(tmp_path))
|
|
165
|
+
config_dir = tmp_path / ".forgexa"
|
|
166
|
+
config_dir.mkdir()
|
|
167
|
+
(config_dir / "config").write_text(
|
|
168
|
+
json.dumps(
|
|
169
|
+
{
|
|
170
|
+
"token": "stale-access",
|
|
171
|
+
"refresh_token": "refresh-1",
|
|
172
|
+
}
|
|
173
|
+
)
|
|
174
|
+
)
|
|
175
|
+
(config_dir / "token").write_text("stale-access")
|
|
176
|
+
|
|
177
|
+
conn = daemon.ServerConnection("https://api.example.com", "stale-access", "daemon-1")
|
|
178
|
+
try:
|
|
179
|
+
async def fake_post(url, json=None, headers=None, timeout=None):
|
|
180
|
+
assert url.endswith("/api/v1/auth/refresh")
|
|
181
|
+
assert json == {"refresh_token": "refresh-1"}
|
|
182
|
+
return HttpxResponse(
|
|
183
|
+
200,
|
|
184
|
+
{
|
|
185
|
+
"access_token": "fresh-access",
|
|
186
|
+
"refresh_token": "refresh-2",
|
|
187
|
+
},
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
monkeypatch.setattr(conn.client, "post", fake_post)
|
|
191
|
+
|
|
192
|
+
refreshed = await conn.refresh_access_token()
|
|
193
|
+
|
|
194
|
+
assert refreshed is True
|
|
195
|
+
assert conn.api_token == "fresh-access"
|
|
196
|
+
assert conn.client.headers["Authorization"] == "Bearer fresh-access"
|
|
197
|
+
cfg = json.loads((config_dir / "config").read_text())
|
|
198
|
+
assert cfg["token"] == "fresh-access"
|
|
199
|
+
assert cfg["refresh_token"] == "refresh-2"
|
|
200
|
+
assert (config_dir / "token").read_text() == "fresh-access"
|
|
201
|
+
finally:
|
|
202
|
+
await conn.client.aclose()
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
@pytest.mark.asyncio
|
|
206
|
+
async def test_daemon_register_retries_once_after_refresh(
|
|
207
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
208
|
+
) -> None:
|
|
209
|
+
conn = daemon.ServerConnection("https://api.example.com", "stale-access", "daemon-1")
|
|
210
|
+
try:
|
|
211
|
+
async def fake_refresh_access_token() -> bool:
|
|
212
|
+
conn.api_token = "fresh-access"
|
|
213
|
+
conn.client.headers["Authorization"] = "Bearer fresh-access"
|
|
214
|
+
return True
|
|
215
|
+
|
|
216
|
+
responses = iter(
|
|
217
|
+
[
|
|
218
|
+
HttpxResponse(401, {"detail": "Invalid token"}),
|
|
219
|
+
HttpxResponse(200, {"runtime_id": "runtime-1"}),
|
|
220
|
+
]
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
async def fake_post(url, json=None, headers=None, timeout=None):
|
|
224
|
+
response = next(responses)
|
|
225
|
+
if response.status_code == 200:
|
|
226
|
+
assert json["api_token"] == "fresh-access"
|
|
227
|
+
return response
|
|
228
|
+
|
|
229
|
+
monkeypatch.setattr(conn, "refresh_access_token", fake_refresh_access_token)
|
|
230
|
+
monkeypatch.setattr(conn.client, "post", fake_post)
|
|
231
|
+
|
|
232
|
+
await conn.register([], 2)
|
|
233
|
+
|
|
234
|
+
assert conn.runtime_id == "runtime-1"
|
|
235
|
+
finally:
|
|
236
|
+
await conn.client.aclose()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|