runtime-sdk 0.4.49__py3-none-win_amd64.whl
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.
- runtime_sdk/__init__.py +18 -0
- runtime_sdk/account.py +243 -0
- runtime_sdk/arguments.py +10 -0
- runtime_sdk/auth.py +175 -0
- runtime_sdk/cli.py +985 -0
- runtime_sdk/client.py +616 -0
- runtime_sdk/completion.py +348 -0
- runtime_sdk/computers.py +578 -0
- runtime_sdk/config.py +201 -0
- runtime_sdk/console.py +118 -0
- runtime_sdk/files.py +233 -0
- runtime_sdk/github.py +229 -0
- runtime_sdk/goals.py +368 -0
- runtime_sdk/output.py +69 -0
- runtime_sdk/providers.py +422 -0
- runtime_sdk/proxy.py +600 -0
- runtime_sdk/runtime-tui.exe +0 -0
- runtime_sdk/secrets.py +155 -0
- runtime_sdk/services.py +173 -0
- runtime_sdk/ssh.py +197 -0
- runtime_sdk/terminal.py +330 -0
- runtime_sdk-0.4.49.dist-info/METADATA +273 -0
- runtime_sdk-0.4.49.dist-info/RECORD +26 -0
- runtime_sdk-0.4.49.dist-info/WHEEL +5 -0
- runtime_sdk-0.4.49.dist-info/entry_points.txt +2 -0
- runtime_sdk-0.4.49.dist-info/top_level.txt +1 -0
runtime_sdk/github.py
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import contextlib
|
|
4
|
+
import re
|
|
5
|
+
import time
|
|
6
|
+
import webbrowser
|
|
7
|
+
|
|
8
|
+
from runtime_sdk import output as cli_output, terminal
|
|
9
|
+
from runtime_sdk.auth import authenticated_client
|
|
10
|
+
from runtime_sdk.client import RuntimeAPIError
|
|
11
|
+
from runtime_sdk.config import RuntimeConfig
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _coerce_github_repo(repo: str | None) -> str | None:
|
|
15
|
+
text = str(repo or "").strip()
|
|
16
|
+
if not text:
|
|
17
|
+
return None
|
|
18
|
+
if not re.fullmatch(r"[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+", text):
|
|
19
|
+
raise RuntimeAPIError("repo must be in owner/name format")
|
|
20
|
+
owner, name = text.split("/", 1)
|
|
21
|
+
return f"{owner}/{name}"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _coerce_git_branch(branch: str | None) -> str | None:
|
|
25
|
+
text = str(branch or "").strip()
|
|
26
|
+
if not text:
|
|
27
|
+
return None
|
|
28
|
+
if text == "@":
|
|
29
|
+
raise RuntimeAPIError("branch name is invalid")
|
|
30
|
+
if (
|
|
31
|
+
text.startswith("-")
|
|
32
|
+
or text.endswith("/")
|
|
33
|
+
or text.endswith(".")
|
|
34
|
+
or text.endswith(".lock")
|
|
35
|
+
):
|
|
36
|
+
raise RuntimeAPIError("branch name is invalid")
|
|
37
|
+
if ".." in text or "//" in text or "@{" in text:
|
|
38
|
+
raise RuntimeAPIError("branch name is invalid")
|
|
39
|
+
if any(ch in text for ch in (" ", "~", "^", ":", "?", "*", "[", "\\")):
|
|
40
|
+
raise RuntimeAPIError("branch name is invalid")
|
|
41
|
+
if any(ord(ch) < 32 or ord(ch) == 127 for ch in text):
|
|
42
|
+
raise RuntimeAPIError("branch name is invalid")
|
|
43
|
+
return text
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def handle_github_list(config: RuntimeConfig) -> int:
|
|
47
|
+
if (err := cli_output.require_auth(config)) is not None:
|
|
48
|
+
return err
|
|
49
|
+
client = authenticated_client(config)
|
|
50
|
+
return cli_output.report_success(client.list_github_installations())
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def handle_github_disconnect(config: RuntimeConfig, ref: str | None) -> int:
|
|
54
|
+
if (err := cli_output.require_auth(config)) is not None:
|
|
55
|
+
return err
|
|
56
|
+
resolved_ref = str(ref or "").strip()
|
|
57
|
+
if not resolved_ref:
|
|
58
|
+
if not cli_output.interactive():
|
|
59
|
+
return cli_output.report_error(
|
|
60
|
+
"github owner or installation id is required"
|
|
61
|
+
)
|
|
62
|
+
resolved_ref = cli_output.prompt_text("GitHub owner or installation id")
|
|
63
|
+
if not resolved_ref:
|
|
64
|
+
return cli_output.report_error(
|
|
65
|
+
"github owner or installation id is required"
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
client = authenticated_client(config)
|
|
69
|
+
installation_id: int | None = None
|
|
70
|
+
owner = resolved_ref
|
|
71
|
+
|
|
72
|
+
try:
|
|
73
|
+
installation_id = int(resolved_ref)
|
|
74
|
+
except ValueError:
|
|
75
|
+
payload = client.list_github_installations()
|
|
76
|
+
installations = payload.get("installations") or []
|
|
77
|
+
target_owner = resolved_ref.casefold()
|
|
78
|
+
match = next(
|
|
79
|
+
(
|
|
80
|
+
entry
|
|
81
|
+
for entry in installations
|
|
82
|
+
if isinstance(entry, dict)
|
|
83
|
+
and str(entry.get("github_account_login") or "").strip().casefold()
|
|
84
|
+
== target_owner
|
|
85
|
+
),
|
|
86
|
+
None,
|
|
87
|
+
)
|
|
88
|
+
if match is None:
|
|
89
|
+
return cli_output.report_error(
|
|
90
|
+
f"github installation not found: {resolved_ref}"
|
|
91
|
+
)
|
|
92
|
+
try:
|
|
93
|
+
installation_id = int(match.get("id"))
|
|
94
|
+
except (TypeError, ValueError):
|
|
95
|
+
return cli_output.report_error(
|
|
96
|
+
f"github installation is missing an id: {resolved_ref}"
|
|
97
|
+
)
|
|
98
|
+
owner = str(match.get("github_account_login") or resolved_ref)
|
|
99
|
+
|
|
100
|
+
if installation_id is None or installation_id <= 0:
|
|
101
|
+
return cli_output.report_error("installation id must be a positive number")
|
|
102
|
+
|
|
103
|
+
client.disconnect_github_installation(installation_id)
|
|
104
|
+
return cli_output.report_success(
|
|
105
|
+
{"id": installation_id, "owner": owner, "deleted": True}
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def handle_integrate_github(
|
|
110
|
+
config: RuntimeConfig,
|
|
111
|
+
provider: str,
|
|
112
|
+
*,
|
|
113
|
+
no_open: bool = False,
|
|
114
|
+
timeout_seconds: int = 180,
|
|
115
|
+
) -> int:
|
|
116
|
+
if (err := cli_output.require_auth(config)) is not None:
|
|
117
|
+
return err
|
|
118
|
+
if provider != "github":
|
|
119
|
+
return cli_output.report_error(f"unsupported integration provider: {provider}")
|
|
120
|
+
|
|
121
|
+
client = authenticated_client(config)
|
|
122
|
+
payload = client.start_github_connect()
|
|
123
|
+
install_url = str(payload.get("install_url") or "").strip()
|
|
124
|
+
session_id = int(payload.get("session_id") or 0)
|
|
125
|
+
if not install_url or session_id <= 0:
|
|
126
|
+
return cli_output.report_error("server did not return a GitHub install session")
|
|
127
|
+
|
|
128
|
+
opened = False
|
|
129
|
+
if not no_open:
|
|
130
|
+
with contextlib.suppress(Exception):
|
|
131
|
+
opened = bool(webbrowser.open(install_url))
|
|
132
|
+
|
|
133
|
+
if cli_output.interactive():
|
|
134
|
+
action = (
|
|
135
|
+
"opened in your browser" if opened else "copy this URL into your browser"
|
|
136
|
+
)
|
|
137
|
+
print(f"{action}: {install_url}")
|
|
138
|
+
print(
|
|
139
|
+
"Choose the GitHub account or organization and repository access. Waiting for completion..."
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
deadline = time.time() + max(1, int(timeout_seconds))
|
|
143
|
+
last_status = "pending"
|
|
144
|
+
while time.time() < deadline:
|
|
145
|
+
session = client.get_github_connect_session(session_id)
|
|
146
|
+
status = str(session.get("status") or "pending")
|
|
147
|
+
last_status = status
|
|
148
|
+
if status == "completed":
|
|
149
|
+
installation = session.get("installation") or {}
|
|
150
|
+
return cli_output.report_success(
|
|
151
|
+
{
|
|
152
|
+
"installation": installation,
|
|
153
|
+
"install_url": install_url,
|
|
154
|
+
"session_id": session_id,
|
|
155
|
+
}
|
|
156
|
+
)
|
|
157
|
+
if status in {"failed", "expired"}:
|
|
158
|
+
return cli_output.report_error(
|
|
159
|
+
str(session.get("error") or f"github connect {status}")
|
|
160
|
+
)
|
|
161
|
+
time.sleep(2)
|
|
162
|
+
|
|
163
|
+
return cli_output.report_error(
|
|
164
|
+
f"timed out waiting for GitHub install to finish (last status: {last_status})"
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def handle_switch(
|
|
169
|
+
config: RuntimeConfig,
|
|
170
|
+
repo: str | None,
|
|
171
|
+
branch: str | None,
|
|
172
|
+
*,
|
|
173
|
+
create_branch: bool = False,
|
|
174
|
+
base_branch: str | None = None,
|
|
175
|
+
) -> int:
|
|
176
|
+
if (err := cli_output.require_auth(config)) is not None:
|
|
177
|
+
return err
|
|
178
|
+
if base_branch is not None and not create_branch:
|
|
179
|
+
return cli_output.report_error("--from can only be used together with -c")
|
|
180
|
+
|
|
181
|
+
try:
|
|
182
|
+
resolved_repo = _coerce_github_repo(repo)
|
|
183
|
+
resolved_branch = _coerce_git_branch(branch)
|
|
184
|
+
except RuntimeAPIError as exc:
|
|
185
|
+
return cli_output.report_error(str(exc), status_code=exc.status_code)
|
|
186
|
+
|
|
187
|
+
if resolved_repo is None:
|
|
188
|
+
if not cli_output.interactive():
|
|
189
|
+
return cli_output.report_error("--repo is required")
|
|
190
|
+
resolved_repo = cli_output.prompt_text("GitHub repo (owner/name)")
|
|
191
|
+
try:
|
|
192
|
+
resolved_repo = _coerce_github_repo(resolved_repo)
|
|
193
|
+
except RuntimeAPIError as exc:
|
|
194
|
+
return cli_output.report_error(str(exc), status_code=exc.status_code)
|
|
195
|
+
if resolved_repo is None:
|
|
196
|
+
return cli_output.report_error("repo is required")
|
|
197
|
+
|
|
198
|
+
if resolved_branch is None:
|
|
199
|
+
if not cli_output.interactive():
|
|
200
|
+
return cli_output.report_error("branch is required")
|
|
201
|
+
prompt = "New branch name" if create_branch else "Branch name"
|
|
202
|
+
resolved_branch = cli_output.prompt_text(prompt)
|
|
203
|
+
try:
|
|
204
|
+
resolved_branch = _coerce_git_branch(resolved_branch)
|
|
205
|
+
except RuntimeAPIError as exc:
|
|
206
|
+
return cli_output.report_error(str(exc), status_code=exc.status_code)
|
|
207
|
+
if resolved_branch is None:
|
|
208
|
+
return cli_output.report_error("branch is required")
|
|
209
|
+
|
|
210
|
+
client = authenticated_client(config)
|
|
211
|
+
try:
|
|
212
|
+
payload = client.switch_workspace(
|
|
213
|
+
repo=resolved_repo,
|
|
214
|
+
branch=resolved_branch,
|
|
215
|
+
create=create_branch,
|
|
216
|
+
base_branch=base_branch,
|
|
217
|
+
)
|
|
218
|
+
except RuntimeAPIError as exc:
|
|
219
|
+
return cli_output.report_error(str(exc), status_code=exc.status_code)
|
|
220
|
+
|
|
221
|
+
computer = payload.get("computer") or {}
|
|
222
|
+
computer_id = str(computer.get("id") or "").strip()
|
|
223
|
+
if not computer_id:
|
|
224
|
+
return cli_output.report_error("server did not return a computer id")
|
|
225
|
+
|
|
226
|
+
code = cli_output.report_success(payload)
|
|
227
|
+
if code != 0 or not cli_output.interactive():
|
|
228
|
+
return code
|
|
229
|
+
return terminal.enter_computer(config, computer_id)
|
runtime_sdk/goals.py
ADDED
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import re
|
|
5
|
+
import time
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from runtime_sdk import computers, output as cli_output
|
|
10
|
+
from runtime_sdk.auth import authenticated_client
|
|
11
|
+
from runtime_sdk.client import RuntimeAPIError, RuntimeClient
|
|
12
|
+
from runtime_sdk.config import RuntimeConfig
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
GOAL_WATCH_DIR = "/home/runtime/.runtime/email-goals"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _goal_emit(tag: str, message: Any) -> None:
|
|
19
|
+
text = str(message or "").rstrip()
|
|
20
|
+
if not text:
|
|
21
|
+
return
|
|
22
|
+
lines = text.splitlines() or [text]
|
|
23
|
+
for index, line in enumerate(lines):
|
|
24
|
+
prefix = f"[{tag}] " if index == 0 else " " * (len(tag) + 3)
|
|
25
|
+
print(prefix + line, flush=True)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _goal_parse_time(value: Any) -> float:
|
|
29
|
+
if not value:
|
|
30
|
+
return 0.0
|
|
31
|
+
text = str(value)
|
|
32
|
+
match = re.match(r"^(.*T\d\d:\d\d:\d\d)(?:\.(\d+))?(Z|[+-]\d\d:\d\d)?$", text)
|
|
33
|
+
if match:
|
|
34
|
+
fraction = match.group(2) or ""
|
|
35
|
+
suffix = match.group(3) or ""
|
|
36
|
+
text = match.group(1)
|
|
37
|
+
if fraction:
|
|
38
|
+
text += "." + fraction[:6].ljust(6, "0")
|
|
39
|
+
text += suffix
|
|
40
|
+
try:
|
|
41
|
+
return datetime.fromisoformat(text.replace("Z", "+00:00")).timestamp()
|
|
42
|
+
except ValueError:
|
|
43
|
+
return 0.0
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _goal_read_text(client: RuntimeClient, computer_id: str, path: str) -> str | None:
|
|
47
|
+
try:
|
|
48
|
+
return client.read_file(computer_id, path).decode("utf-8", errors="replace")
|
|
49
|
+
except RuntimeAPIError as exc:
|
|
50
|
+
if exc.status_code == 404:
|
|
51
|
+
return None
|
|
52
|
+
raise
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _goal_read_json(
|
|
56
|
+
client: RuntimeClient, computer_id: str, path: str
|
|
57
|
+
) -> dict[str, Any] | None:
|
|
58
|
+
text = _goal_read_text(client, computer_id, path)
|
|
59
|
+
if not text:
|
|
60
|
+
return None
|
|
61
|
+
try:
|
|
62
|
+
payload = json.loads(text)
|
|
63
|
+
except ValueError:
|
|
64
|
+
return None
|
|
65
|
+
return payload if isinstance(payload, dict) else None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _goal_list_logs(client: RuntimeClient, computer_id: str) -> list[dict[str, Any]]:
|
|
69
|
+
try:
|
|
70
|
+
payload = client.list_files(computer_id, GOAL_WATCH_DIR)
|
|
71
|
+
except RuntimeAPIError as exc:
|
|
72
|
+
if exc.status_code == 404:
|
|
73
|
+
return []
|
|
74
|
+
raise
|
|
75
|
+
entries = payload.get("entries") or []
|
|
76
|
+
logs = [
|
|
77
|
+
entry
|
|
78
|
+
for entry in entries
|
|
79
|
+
if isinstance(entry, dict) and str(entry.get("name") or "").endswith(".log")
|
|
80
|
+
]
|
|
81
|
+
return sorted(logs, key=lambda entry: _goal_parse_time(entry.get("mtime")))
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _goal_run_json(
|
|
85
|
+
client: RuntimeClient, computer_id: str, command: str
|
|
86
|
+
) -> dict[str, Any] | None:
|
|
87
|
+
try:
|
|
88
|
+
result = client.run_command(
|
|
89
|
+
computer_id,
|
|
90
|
+
command,
|
|
91
|
+
uid=1000,
|
|
92
|
+
gid=1000,
|
|
93
|
+
cwd="/home/runtime",
|
|
94
|
+
shell="/bin/bash",
|
|
95
|
+
)
|
|
96
|
+
except RuntimeAPIError:
|
|
97
|
+
return None
|
|
98
|
+
if int(result.get("exit_code") or 0) != 0:
|
|
99
|
+
return None
|
|
100
|
+
try:
|
|
101
|
+
payload = json.loads(result.get("stdout") or "{}")
|
|
102
|
+
except ValueError:
|
|
103
|
+
return None
|
|
104
|
+
return payload if isinstance(payload, dict) else None
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _goal_run_text(client: RuntimeClient, computer_id: str, command: str) -> str:
|
|
108
|
+
try:
|
|
109
|
+
result = client.run_command(
|
|
110
|
+
computer_id,
|
|
111
|
+
command,
|
|
112
|
+
uid=1000,
|
|
113
|
+
gid=1000,
|
|
114
|
+
cwd="/home/runtime",
|
|
115
|
+
shell="/bin/bash",
|
|
116
|
+
)
|
|
117
|
+
except RuntimeAPIError:
|
|
118
|
+
return ""
|
|
119
|
+
if int(result.get("exit_code") or 0) != 0:
|
|
120
|
+
return ""
|
|
121
|
+
return str(result.get("stdout") or "").strip()
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _goal_current_email_ids(client: RuntimeClient, computer_id: str) -> set[str]:
|
|
125
|
+
payload = _goal_run_json(client, computer_id, "runtime-env email inbox --json")
|
|
126
|
+
emails = payload.get("emails") if isinstance(payload, dict) else []
|
|
127
|
+
return {
|
|
128
|
+
str(item.get("id") or "")
|
|
129
|
+
for item in emails or []
|
|
130
|
+
if isinstance(item, dict) and item.get("id")
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _goal_render_log_line(line: str, state: dict[str, Any]) -> None:
|
|
135
|
+
text = str(line or "").strip()
|
|
136
|
+
if not text:
|
|
137
|
+
return
|
|
138
|
+
mappings = (
|
|
139
|
+
("assistant final_answer: ", "agent-final"),
|
|
140
|
+
("assistant commentary: ", "agent"),
|
|
141
|
+
("assistant analysis: ", "agent"),
|
|
142
|
+
("assistant message: ", "agent"),
|
|
143
|
+
("assistant delta: ", "agent"),
|
|
144
|
+
("started command: ", "cmd"),
|
|
145
|
+
("command: ", "cmd"),
|
|
146
|
+
("command output delta: ", "out"),
|
|
147
|
+
("command output: ", "out"),
|
|
148
|
+
("file change: ", "file"),
|
|
149
|
+
("mcp tool: ", "mcp"),
|
|
150
|
+
("token usage: ", "usage"),
|
|
151
|
+
("codex turn started", "codex"),
|
|
152
|
+
("codex turn completed: ", "codex"),
|
|
153
|
+
)
|
|
154
|
+
for prefix, tag in mappings:
|
|
155
|
+
if text.startswith(prefix):
|
|
156
|
+
state["native_codex_stream_seen"] = True
|
|
157
|
+
value = text[len(prefix) :] if text != prefix.strip() else text
|
|
158
|
+
if tag == "cmd" and value.startswith("command: "):
|
|
159
|
+
value = value[len("command: ") :]
|
|
160
|
+
_goal_emit(tag, value)
|
|
161
|
+
return
|
|
162
|
+
_goal_emit("goal", text)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _goal_wait_for_target(
|
|
166
|
+
client: RuntimeClient,
|
|
167
|
+
computer_id: str,
|
|
168
|
+
*,
|
|
169
|
+
mode: str,
|
|
170
|
+
email_id: str | None,
|
|
171
|
+
baseline: set[str],
|
|
172
|
+
deadline: float | None,
|
|
173
|
+
) -> str:
|
|
174
|
+
if email_id:
|
|
175
|
+
_goal_emit("watch", f"waiting for {email_id}")
|
|
176
|
+
while True:
|
|
177
|
+
logs = _goal_list_logs(client, computer_id)
|
|
178
|
+
if any(entry.get("name") == f"{email_id}.log" for entry in logs):
|
|
179
|
+
return email_id
|
|
180
|
+
_goal_check_timeout(deadline)
|
|
181
|
+
time.sleep(0.75)
|
|
182
|
+
|
|
183
|
+
_goal_emit(
|
|
184
|
+
"watch",
|
|
185
|
+
"waiting for next email goal"
|
|
186
|
+
if mode == "next"
|
|
187
|
+
else "waiting for latest email goal",
|
|
188
|
+
)
|
|
189
|
+
while True:
|
|
190
|
+
logs = _goal_list_logs(client, computer_id)
|
|
191
|
+
if mode == "next":
|
|
192
|
+
logs = [
|
|
193
|
+
entry for entry in logs if str(entry.get("name") or "") not in baseline
|
|
194
|
+
]
|
|
195
|
+
if logs:
|
|
196
|
+
return str(logs[-1].get("name") or "").removesuffix(".log")
|
|
197
|
+
_goal_check_timeout(deadline)
|
|
198
|
+
time.sleep(0.75)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _goal_check_timeout(deadline: float | None) -> None:
|
|
202
|
+
if deadline is not None and time.time() >= deadline:
|
|
203
|
+
_goal_emit("watch", "timeout reached")
|
|
204
|
+
raise TimeoutError
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _goal_poll_mail(
|
|
208
|
+
client: RuntimeClient,
|
|
209
|
+
computer_id: str,
|
|
210
|
+
seen: set[str],
|
|
211
|
+
target_email_id: str,
|
|
212
|
+
started_at: float,
|
|
213
|
+
) -> None:
|
|
214
|
+
payload = _goal_run_json(client, computer_id, "runtime-env email inbox --json")
|
|
215
|
+
emails = payload.get("emails") if isinstance(payload, dict) else []
|
|
216
|
+
for item in sorted(
|
|
217
|
+
(email for email in emails or [] if isinstance(email, dict)),
|
|
218
|
+
key=lambda email: _goal_parse_time(email.get("created_at")),
|
|
219
|
+
):
|
|
220
|
+
item_id = str(item.get("id") or "")
|
|
221
|
+
if not item_id or item_id in seen:
|
|
222
|
+
continue
|
|
223
|
+
created = _goal_parse_time(item.get("created_at"))
|
|
224
|
+
if item_id != target_email_id and created and created < started_at - 5:
|
|
225
|
+
seen.add(item_id)
|
|
226
|
+
continue
|
|
227
|
+
seen.add(item_id)
|
|
228
|
+
direction = item.get("direction") or "email"
|
|
229
|
+
status = item.get("status") or ""
|
|
230
|
+
subject = item.get("subject") or ""
|
|
231
|
+
_goal_emit("mail", f"{direction} {status} {item_id}: {subject}".strip())
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _goal_poll_service(
|
|
235
|
+
client: RuntimeClient, computer_id: str, last_status: str
|
|
236
|
+
) -> str:
|
|
237
|
+
status = _goal_run_text(client, computer_id, "runtime-env service status")
|
|
238
|
+
if status and status != last_status:
|
|
239
|
+
_goal_emit("service", status)
|
|
240
|
+
return status
|
|
241
|
+
return last_status
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _goal_describe(client: RuntimeClient, computer_id: str, email_id: str) -> None:
|
|
245
|
+
metadata = _goal_read_json(client, computer_id, f"{GOAL_WATCH_DIR}/{email_id}.json")
|
|
246
|
+
if not metadata:
|
|
247
|
+
return
|
|
248
|
+
sender = metadata.get("from") or ""
|
|
249
|
+
to = ", ".join(metadata.get("to") or [])
|
|
250
|
+
subject = metadata.get("subject") or ""
|
|
251
|
+
_goal_emit("mail", f"received {email_id} from {sender} to {to}: {subject}")
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _goal_watch_polling(
|
|
255
|
+
client: RuntimeClient,
|
|
256
|
+
computer_id: str,
|
|
257
|
+
*,
|
|
258
|
+
mode: str,
|
|
259
|
+
email_id: str | None,
|
|
260
|
+
timeout: int,
|
|
261
|
+
) -> int:
|
|
262
|
+
started_at = time.time()
|
|
263
|
+
deadline = started_at + timeout if timeout > 0 else None
|
|
264
|
+
baseline_logs = (
|
|
265
|
+
{str(entry.get("name") or "") for entry in _goal_list_logs(client, computer_id)}
|
|
266
|
+
if mode == "next"
|
|
267
|
+
else set()
|
|
268
|
+
)
|
|
269
|
+
seen_emails = (
|
|
270
|
+
_goal_current_email_ids(client, computer_id) if mode == "next" else set()
|
|
271
|
+
)
|
|
272
|
+
state: dict[str, Any] = {"native_codex_stream_seen": False}
|
|
273
|
+
|
|
274
|
+
try:
|
|
275
|
+
target_email_id = _goal_wait_for_target(
|
|
276
|
+
client,
|
|
277
|
+
computer_id,
|
|
278
|
+
mode=mode,
|
|
279
|
+
email_id=email_id,
|
|
280
|
+
baseline=baseline_logs,
|
|
281
|
+
deadline=deadline,
|
|
282
|
+
)
|
|
283
|
+
except TimeoutError:
|
|
284
|
+
return 124
|
|
285
|
+
|
|
286
|
+
target_log = f"{GOAL_WATCH_DIR}/{target_email_id}.log"
|
|
287
|
+
target_response = f"{GOAL_WATCH_DIR}/{target_email_id}.response.md"
|
|
288
|
+
target_started_at = time.time()
|
|
289
|
+
offset = 0
|
|
290
|
+
response_printed = False
|
|
291
|
+
done_seen_at: float | None = None
|
|
292
|
+
last_mail_poll = 0.0
|
|
293
|
+
last_service_poll = 0.0
|
|
294
|
+
last_service_status = ""
|
|
295
|
+
|
|
296
|
+
_goal_emit("goal", f"watching {target_email_id}")
|
|
297
|
+
_goal_describe(client, computer_id, target_email_id)
|
|
298
|
+
|
|
299
|
+
while True:
|
|
300
|
+
try:
|
|
301
|
+
_goal_check_timeout(deadline)
|
|
302
|
+
except TimeoutError:
|
|
303
|
+
return 124
|
|
304
|
+
now = time.time()
|
|
305
|
+
|
|
306
|
+
text = _goal_read_text(client, computer_id, target_log) or ""
|
|
307
|
+
if len(text) < offset:
|
|
308
|
+
offset = 0
|
|
309
|
+
if len(text) > offset:
|
|
310
|
+
chunk = text[offset:]
|
|
311
|
+
offset = len(text)
|
|
312
|
+
for line in chunk.splitlines():
|
|
313
|
+
_goal_render_log_line(line, state)
|
|
314
|
+
|
|
315
|
+
if now - last_mail_poll >= 2:
|
|
316
|
+
_goal_poll_mail(
|
|
317
|
+
client, computer_id, seen_emails, target_email_id, target_started_at
|
|
318
|
+
)
|
|
319
|
+
last_mail_poll = now
|
|
320
|
+
|
|
321
|
+
if now - last_service_poll >= 2:
|
|
322
|
+
last_service_status = _goal_poll_service(
|
|
323
|
+
client, computer_id, last_service_status
|
|
324
|
+
)
|
|
325
|
+
last_service_poll = now
|
|
326
|
+
|
|
327
|
+
if not response_printed:
|
|
328
|
+
response = (
|
|
329
|
+
_goal_read_text(client, computer_id, target_response) or ""
|
|
330
|
+
).strip()
|
|
331
|
+
if response:
|
|
332
|
+
_goal_emit("done", response)
|
|
333
|
+
response_printed = True
|
|
334
|
+
done_seen_at = now
|
|
335
|
+
|
|
336
|
+
if done_seen_at is not None and now - done_seen_at >= 3:
|
|
337
|
+
return 0
|
|
338
|
+
|
|
339
|
+
time.sleep(0.75)
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def handle_goal_watch(
|
|
343
|
+
config: RuntimeConfig,
|
|
344
|
+
computer_id: str | None,
|
|
345
|
+
*,
|
|
346
|
+
latest: bool,
|
|
347
|
+
next_goal: bool,
|
|
348
|
+
email_id: str | None,
|
|
349
|
+
timeout: int,
|
|
350
|
+
) -> int:
|
|
351
|
+
if (err := cli_output.require_auth(config)) is not None:
|
|
352
|
+
return err
|
|
353
|
+
if not computer_id:
|
|
354
|
+
return cli_output.report_error("computer id is required")
|
|
355
|
+
if latest and next_goal:
|
|
356
|
+
return cli_output.report_error("--latest and --next cannot be used together")
|
|
357
|
+
if email_id and next_goal:
|
|
358
|
+
return cli_output.report_error("--email-id and --next cannot be used together")
|
|
359
|
+
if timeout < 0:
|
|
360
|
+
return cli_output.report_error("--timeout must be non-negative")
|
|
361
|
+
|
|
362
|
+
mode = "email" if email_id else "next" if next_goal else "latest"
|
|
363
|
+
|
|
364
|
+
client = authenticated_client(config)
|
|
365
|
+
computer_id = computers.resolve_computer_ref(client, computer_id)
|
|
366
|
+
return _goal_watch_polling(
|
|
367
|
+
client, computer_id, mode=mode, email_id=email_id, timeout=timeout
|
|
368
|
+
)
|
runtime_sdk/output.py
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import getpass
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from runtime_sdk.auth import has_credentials
|
|
10
|
+
from runtime_sdk.config import RuntimeConfig
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def interactive() -> bool:
|
|
14
|
+
if os.environ.get("RUNTIME_NO_TTY"):
|
|
15
|
+
return False
|
|
16
|
+
try:
|
|
17
|
+
return sys.stdin.isatty() and sys.stdout.isatty()
|
|
18
|
+
except (AttributeError, ValueError):
|
|
19
|
+
return False
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def write_json(payload: dict[str, Any], *, error: bool = False) -> int:
|
|
23
|
+
stream = sys.stderr if error else sys.stdout
|
|
24
|
+
stream.write(json.dumps(payload) + "\n")
|
|
25
|
+
stream.flush()
|
|
26
|
+
return 1 if error else 0
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def report_error(message: str, *, status_code: int | None = None) -> int:
|
|
30
|
+
payload: dict[str, Any] = {"success": False, "error": message}
|
|
31
|
+
if status_code is not None:
|
|
32
|
+
payload["status_code"] = status_code
|
|
33
|
+
return write_json(payload, error=True)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def report_success(payload: dict[str, Any]) -> int:
|
|
37
|
+
return write_json({"success": True, **payload})
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def require_auth(config: RuntimeConfig) -> int | None:
|
|
41
|
+
if has_credentials(config):
|
|
42
|
+
return None
|
|
43
|
+
return report_error("not logged in; run runtime login", status_code=401)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def require_interactive_session(config: RuntimeConfig) -> int | None:
|
|
47
|
+
if has_credentials(config, interactive=True):
|
|
48
|
+
return None
|
|
49
|
+
return report_error(
|
|
50
|
+
"interactive WorkOS login required; run runtime login", status_code=401
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def prompt_text(message: str, *, default: str | None = None) -> str | None:
|
|
55
|
+
suffix = f" [{default}]" if default else ""
|
|
56
|
+
answer = input(f"{message}{suffix}: ")
|
|
57
|
+
if not answer and default is not None:
|
|
58
|
+
answer = default
|
|
59
|
+
return answer.strip() or None
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def prompt_password(message: str) -> str | None:
|
|
63
|
+
answer = getpass.getpass(f"{message}: ")
|
|
64
|
+
return answer.strip() or None
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def prompt_typed_confirm(message: str, expected: str) -> bool:
|
|
68
|
+
answer = prompt_text(f"{message} (type {expected!r} to confirm)") or ""
|
|
69
|
+
return answer.strip().lower() == expected.strip().lower()
|