Skip to content

Commit 114a204

Browse files
robert-ursuclaude
andcommitted
feat(cli): dispatch jobs asynchronously and push results/logs to the caller
`uipath server` used to block for a whole job and leave its outcome on disk for the caller to find. StartJob now enqueues the work and returns; the server pushes logs while the job runs and the terminal result when it finishes. Behaviour is a pure function of the request: a caller that supplies `resultCallbackSocket` gets async dispatch, one that does not gets the original blocking call, byte for byte. No handshake, no capability gate on the dispatch path -- an older caller cannot send the field and could not serve the callback if it did. POST {callback}/api/python/jobs/{jobKey}/result POST {callback}/api/python/jobs/{jobKey}/logs The readiness ACK advertises protocolVersion + capabilities so the caller knows whether logs will be forwarded before it decides to tail the file itself. Execution stays serialised behind the process-wide lock: a job mutates process globals (logging handlers, OTel providers, env, cwd), so queueing changes who waits, not how many run. Also fixes two pre-existing defects that made the API result meaningless: _run_command_isolated hardcoded ExitCode 0 while click RETURNS ctx.exit(N)'s code under standalone_mode=False, so every ConsoleLogger.error path reported success; and the HTTP body now carries exitCode, which is the field the un-upgraded .NET handler already reads. Notes for review: - Server diagnostics go to a stderr handle bound at import, NOT ConsoleLogger. ConsoleLogger resolves sys.stdout at call time, and the runtime's interceptor has replaced it with a writer feeding the job's execution.log -- which the tailer then reads and posts back. With the callback down that is a self-feeding loop. - A 4xx from the callback is REJECTED, not UNREACHABLE: the caller is up and has moved on, so retrying cannot help and must not trip the shutdown path. - The log tailer holds back an unterminated tail; a handler writes the record and only then flushes, so a poll can otherwise split one line into two entries that cannot be rejoined. - Logs are tailed from the file rather than captured via a handler: the runtime's log interceptor strips every handler but its own, and uipath-runtime ships on its own release train. Stopping a job that is already executing is deliberately NOT in this change -- it follows on top. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 6de5bed commit 114a204

9 files changed

Lines changed: 1796 additions & 26 deletions

File tree

packages/uipath/src/uipath/_cli/_server_core.py

Lines changed: 129 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"""Transport-agnostic job core shared by the HTTP and uipath-ipc channels."""
22

33
import asyncio
4+
import json
45
import os
56
import shlex
67
from typing import Any
@@ -45,6 +46,127 @@ def parse_args(args: str | list[str] | None) -> list[str]:
4546
return []
4647

4748

