kiwi-code 0.0.30__tar.gz → 0.0.32__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.
- {kiwi_code-0.0.30 → kiwi_code-0.0.32}/PKG-INFO +1 -1
- {kiwi_code-0.0.30 → kiwi_code-0.0.32}/pyproject.toml +1 -1
- {kiwi_code-0.0.30 → kiwi_code-0.0.32}/src/kiwi_cli/cli.py +85 -11
- {kiwi_code-0.0.30 → kiwi_code-0.0.32}/src/kiwi_cli/client.py +8 -1
- {kiwi_code-0.0.30 → kiwi_code-0.0.32}/src/kiwi_tui/screens/dashboard.py +304 -53
- {kiwi_code-0.0.30 → kiwi_code-0.0.32}/uv.lock +1 -1
- {kiwi_code-0.0.30 → kiwi_code-0.0.32}/.github/workflows/publish.yml +0 -0
- {kiwi_code-0.0.30 → kiwi_code-0.0.32}/.github/workflows/test.yml +0 -0
- {kiwi_code-0.0.30 → kiwi_code-0.0.32}/.gitignore +0 -0
- {kiwi_code-0.0.30 → kiwi_code-0.0.32}/.python-version +0 -0
- {kiwi_code-0.0.30 → kiwi_code-0.0.32}/CLAUDE.md +0 -0
- {kiwi_code-0.0.30 → kiwi_code-0.0.32}/Makefile +0 -0
- {kiwi_code-0.0.30 → kiwi_code-0.0.32}/README.md +0 -0
- {kiwi_code-0.0.30 → kiwi_code-0.0.32}/src/kiwi_cli/__init__.py +0 -0
- {kiwi_code-0.0.30 → kiwi_code-0.0.32}/src/kiwi_cli/auth.py +0 -0
- {kiwi_code-0.0.30 → kiwi_code-0.0.32}/src/kiwi_cli/commands.py +0 -0
- {kiwi_code-0.0.30 → kiwi_code-0.0.32}/src/kiwi_cli/logger.py +0 -0
- {kiwi_code-0.0.30 → kiwi_code-0.0.32}/src/kiwi_cli/models.py +0 -0
- {kiwi_code-0.0.30 → kiwi_code-0.0.32}/src/kiwi_cli/runtime_manager.py +0 -0
- {kiwi_code-0.0.30 → kiwi_code-0.0.32}/src/kiwi_cli/server.py +0 -0
- {kiwi_code-0.0.30 → kiwi_code-0.0.32}/src/kiwi_runtime/__init__.py +0 -0
- {kiwi_code-0.0.30 → kiwi_code-0.0.32}/src/kiwi_runtime/__main__.py +0 -0
- {kiwi_code-0.0.30 → kiwi_code-0.0.32}/src/kiwi_runtime/main.py +0 -0
- {kiwi_code-0.0.30 → kiwi_code-0.0.32}/src/kiwi_runtime/snake_game/.gitignore +0 -0
- {kiwi_code-0.0.30 → kiwi_code-0.0.32}/src/kiwi_runtime/snake_game/requirements.txt +0 -0
- {kiwi_code-0.0.30 → kiwi_code-0.0.32}/src/kiwi_tui/__init__.py +0 -0
- {kiwi_code-0.0.30 → kiwi_code-0.0.32}/src/kiwi_tui/inline_file_picker.py +0 -0
- {kiwi_code-0.0.30 → kiwi_code-0.0.32}/src/kiwi_tui/main.py +0 -0
- {kiwi_code-0.0.30 → kiwi_code-0.0.32}/src/kiwi_tui/random_words.py +0 -0
- {kiwi_code-0.0.30 → kiwi_code-0.0.32}/src/kiwi_tui/runtime_agent.py +0 -0
- {kiwi_code-0.0.30 → kiwi_code-0.0.32}/src/kiwi_tui/screens/__init__.py +0 -0
- {kiwi_code-0.0.30 → kiwi_code-0.0.32}/src/kiwi_tui/screens/attach_content.py +0 -0
- {kiwi_code-0.0.30 → kiwi_code-0.0.32}/src/kiwi_tui/screens/command_result.py +0 -0
- {kiwi_code-0.0.30 → kiwi_code-0.0.32}/src/kiwi_tui/screens/file_browser.py +0 -0
- {kiwi_code-0.0.30 → kiwi_code-0.0.32}/src/kiwi_tui/screens/help.py +0 -0
- {kiwi_code-0.0.30 → kiwi_code-0.0.32}/src/kiwi_tui/screens/id_picker.py +0 -0
- {kiwi_code-0.0.30 → kiwi_code-0.0.32}/src/kiwi_tui/screens/login.py +0 -0
- {kiwi_code-0.0.30 → kiwi_code-0.0.32}/src/kiwi_tui/screens/runtime_cleanup.py +0 -0
- {kiwi_code-0.0.30 → kiwi_code-0.0.32}/src/kiwi_tui/screens/runtime_logs.py +0 -0
- {kiwi_code-0.0.30 → kiwi_code-0.0.32}/src/kiwi_tui/screens/slash_picker.py +0 -0
- {kiwi_code-0.0.30 → kiwi_code-0.0.32}/src/kiwi_tui/slash_commands.py +0 -0
- {kiwi_code-0.0.30 → kiwi_code-0.0.32}/src/kiwi_tui/status_words.py +0 -0
- {kiwi_code-0.0.30 → kiwi_code-0.0.32}/src/kiwi_tui/widgets.py +0 -0
- {kiwi_code-0.0.30 → kiwi_code-0.0.32}/test_hello.py +0 -0
- {kiwi_code-0.0.30 → kiwi_code-0.0.32}/tests/__init__.py +0 -0
- {kiwi_code-0.0.30 → kiwi_code-0.0.32}/tests/conftest.py +0 -0
- {kiwi_code-0.0.30 → kiwi_code-0.0.32}/tests/test_cli_help.py +0 -0
- {kiwi_code-0.0.30 → kiwi_code-0.0.32}/tests/test_imports.py +0 -0
- {kiwi_code-0.0.30 → kiwi_code-0.0.32}/tests/test_reexec_kiwi.py +0 -0
- {kiwi_code-0.0.30 → kiwi_code-0.0.32}/tests/test_runtime_log_trimming.py +0 -0
- {kiwi_code-0.0.30 → kiwi_code-0.0.32}/tests/test_tokens.py +0 -0
- {kiwi_code-0.0.30 → kiwi_code-0.0.32}/tests/test_tui_headless.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"""Typer CLI for Autobots — thin wrapper over commands.py."""
|
|
2
2
|
|
|
3
|
-
from typing import Optional
|
|
3
|
+
from typing import Optional, Callable
|
|
4
4
|
|
|
5
5
|
from loguru import logger
|
|
6
6
|
logger.remove() # Suppress loguru console output in CLI mode
|
|
@@ -55,18 +55,92 @@ def _backend_url() -> str:
|
|
|
55
55
|
return AppConfig().backend_url
|
|
56
56
|
|
|
57
57
|
|
|
58
|
+
def _http_status_from_lines(lines: list[str]) -> int | None:
|
|
59
|
+
"""Extract an HTTP status code from commands.py output lines.
|
|
60
|
+
|
|
61
|
+
commands.py returns errors in the form: ["Error: HTTP <code>"]
|
|
62
|
+
"""
|
|
63
|
+
if not lines:
|
|
64
|
+
return None
|
|
65
|
+
if len(lines) != 1:
|
|
66
|
+
return None
|
|
67
|
+
s = lines[0].strip()
|
|
68
|
+
prefix = "Error: HTTP "
|
|
69
|
+
if not s.startswith(prefix):
|
|
70
|
+
return None
|
|
71
|
+
try:
|
|
72
|
+
return int(s[len(prefix):].strip())
|
|
73
|
+
except Exception:
|
|
74
|
+
return None
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _force_refresh_tokens(tm: TokenManager) -> bool:
|
|
78
|
+
"""Force refresh tokens once and persist them."""
|
|
79
|
+
from .client import AutobotsClientWrapper
|
|
80
|
+
|
|
81
|
+
with tm.file_lock():
|
|
82
|
+
tokens = tm.load_tokens()
|
|
83
|
+
if not tokens:
|
|
84
|
+
return False
|
|
85
|
+
refresh = getattr(tokens, "refresh_token", None)
|
|
86
|
+
if not refresh:
|
|
87
|
+
return False
|
|
88
|
+
|
|
89
|
+
wrapper = AutobotsClientWrapper(
|
|
90
|
+
base_url=_backend_url(),
|
|
91
|
+
access_token=getattr(tokens, "access_token", None),
|
|
92
|
+
)
|
|
93
|
+
ok, new_tokens, _msg = wrapper.refresh_token(str(refresh))
|
|
94
|
+
if ok and new_tokens:
|
|
95
|
+
tm.save_tokens(new_tokens)
|
|
96
|
+
return True
|
|
97
|
+
return False
|
|
98
|
+
|
|
99
|
+
|
|
58
100
|
def _get_client() -> AuthenticatedClient:
|
|
101
|
+
"""Get an authenticated OpenAPI client.
|
|
102
|
+
|
|
103
|
+
If the access token is expired but a refresh token exists, attempt a refresh
|
|
104
|
+
once. This improves CLI reliability for long-lived sessions.
|
|
105
|
+
"""
|
|
59
106
|
tm = TokenManager()
|
|
60
|
-
|
|
107
|
+
tokens = tm.load_tokens()
|
|
108
|
+
if not tokens:
|
|
109
|
+
typer.echo("Not authenticated. Run `kiwi login` first.", err=True)
|
|
110
|
+
raise typer.Exit(code=1)
|
|
111
|
+
|
|
112
|
+
try:
|
|
113
|
+
if tokens.is_expired() and getattr(tokens, "refresh_token", None):
|
|
114
|
+
_force_refresh_tokens(tm)
|
|
115
|
+
tokens = tm.load_tokens() or tokens
|
|
116
|
+
except Exception:
|
|
117
|
+
pass
|
|
118
|
+
|
|
119
|
+
access = getattr(tokens, "access_token", None)
|
|
120
|
+
if not access:
|
|
61
121
|
typer.echo("Not authenticated. Run `kiwi login` first.", err=True)
|
|
62
122
|
raise typer.Exit(code=1)
|
|
123
|
+
|
|
63
124
|
return AuthenticatedClient(
|
|
64
125
|
base_url=_backend_url(),
|
|
65
|
-
token=
|
|
126
|
+
token=str(access),
|
|
66
127
|
raise_on_unexpected_status=False,
|
|
67
128
|
)
|
|
68
129
|
|
|
69
130
|
|
|
131
|
+
def _run_with_refresh_retry(run: Callable[[AuthenticatedClient], list[str]]) -> list[str]:
|
|
132
|
+
"""Run a CLI command with one forced refresh retry on 401/403."""
|
|
133
|
+
tm = TokenManager()
|
|
134
|
+
lines = run(_get_client())
|
|
135
|
+
code = _http_status_from_lines(lines)
|
|
136
|
+
if code in (401, 403):
|
|
137
|
+
try:
|
|
138
|
+
if _force_refresh_tokens(tm):
|
|
139
|
+
lines = run(_get_client())
|
|
140
|
+
except Exception:
|
|
141
|
+
pass
|
|
142
|
+
return lines
|
|
143
|
+
|
|
70
144
|
def _print(lines: list[str]) -> None:
|
|
71
145
|
for line in lines:
|
|
72
146
|
typer.echo(line)
|
|
@@ -81,12 +155,12 @@ def actions_list_cmd(
|
|
|
81
155
|
offset: int = typer.Option(0, help="Offset"),
|
|
82
156
|
):
|
|
83
157
|
"""List actions."""
|
|
84
|
-
_print(commands.actions_list(
|
|
158
|
+
_print(_run_with_refresh_retry(lambda c: commands.actions_list(c, name=name, limit=limit, offset=offset)))
|
|
85
159
|
|
|
86
160
|
@actions_app.command("get")
|
|
87
161
|
def actions_get_cmd(id: str = typer.Argument(help="Action ID")):
|
|
88
162
|
"""Get action details."""
|
|
89
|
-
_print(commands.actions_get(
|
|
163
|
+
_print(_run_with_refresh_retry(lambda c: commands.actions_get(c, id=id)))
|
|
90
164
|
|
|
91
165
|
|
|
92
166
|
# -- runs -------------------------------------------------------------------
|
|
@@ -100,12 +174,12 @@ def runs_list_cmd(
|
|
|
100
174
|
offset: int = typer.Option(0, help="Offset"),
|
|
101
175
|
):
|
|
102
176
|
"""List action runs (results)."""
|
|
103
|
-
_print(commands.runs_list(
|
|
177
|
+
_print(_run_with_refresh_retry(lambda c: commands.runs_list(c, action_id=action_id, action_name=action_name, status=status, limit=limit, offset=offset)))
|
|
104
178
|
|
|
105
179
|
@action_runs_app.command("get")
|
|
106
180
|
def runs_get_cmd(id: str = typer.Argument(help="Action run ID")):
|
|
107
181
|
"""Get action run details."""
|
|
108
|
-
_print(commands.runs_get(
|
|
182
|
+
_print(_run_with_refresh_retry(lambda c: commands.runs_get(c, id=id)))
|
|
109
183
|
|
|
110
184
|
|
|
111
185
|
# -- graphs -----------------------------------------------------------------
|
|
@@ -117,12 +191,12 @@ def graphs_list_cmd(
|
|
|
117
191
|
offset: int = typer.Option(0, help="Offset"),
|
|
118
192
|
):
|
|
119
193
|
"""List action graphs."""
|
|
120
|
-
_print(commands.graphs_list(
|
|
194
|
+
_print(_run_with_refresh_retry(lambda c: commands.graphs_list(c, name=name, limit=limit, offset=offset)))
|
|
121
195
|
|
|
122
196
|
@graphs_app.command("get")
|
|
123
197
|
def graphs_get_cmd(id: str = typer.Argument(help="Action graph ID")):
|
|
124
198
|
"""Get action graph details."""
|
|
125
|
-
_print(commands.graphs_get(
|
|
199
|
+
_print(_run_with_refresh_retry(lambda c: commands.graphs_get(c, id=id)))
|
|
126
200
|
|
|
127
201
|
|
|
128
202
|
# -- graph-runs -------------------------------------------------------------
|
|
@@ -136,12 +210,12 @@ def graph_runs_list_cmd(
|
|
|
136
210
|
offset: int = typer.Option(0, help="Offset"),
|
|
137
211
|
):
|
|
138
212
|
"""List action graph runs (results)."""
|
|
139
|
-
_print(commands.graph_runs_list(
|
|
213
|
+
_print(_run_with_refresh_retry(lambda c: commands.graph_runs_list(c, action_graph_id=graph_id, action_graph_name=graph_name, status=status, limit=limit, offset=offset)))
|
|
140
214
|
|
|
141
215
|
@graph_runs_app.command("get")
|
|
142
216
|
def graph_runs_get_cmd(id: str = typer.Argument(help="Graph run ID")):
|
|
143
217
|
"""Get action graph run details."""
|
|
144
|
-
_print(commands.graph_runs_get(
|
|
218
|
+
_print(_run_with_refresh_retry(lambda c: commands.graph_runs_get(c, id=id)))
|
|
145
219
|
|
|
146
220
|
|
|
147
221
|
# -- runtime ----------------------------------------------------------------
|
|
@@ -183,7 +183,14 @@ class AutobotsClientWrapper:
|
|
|
183
183
|
# even if `refresh_token` is provided as a query param.
|
|
184
184
|
url = f"{self.base_url.rstrip('/')}/v1/auth/session/refresh"
|
|
185
185
|
with httpx.Client(timeout=30) as client:
|
|
186
|
-
|
|
186
|
+
# Best-effort: send the (possibly expired) access token for anomaly tracking.
|
|
187
|
+
# The backend refresh endpoint must not *require* it.
|
|
188
|
+
headers: dict[str, str] = {}
|
|
189
|
+
if self.access_token:
|
|
190
|
+
headers["Authorization"] = f"Bearer {self.access_token}"
|
|
191
|
+
resp = client.post(
|
|
192
|
+
url, params={"refresh_token": refresh_token}, headers=headers
|
|
193
|
+
)
|
|
187
194
|
|
|
188
195
|
if resp.status_code != 200:
|
|
189
196
|
detail = (resp.text or "").strip()
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
from typing import Callable
|
|
5
6
|
from textual.app import ComposeResult
|
|
6
7
|
from textual.binding import Binding
|
|
7
8
|
from textual.screen import Screen
|
|
@@ -720,6 +721,36 @@ class DashboardScreen(Screen):
|
|
|
720
721
|
def show(body: str, *, is_error: bool = False, title: str | None = None) -> None:
|
|
721
722
|
self._show_command_result(title or command, body, is_error=is_error)
|
|
722
723
|
|
|
724
|
+
def toast(
|
|
725
|
+
message: str,
|
|
726
|
+
*,
|
|
727
|
+
severity: "SeverityLevel" = "information",
|
|
728
|
+
title: str = "",
|
|
729
|
+
timeout: float | None = 2.5,
|
|
730
|
+
) -> None:
|
|
731
|
+
"""Show a non-blocking toast notification.
|
|
732
|
+
|
|
733
|
+
Preferred for slash commands where the user doesn't need a full modal
|
|
734
|
+
(e.g. /new, /use, /continue).
|
|
735
|
+
"""
|
|
736
|
+
try:
|
|
737
|
+
# Screen inherits Widget, which provides notify() (delegates to App.notify).
|
|
738
|
+
self.notify(
|
|
739
|
+
message,
|
|
740
|
+
title=title,
|
|
741
|
+
severity=severity,
|
|
742
|
+
timeout=timeout,
|
|
743
|
+
markup=False,
|
|
744
|
+
)
|
|
745
|
+
except Exception:
|
|
746
|
+
# If toast notifications fail for any reason, fall back to the modal so
|
|
747
|
+
# the user still gets feedback.
|
|
748
|
+
self._show_command_result(
|
|
749
|
+
title or command, message, is_error=severity == "error"
|
|
750
|
+
)
|
|
751
|
+
|
|
752
|
+
|
|
753
|
+
|
|
723
754
|
# --- TUI-specific commands ---
|
|
724
755
|
|
|
725
756
|
if cmd == "/upload":
|
|
@@ -870,58 +901,62 @@ class DashboardScreen(Screen):
|
|
|
870
901
|
self.current_run_kind = None
|
|
871
902
|
self._update_run_status_bar()
|
|
872
903
|
self._clear_chat_messages()
|
|
873
|
-
|
|
904
|
+
toast("Started a new conversation.", title="/new")
|
|
874
905
|
return
|
|
875
906
|
if cmd == "/use":
|
|
876
907
|
if not args:
|
|
877
|
-
|
|
908
|
+
toast("Usage: /use <action_id>", title="/use", severity="warning")
|
|
878
909
|
return
|
|
879
910
|
action_id = args[0]
|
|
880
|
-
api_client = self._get_api_client_for_command(command)
|
|
881
|
-
if not api_client:
|
|
882
|
-
return
|
|
883
911
|
from kiwi_cli.commands import actions_get
|
|
884
|
-
lines = await
|
|
912
|
+
lines = await self._run_lines_with_refresh_retry(
|
|
913
|
+
command, lambda c: actions_get(c, id=action_id)
|
|
914
|
+
)
|
|
915
|
+
if lines is None:
|
|
916
|
+
return
|
|
885
917
|
if lines and lines[0].startswith("Error"):
|
|
886
|
-
|
|
918
|
+
toast(f"Action not found: {action_id}", title="/use", severity="error")
|
|
887
919
|
return
|
|
888
920
|
self.current_action_id = action_id
|
|
889
921
|
self.current_run_id = None
|
|
890
922
|
self.current_run_kind = None
|
|
891
923
|
self._update_run_status_bar()
|
|
892
924
|
self._clear_chat_messages()
|
|
893
|
-
|
|
925
|
+
toast(f"Switched to action {action_id}.", title="/use")
|
|
894
926
|
return
|
|
895
927
|
|
|
896
928
|
if cmd == "/continue":
|
|
897
929
|
if not args:
|
|
898
|
-
|
|
930
|
+
toast("Usage: /continue <run_id>", title="/continue", severity="warning")
|
|
899
931
|
return
|
|
900
932
|
run_id = args[0]
|
|
901
|
-
api_client = self._get_api_client_for_command(command)
|
|
902
|
-
if not api_client:
|
|
903
|
-
return
|
|
904
933
|
from kiwi_cli.commands import runs_get
|
|
905
|
-
lines = await
|
|
934
|
+
lines = await self._run_lines_with_refresh_retry(
|
|
935
|
+
command, lambda c: runs_get(c, id=run_id)
|
|
936
|
+
)
|
|
937
|
+
if lines is None:
|
|
938
|
+
return
|
|
906
939
|
if lines and lines[0].startswith("Error"):
|
|
907
|
-
|
|
940
|
+
toast(f"Run not found: {run_id}", title="/continue", severity="error")
|
|
908
941
|
return
|
|
909
942
|
self.current_run_id = run_id
|
|
910
943
|
self.current_run_kind = "action"
|
|
911
944
|
self._update_run_status_bar()
|
|
912
|
-
|
|
945
|
+
toast(f"Continuing run {run_id}...", title="/continue")
|
|
913
946
|
await self._load_conversation_history_async(run_id)
|
|
914
947
|
return
|
|
915
948
|
|
|
916
949
|
if cmd == "/name":
|
|
917
950
|
new_name = " ".join(args).strip()
|
|
918
951
|
if not new_name:
|
|
919
|
-
|
|
952
|
+
toast("Usage: /name <new name>", title="/name", severity="warning")
|
|
920
953
|
return
|
|
921
954
|
if not self.current_run_id:
|
|
922
|
-
|
|
923
|
-
"
|
|
924
|
-
|
|
955
|
+
toast(
|
|
956
|
+
"No run id yet. Send a message to start a run, or use /continue <run_id> (or pick one from /runs list).",
|
|
957
|
+
title="/name",
|
|
958
|
+
severity="error",
|
|
959
|
+
timeout=4,
|
|
925
960
|
)
|
|
926
961
|
return
|
|
927
962
|
|
|
@@ -987,10 +1022,15 @@ class DashboardScreen(Screen):
|
|
|
987
1022
|
|
|
988
1023
|
if status_code != 200:
|
|
989
1024
|
details = err or ""
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
1025
|
+
# Keep this as a toast (non-blocking). Truncate details to avoid huge toasts.
|
|
1026
|
+
details_short = (details or "").strip().replace("\n", " ")
|
|
1027
|
+
if len(details_short) > 160:
|
|
1028
|
+
details_short = details_short[:157] + "..."
|
|
1029
|
+
msg = f"Failed to rename run {run_id} (HTTP {status_code})."
|
|
1030
|
+
if details_short:
|
|
1031
|
+
msg += f" {details_short}"
|
|
1032
|
+
toast(msg, title="/name", severity="error", timeout=5)
|
|
1033
|
+
|
|
994
1034
|
return
|
|
995
1035
|
|
|
996
1036
|
# Best-effort: update local cached run name (used by the quit-time cleanup prompt).
|
|
@@ -1006,7 +1046,7 @@ class DashboardScreen(Screen):
|
|
|
1006
1046
|
except Exception:
|
|
1007
1047
|
pass
|
|
1008
1048
|
|
|
1009
|
-
|
|
1049
|
+
toast(f"Renamed run {run_id} to: {new_name}", title="/name", timeout=4)
|
|
1010
1050
|
return
|
|
1011
1051
|
|
|
1012
1052
|
if cmd == "/status":
|
|
@@ -1056,7 +1096,11 @@ class DashboardScreen(Screen):
|
|
|
1056
1096
|
|
|
1057
1097
|
from kiwi_cli.commands import dispatch
|
|
1058
1098
|
try:
|
|
1059
|
-
lines = await
|
|
1099
|
+
lines = await self._run_lines_with_refresh_retry(
|
|
1100
|
+
command, lambda c: dispatch(command, c)
|
|
1101
|
+
)
|
|
1102
|
+
if lines is None:
|
|
1103
|
+
return
|
|
1060
1104
|
show("\n".join(lines))
|
|
1061
1105
|
except Exception as e:
|
|
1062
1106
|
show(f"Command error: {e}", is_error=True)
|
|
@@ -1070,9 +1114,26 @@ class DashboardScreen(Screen):
|
|
|
1070
1114
|
"""Fetch and render conversation history without blocking the UI."""
|
|
1071
1115
|
if not hasattr(self.app, "autobots_client"):
|
|
1072
1116
|
return
|
|
1073
|
-
success, result,
|
|
1117
|
+
success, result, msg = await asyncio.to_thread(
|
|
1074
1118
|
lambda: self.app.autobots_client.get_action_result(run_id)
|
|
1075
1119
|
)
|
|
1120
|
+
if (not success) and msg:
|
|
1121
|
+
code = None
|
|
1122
|
+
try:
|
|
1123
|
+
m = re.search(r"(?:HTTP|status)\s*(\d{3})", str(msg))
|
|
1124
|
+
if m:
|
|
1125
|
+
code = int(m.group(1))
|
|
1126
|
+
except Exception:
|
|
1127
|
+
code = None
|
|
1128
|
+
if code in (401, 403):
|
|
1129
|
+
try:
|
|
1130
|
+
refreshed = bool(self.app._refresh_token_if_needed(force=True))
|
|
1131
|
+
except Exception:
|
|
1132
|
+
refreshed = False
|
|
1133
|
+
if refreshed:
|
|
1134
|
+
success, result, msg = await asyncio.to_thread(
|
|
1135
|
+
lambda: self.app.autobots_client.get_action_result(run_id)
|
|
1136
|
+
)
|
|
1076
1137
|
if not success or not result:
|
|
1077
1138
|
return
|
|
1078
1139
|
self._render_conversation_history_from_action_result(result)
|
|
@@ -1093,6 +1154,22 @@ class DashboardScreen(Screen):
|
|
|
1093
1154
|
offset=offset,
|
|
1094
1155
|
)
|
|
1095
1156
|
)
|
|
1157
|
+
if resp.status_code in (401, 403):
|
|
1158
|
+
# Reactive auth guard: force refresh and retry once.
|
|
1159
|
+
try:
|
|
1160
|
+
refreshed = bool(self.app._refresh_token_if_needed(force=True))
|
|
1161
|
+
except Exception:
|
|
1162
|
+
refreshed = False
|
|
1163
|
+
if refreshed:
|
|
1164
|
+
api_client = getattr(getattr(self.app, "autobots_client", None), "client", api_client)
|
|
1165
|
+
resp = await asyncio.to_thread(
|
|
1166
|
+
lambda: list_actions_v1_actions_get.sync_detailed(
|
|
1167
|
+
client=api_client,
|
|
1168
|
+
name=name if name else UNSET,
|
|
1169
|
+
limit=limit,
|
|
1170
|
+
offset=offset,
|
|
1171
|
+
)
|
|
1172
|
+
)
|
|
1096
1173
|
if resp.status_code != 200 or not resp.parsed:
|
|
1097
1174
|
self._show_command_result(command, f"Error: HTTP {resp.status_code}", is_error=True)
|
|
1098
1175
|
return
|
|
@@ -1147,6 +1224,23 @@ class DashboardScreen(Screen):
|
|
|
1147
1224
|
offset=int(opts.get("offset", "0")),
|
|
1148
1225
|
)
|
|
1149
1226
|
)
|
|
1227
|
+
if resp.status_code in (401, 403):
|
|
1228
|
+
try:
|
|
1229
|
+
refreshed = bool(self.app._refresh_token_if_needed(force=True))
|
|
1230
|
+
except Exception:
|
|
1231
|
+
refreshed = False
|
|
1232
|
+
if refreshed:
|
|
1233
|
+
api_client = getattr(getattr(self.app, "autobots_client", None), "client", api_client)
|
|
1234
|
+
resp = await asyncio.to_thread(
|
|
1235
|
+
lambda: list_action_result_v1_action_results_get.sync_detailed(
|
|
1236
|
+
client=api_client,
|
|
1237
|
+
action_id=opts.get("action_id") or UNSET,
|
|
1238
|
+
action_name=opts.get("action_name") or UNSET,
|
|
1239
|
+
status=status_enum,
|
|
1240
|
+
limit=int(opts.get("limit", "50")),
|
|
1241
|
+
offset=int(opts.get("offset", "0")),
|
|
1242
|
+
)
|
|
1243
|
+
)
|
|
1150
1244
|
if resp.status_code != 200 or not resp.parsed:
|
|
1151
1245
|
self._show_command_result(command, f"Error: HTTP {resp.status_code}", is_error=True)
|
|
1152
1246
|
return
|
|
@@ -1188,6 +1282,21 @@ class DashboardScreen(Screen):
|
|
|
1188
1282
|
offset=offset,
|
|
1189
1283
|
)
|
|
1190
1284
|
)
|
|
1285
|
+
if resp.status_code in (401, 403):
|
|
1286
|
+
try:
|
|
1287
|
+
refreshed = bool(self.app._refresh_token_if_needed(force=True))
|
|
1288
|
+
except Exception:
|
|
1289
|
+
refreshed = False
|
|
1290
|
+
if refreshed:
|
|
1291
|
+
api_client = getattr(getattr(self.app, "autobots_client", None), "client", api_client)
|
|
1292
|
+
resp = await asyncio.to_thread(
|
|
1293
|
+
lambda: list_action_graphs_v1_action_graphs_get.sync_detailed(
|
|
1294
|
+
client=api_client,
|
|
1295
|
+
name=name if name else UNSET,
|
|
1296
|
+
limit=limit,
|
|
1297
|
+
offset=offset,
|
|
1298
|
+
)
|
|
1299
|
+
)
|
|
1191
1300
|
if resp.status_code != 200 or not resp.parsed:
|
|
1192
1301
|
self._show_command_result(command, f"Error: HTTP {resp.status_code}", is_error=True)
|
|
1193
1302
|
return
|
|
@@ -1242,6 +1351,23 @@ class DashboardScreen(Screen):
|
|
|
1242
1351
|
offset=int(opts.get("offset", "0")),
|
|
1243
1352
|
)
|
|
1244
1353
|
)
|
|
1354
|
+
if resp.status_code in (401, 403):
|
|
1355
|
+
try:
|
|
1356
|
+
refreshed = bool(self.app._refresh_token_if_needed(force=True))
|
|
1357
|
+
except Exception:
|
|
1358
|
+
refreshed = False
|
|
1359
|
+
if refreshed:
|
|
1360
|
+
api_client = getattr(getattr(self.app, "autobots_client", None), "client", api_client)
|
|
1361
|
+
resp = await asyncio.to_thread(
|
|
1362
|
+
lambda: list_action_graph_result_v1_action_graphs_results_get.sync_detailed(
|
|
1363
|
+
client=api_client,
|
|
1364
|
+
action_graph_id=opts.get("graph_id") or UNSET,
|
|
1365
|
+
action_graph_name=opts.get("graph_name") or UNSET,
|
|
1366
|
+
status=status_enum,
|
|
1367
|
+
limit=int(opts.get("limit", "50")),
|
|
1368
|
+
offset=int(opts.get("offset", "0")),
|
|
1369
|
+
)
|
|
1370
|
+
)
|
|
1245
1371
|
if resp.status_code != 200 or not resp.parsed:
|
|
1246
1372
|
self._show_command_result(command, f"Error: HTTP {resp.status_code}", is_error=True)
|
|
1247
1373
|
return
|
|
@@ -1302,6 +1428,47 @@ class DashboardScreen(Screen):
|
|
|
1302
1428
|
return api_client
|
|
1303
1429
|
|
|
1304
1430
|
|
|
1431
|
+
|
|
1432
|
+
def _http_status_from_lines(self, lines: list[str]) -> int | None:
|
|
1433
|
+
"""Extract status code from a commands.py-style error list."""
|
|
1434
|
+
if not lines:
|
|
1435
|
+
return None
|
|
1436
|
+
if len(lines) != 1:
|
|
1437
|
+
return None
|
|
1438
|
+
s = str(lines[0]).strip()
|
|
1439
|
+
m = re.match(r"^Error:\s*HTTP\s*(\d{3})\s*$", s)
|
|
1440
|
+
if not m:
|
|
1441
|
+
return None
|
|
1442
|
+
try:
|
|
1443
|
+
return int(m.group(1))
|
|
1444
|
+
except Exception:
|
|
1445
|
+
return None
|
|
1446
|
+
|
|
1447
|
+
|
|
1448
|
+
async def _run_lines_with_refresh_retry(
|
|
1449
|
+
self,
|
|
1450
|
+
title: str,
|
|
1451
|
+
fn: Callable[[object], list[str]],
|
|
1452
|
+
) -> list[str] | None:
|
|
1453
|
+
"""Run a slash-command lines function with one refresh+retry on 401/403."""
|
|
1454
|
+
api_client = self._get_api_client_for_command(title)
|
|
1455
|
+
if not api_client:
|
|
1456
|
+
return None
|
|
1457
|
+
lines = await asyncio.to_thread(lambda: fn(api_client))
|
|
1458
|
+
code = self._http_status_from_lines(lines)
|
|
1459
|
+
if code in (401, 403):
|
|
1460
|
+
refreshed = False
|
|
1461
|
+
try:
|
|
1462
|
+
refreshed = bool(self.app._refresh_token_if_needed(force=True))
|
|
1463
|
+
except Exception:
|
|
1464
|
+
refreshed = False
|
|
1465
|
+
if refreshed:
|
|
1466
|
+
api_client = self._get_api_client_for_command(title)
|
|
1467
|
+
if not api_client:
|
|
1468
|
+
return lines
|
|
1469
|
+
lines = await asyncio.to_thread(lambda: fn(api_client))
|
|
1470
|
+
return lines
|
|
1471
|
+
|
|
1305
1472
|
def _parse_command_opts(self, command: str, skip: int = 2) -> dict[str, str]:
|
|
1306
1473
|
"""Parse --key value pairs from a slash command string."""
|
|
1307
1474
|
parts = command.strip().split()
|
|
@@ -1543,11 +1710,12 @@ class DashboardScreen(Screen):
|
|
|
1543
1710
|
|
|
1544
1711
|
async def _on_action_picked_async(self, action_id: str) -> None:
|
|
1545
1712
|
try:
|
|
1546
|
-
api_client = self._get_api_client_for_command("/actions list")
|
|
1547
|
-
if not api_client:
|
|
1548
|
-
return
|
|
1549
1713
|
from kiwi_cli.commands import actions_get
|
|
1550
|
-
lines = await
|
|
1714
|
+
lines = await self._run_lines_with_refresh_retry(
|
|
1715
|
+
"/actions list", lambda c: actions_get(c, id=action_id)
|
|
1716
|
+
)
|
|
1717
|
+
if lines is None:
|
|
1718
|
+
return
|
|
1551
1719
|
if lines and lines[0].startswith("Error"):
|
|
1552
1720
|
self._show_command_result("Action selection", f"Action not found: {action_id}", is_error=True)
|
|
1553
1721
|
return
|
|
@@ -1578,11 +1746,12 @@ class DashboardScreen(Screen):
|
|
|
1578
1746
|
|
|
1579
1747
|
async def _on_run_picked_async(self, run_id: str) -> None:
|
|
1580
1748
|
try:
|
|
1581
|
-
api_client = self._get_api_client_for_command("/runs list")
|
|
1582
|
-
if not api_client:
|
|
1583
|
-
return
|
|
1584
1749
|
from kiwi_cli.commands import runs_get
|
|
1585
|
-
lines = await
|
|
1750
|
+
lines = await self._run_lines_with_refresh_retry(
|
|
1751
|
+
"/runs list", lambda c: runs_get(c, id=run_id)
|
|
1752
|
+
)
|
|
1753
|
+
if lines is None:
|
|
1754
|
+
return
|
|
1586
1755
|
if lines and lines[0].startswith("Error"):
|
|
1587
1756
|
self._show_command_result("Run selection", f"Run not found: {run_id}", is_error=True)
|
|
1588
1757
|
return
|
|
@@ -1612,11 +1781,12 @@ class DashboardScreen(Screen):
|
|
|
1612
1781
|
|
|
1613
1782
|
async def _on_graph_picked_async(self, graph_id: str) -> None:
|
|
1614
1783
|
try:
|
|
1615
|
-
api_client = self._get_api_client_for_command("/graphs list")
|
|
1616
|
-
if not api_client:
|
|
1617
|
-
return
|
|
1618
1784
|
from kiwi_cli.commands import graphs_get
|
|
1619
|
-
lines = await
|
|
1785
|
+
lines = await self._run_lines_with_refresh_retry(
|
|
1786
|
+
"/graphs list", lambda c: graphs_get(c, id=graph_id)
|
|
1787
|
+
)
|
|
1788
|
+
if lines is None:
|
|
1789
|
+
return
|
|
1620
1790
|
if lines and lines[0].startswith("Error"):
|
|
1621
1791
|
self._show_command_result("Graph selection", f"Graph not found: {graph_id}", is_error=True)
|
|
1622
1792
|
return
|
|
@@ -1642,11 +1812,12 @@ class DashboardScreen(Screen):
|
|
|
1642
1812
|
|
|
1643
1813
|
async def _on_graph_run_picked_async(self, graph_run_id: str) -> None:
|
|
1644
1814
|
try:
|
|
1645
|
-
api_client = self._get_api_client_for_command("/graph-runs list")
|
|
1646
|
-
if not api_client:
|
|
1647
|
-
return
|
|
1648
1815
|
from kiwi_cli.commands import graph_runs_get
|
|
1649
|
-
lines = await
|
|
1816
|
+
lines = await self._run_lines_with_refresh_retry(
|
|
1817
|
+
"/graph-runs list", lambda c: graph_runs_get(c, id=graph_run_id)
|
|
1818
|
+
)
|
|
1819
|
+
if lines is None:
|
|
1820
|
+
return
|
|
1650
1821
|
if lines and lines[0].startswith("Error"):
|
|
1651
1822
|
self._show_command_result("Graph run selection", f"Graph run not found: {graph_run_id}", is_error=True)
|
|
1652
1823
|
return
|
|
@@ -2188,17 +2359,48 @@ class DashboardScreen(Screen):
|
|
|
2188
2359
|
return
|
|
2189
2360
|
|
|
2190
2361
|
if not success:
|
|
2191
|
-
|
|
2192
|
-
#
|
|
2362
|
+
# Reactive auth guard: if the request failed with 401/403, force a token
|
|
2363
|
+
# refresh and retry once before surfacing the error.
|
|
2364
|
+
code = None
|
|
2193
2365
|
try:
|
|
2194
|
-
|
|
2195
|
-
if
|
|
2196
|
-
|
|
2366
|
+
m = re.search(r"(?:HTTP|status)\s*(\d{3})", str(message or ""))
|
|
2367
|
+
if m:
|
|
2368
|
+
code = int(m.group(1))
|
|
2197
2369
|
except Exception:
|
|
2198
|
-
|
|
2199
|
-
|
|
2200
|
-
|
|
2201
|
-
|
|
2370
|
+
code = None
|
|
2371
|
+
|
|
2372
|
+
if code in (401, 403):
|
|
2373
|
+
try:
|
|
2374
|
+
refreshed = bool(self.app._refresh_token_if_needed(force=True))
|
|
2375
|
+
except Exception:
|
|
2376
|
+
refreshed = False
|
|
2377
|
+
if refreshed:
|
|
2378
|
+
try:
|
|
2379
|
+
success, run_id, message = await loop.run_in_executor(
|
|
2380
|
+
None,
|
|
2381
|
+
lambda: client.run_action_async(
|
|
2382
|
+
self.current_action_id,
|
|
2383
|
+
user_input,
|
|
2384
|
+
action_result_id=self.current_run_id,
|
|
2385
|
+
urls=urls if urls else None,
|
|
2386
|
+
metadata=self._metadata if self._metadata else None,
|
|
2387
|
+
),
|
|
2388
|
+
)
|
|
2389
|
+
except Exception:
|
|
2390
|
+
pass
|
|
2391
|
+
|
|
2392
|
+
if not success:
|
|
2393
|
+
self.add_message(f"Error starting action: {message}", "error")
|
|
2394
|
+
# Remove the placeholder row (no run was started).
|
|
2395
|
+
try:
|
|
2396
|
+
w = getattr(self, "_streaming_widget_ref", None)
|
|
2397
|
+
if w:
|
|
2398
|
+
w.remove()
|
|
2399
|
+
except Exception:
|
|
2400
|
+
pass
|
|
2401
|
+
self._streaming_widget_ref = None
|
|
2402
|
+
self._set_streaming(False)
|
|
2403
|
+
return
|
|
2202
2404
|
|
|
2203
2405
|
# Check if this is continuing an existing conversation
|
|
2204
2406
|
if self.current_run_id and run_id == self.current_run_id:
|
|
@@ -2312,17 +2514,51 @@ class DashboardScreen(Screen):
|
|
|
2312
2514
|
if got_final_result:
|
|
2313
2515
|
return True
|
|
2314
2516
|
|
|
2517
|
+
auth_failed_code: int | None = None
|
|
2518
|
+
|
|
2315
2519
|
try:
|
|
2316
2520
|
async with fetch_lock:
|
|
2317
2521
|
if got_final_result:
|
|
2318
2522
|
return True
|
|
2319
|
-
success, final_result,
|
|
2523
|
+
success, final_result, message = await asyncio.to_thread(
|
|
2320
2524
|
client.get_action_result, run_id
|
|
2321
2525
|
)
|
|
2526
|
+
|
|
2527
|
+
if not success:
|
|
2528
|
+
code = None
|
|
2529
|
+
try:
|
|
2530
|
+
m = re.search(r"(?:HTTP|status)\s*(\d{3})", str(message or ""))
|
|
2531
|
+
if m:
|
|
2532
|
+
code = int(m.group(1))
|
|
2533
|
+
except Exception:
|
|
2534
|
+
code = None
|
|
2535
|
+
|
|
2536
|
+
if code in (401, 403):
|
|
2537
|
+
try:
|
|
2538
|
+
refreshed = bool(self.app._refresh_token_if_needed(force=True))
|
|
2539
|
+
except Exception:
|
|
2540
|
+
refreshed = False
|
|
2541
|
+
if refreshed:
|
|
2542
|
+
success, final_result, message = await asyncio.to_thread(
|
|
2543
|
+
client.get_action_result, run_id
|
|
2544
|
+
)
|
|
2545
|
+
if (not success) and (code in (401, 403)):
|
|
2546
|
+
auth_failed_code = code
|
|
2322
2547
|
except Exception as e:
|
|
2323
2548
|
logger.warning(f"Poll error: {e}")
|
|
2324
2549
|
return False
|
|
2325
2550
|
|
|
2551
|
+
|
|
2552
|
+
if auth_failed_code in (401, 403):
|
|
2553
|
+
self.add_message(
|
|
2554
|
+
f"Authentication error while fetching result (HTTP {auth_failed_code}). Please /login again.",
|
|
2555
|
+
"error",
|
|
2556
|
+
)
|
|
2557
|
+
got_final_result = True
|
|
2558
|
+
# Stop spinner/blink immediately when we have a terminal state.
|
|
2559
|
+
self._set_streaming(False)
|
|
2560
|
+
return True
|
|
2561
|
+
|
|
2326
2562
|
if not success or not final_result:
|
|
2327
2563
|
return False
|
|
2328
2564
|
|
|
@@ -2613,7 +2849,22 @@ class DashboardScreen(Screen):
|
|
|
2613
2849
|
"""
|
|
2614
2850
|
if not hasattr(self.app, "autobots_client"):
|
|
2615
2851
|
return
|
|
2616
|
-
success, result,
|
|
2852
|
+
success, result, msg = self.app.autobots_client.get_action_result(run_id)
|
|
2853
|
+
if (not success) and msg:
|
|
2854
|
+
code = None
|
|
2855
|
+
try:
|
|
2856
|
+
m = re.search(r"(?:HTTP|status)\s*(\d{3})", str(msg))
|
|
2857
|
+
if m:
|
|
2858
|
+
code = int(m.group(1))
|
|
2859
|
+
except Exception:
|
|
2860
|
+
code = None
|
|
2861
|
+
if code in (401, 403):
|
|
2862
|
+
try:
|
|
2863
|
+
refreshed = bool(self.app._refresh_token_if_needed(force=True))
|
|
2864
|
+
except Exception:
|
|
2865
|
+
refreshed = False
|
|
2866
|
+
if refreshed:
|
|
2867
|
+
success, result, msg = self.app.autobots_client.get_action_result(run_id)
|
|
2617
2868
|
if not success or not result:
|
|
2618
2869
|
return
|
|
2619
2870
|
self._render_conversation_history_from_action_result(result)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|