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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: forgexa-cli
3
- Version: 1.11.3
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.3"
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.3"
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
- "clone", "--single-branch", "--no-tags",
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
- "clone", "--single-branch", "--no-tags",
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
- "clone", "--single-branch", "--no-tags",
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
- "clone", "--single-branch", "--no-tags",
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
- "clone", "--single-branch", "--no-tags",
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 refresh_token(self) -> bool:
4650
- """Re-read the user JWT from ~/.forgexa/token and update client headers.
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
- Returns True if a new token was loaded (different from current).
4653
- """
4847
+ def _load_token_from_disk(self) -> str:
4654
4848
  token_path = Path.home() / ".forgexa" / "token"
4655
4849
  try:
4656
- new_token = token_path.read_text().strip() if token_path.exists() else ""
4850
+ return token_path.read_text().strip() if token_path.exists() else ""
4657
4851
  except OSError:
4658
- new_token = ""
4852
+ return ""
4853
+
4854
+ async def refresh_access_token(self) -> bool:
4855
+ """Refresh the daemon's user session token.
4659
4856
 
4660
- if new_token and new_token != self.api_token:
4661
- self.api_token = new_token
4662
- self.client.headers["Authorization"] = f"Bearer {new_token}"
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
- return False
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.refresh_token()
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
- try:
4705
- resp = await self.client.post(
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
- output_content = (result.stdout or "")[-max_content:] if result.stdout else ""
6261
- scripts: dict = {}
6262
-
6263
- scenario_ids = input_ctx.get("scenario_ids", [])
6264
- if scenario_ids and output_content:
6265
- # Primary: extract scripts using structured SCRIPT_START/END markers
6266
- # inserted by poll_ai_jobs into the multi-scenario prompt.
6267
- import re as _re
6268
- for sid in scenario_ids:
6269
- pattern = (
6270
- r"##\s*SCRIPT_START::" + _re.escape(sid)
6271
- + r"\s*\n(.*?)\n##\s*SCRIPT_END::" + _re.escape(sid)
6272
- )
6273
- m = _re.search(pattern, output_content, _re.DOTALL)
6274
- if m:
6275
- scripts[sid] = m.group(1).strip()
6276
-
6277
- # Fallback: if no markers found but only one scenario, treat
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 _headers() -> dict[str, str]:
97
- h: dict[str, str] = {"Content-Type": "application/json"}
119
+ def _can_refresh_session() -> bool:
120
+ if os.environ.get("FORGEXA_TOKEN"):
121
+ return False
98
122
  token = _token()
99
- if token:
100
- h["Authorization"] = f"Bearer {token}"
101
- return h
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 _get(path: str) -> dict | list:
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{path}"
109
- req = urllib.request.Request(url, headers=_headers())
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
- return json.loads(resp.read())
113
- except urllib.error.HTTPError as e:
114
- body = e.read().decode(errors="replace")
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
- def _post(path: str, data: dict | None = None) -> dict:
123
- import urllib.request
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
- def _delete(path: str) -> dict | None:
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
- req = urllib.request.Request(url, headers=_headers(), method="DELETE")
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=15) as resp:
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
- body = e.read().decode(errors="replace")
153
- print(f"Error {e.code}: {body}", file=sys.stderr)
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
- # Save to config file (server_url + token in one place)
209
- cfg = _load_config()
210
- if server:
211
- cfg["server_url"] = _SERVER_URL_OVERRIDE
212
- cfg["token"] = token
213
- _save_config(cfg)
214
-
215
- # Also keep the legacy token file for backwards compatibility
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. Token removed.")
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 cmd_daemon_status(_args: argparse.Namespace) -> None:
277
- runtimes = _get("/runtimes")
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(_args: argparse.Namespace) -> None:
367
- runtimes = _get("/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 access token")
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 access token")
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 all daemon statuses (from server)")
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 all runtimes")
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
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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "forgexa-cli"
3
- version = "1.11.3"
3
+ version = "1.11.5"
4
4
  description = "Forgexa CLI — command-line client and AI agent runtime for the Forgexa platform"
5
5
  requires-python = ">=3.9"
6
6
  license = { text = "MIT" }
@@ -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