49+
# The document is carried inline on the result push when it fits. uipath_ipc caps a
50+
# frame at 2 MiB and Orchestrator already spills large outputs to an attachment, so
51+
# anything bigger stays on disk and the caller reads the file as it always has.
52+
MAX_INLINE_DOCUMENT_BYTES = 1024 * 1024
53+
54+
DEFAULT_RUNTIME_DIR = "__uipath"
55+
DEFAULT_RESULT_FILE = "output.json"
56+
DEFAULT_LOGS_FILE = "execution.log"
57+
58+
59+
def _resolve_runtime_file(
60+
config_path: str, base_dir: str, key: str, default_name: str
61+
) -> str | None:
62+
"""Resolve one ``runtime.*`` file path from a uipath.json.
63+
64+
Mirrors UiPathRuntimeContext.from_config's ``runtime.dir`` / ``runtime.<key>``
65+
mapping. ``base_dir`` anchors relative paths so this works without ever changing
66+
the process's cwd.
67+
"""
68+
if not os.path.isabs(config_path):
69+
config_path = os.path.join(base_dir, config_path)
70+
71+
runtime: dict[str, Any] = {}
72+
try:
73+
with open(config_path, encoding="utf-8") as f:
74+
loaded = json.load(f)
75+
if isinstance(loaded, dict):
76+
runtime = loaded.get("runtime") or {}
77+
except (OSError, json.JSONDecodeError, UnicodeDecodeError):
78+
# Fall through to the runtime's own defaults rather than giving up. Returning
79+
# None here would stop the log tailer from starting at all — and by then the
80+
# caller has already been told we forward logs and stopped tailing the file
81+
# itself, so the job would produce no logs anywhere.
82+
runtime = {}
83+
84+
directory = runtime.get("dir") or DEFAULT_RUNTIME_DIR
85+
name = runtime.get(key) or default_name
86+
if not isinstance(directory, str) or not isinstance(name, str):
87+
directory, name = DEFAULT_RUNTIME_DIR, default_name
88+
89+
if not os.path.isabs(directory):
90+
directory = os.path.join(base_dir, directory)
91+
return os.path.abspath(os.path.join(directory, name))
92+
93+
94+
def resolve_result_file_path() -> str | None:
95+
"""Where this job's terminal document lives. Call inside the job's env and cwd."""
96+
return _resolve_runtime_file(
97+
os.environ.get("UIPATH_CONFIG_PATH", "uipath.json"),
98+
os.getcwd(),
99+
"outputFile",
100+
DEFAULT_RESULT_FILE,
101+
)
102+
103+
104+
def resolve_logs_file_path(
105+
env_vars: dict[str, str] | None, working_dir: str | None
106+
) -> str | None:
107+
"""Where this job will write its log file, derived from the request alone.
108+
109+
Pure with respect to process state: the log tailer has to know the path *before*
110+
the job takes the lock and applies its env/cwd.
111+
"""
112+
env_vars = env_vars or {}
113+
base_dir = working_dir or os.getcwd()
114+
config_path = env_vars.get("UIPATH_CONFIG_PATH", "uipath.json")
115+
return _resolve_runtime_file(config_path, base_dir, "logsFile", DEFAULT_LOGS_FILE)
116+
117+
118+
def _read_result_document() -> tuple[str | None, str]:
119+
"""Return ``(document, conveyance)`` for the terminal result document.
120+
121+
``conveyance`` is ``inline`` when the document rides the wire, ``file`` when the
122+
caller must read it from disk (too large, unreadable, or never written).
123+
"""
124+
path = resolve_result_file_path()
125+
if not path or not os.path.exists(path):
126+
return None, "file"
127+
128+
try:
129+
if os.path.getsize(path) > MAX_INLINE_DOCUMENT_BYTES:
130+
return None, "file"
131+
with open(path, encoding="utf-8") as f:
132+
return f.read(), "inline"
133+
except (OSError, UnicodeDecodeError):
134+
return None, "file"
135+
136+
137+
async def _invoke_command(cmd: Any, args: list[str]) -> dict[str, Any]:
138+
"""Invoke one click command and classify how it ended."""
139+
try:
140+
result_value = await asyncio.to_thread(cmd.main, args, standalone_mode=False)
141+
# Under standalone_mode=False click RETURNS ctx.exit(N)'s code instead of
142+
# raising SystemExit, so a bare int is the exit code, not a result — every
143+
# ConsoleLogger.error path lands here via ctx.exit(1). The run/debug/eval
144+
# callbacks only ever return a result object or None, so this is unambiguous.
145+
if isinstance(result_value, int) and not isinstance(result_value, bool):
146+
return {
147+
"ExitCode": result_value,
148+
"Error": None if result_value == 0 else f"Exit code: {result_value}",
149+
"Result": None,
150+
"Unexpected": False,
151+
}
152+
return {
153+
"ExitCode": 0,
154+
"Error": None,
155+
"Result": result_value,
156+
"Unexpected": False,
157+
}
158+
except SystemExit as e:
159+
exit_code = e.code if isinstance(e.code, int) else 1
160+
return {
161+
"ExitCode": exit_code,
162+
"Error": None if exit_code == 0 else f"Exit code: {exit_code}",
163+
"Result": None,
164+
"Unexpected": False,
165+
}
166+
except Exception as e: # report any job failure as a result, not a fault
167+
return {"ExitCode": 1, "Error": str(e), "Result": None, "Unexpected": True}
168+
169+
48170
async def _run_command_isolated(
49171
cmd: Any,
50172
args: list[str],
@@ -79,25 +201,13 @@ async def _run_command_isolated(
79201
"ClientError": True,
80202
}
81203

82-
result_value = await asyncio.to_thread(
83-
cmd.main, args, standalone_mode=False
84-
)
85-
return {
86-
"ExitCode": 0,
87-
"Error": None,
88-
"Result": result_value,
89-
"Unexpected": False,
90-
}
91-
except SystemExit as e:
92-
exit_code = e.code if isinstance(e.code, int) else 1
93-
return {
94-
"ExitCode": exit_code,
95-
"Error": None if exit_code == 0 else f"Exit code: {exit_code}",
96-
"Result": None,
97-
"Unexpected": False,
98-
}
99-
except Exception as e: # report any job failure as a result, not a fault
100-
return {"ExitCode": 1, "Error": str(e), "Result": None, "Unexpected": True}
204+
outcome = await _invoke_command(cmd, args)
205+
# Must happen before the finally below restores env/cwd: the document's
206+
# location comes from this job's UIPATH_CONFIG_PATH and may be relative.
207+
document, conveyance = _read_result_document()
208+
outcome["Document"] = document
209+
outcome["DocumentConveyance"] = conveyance
210+
return outcome
101211
finally:
102212
# Restore to server baseline.
103213
try:

0 commit comments

Comments
 (0)