Skip to content

Commit 78808b7

Browse files
Merge pull request #159 from UiPath/feat/output-args-memory-optimization
feat: optionally split output arguments into their own file [ROBO-5900]
1 parent 098b022 commit 78808b7

5 files changed

Lines changed: 318 additions & 4 deletions

File tree

.github/scripts/force-runtime-override.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,8 @@ def _add_override(pyproject_path: Path, override: str) -> None:
3737
body,
3838
)
3939
if override_match:
40-
items = override_match.group("items").strip()
40+
# A trailing comma must not survive the join below, or it yields `[..,, ..]`
41+
items = override_match.group("items").strip().rstrip(",").strip()
4142
if quoted_override in items:
4243
return
4344

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "uipath-runtime"
3-
version = "0.13.0"
3+
version = "0.13.1"
44
description = "Runtime abstractions and interfaces for building agents and automation scripts in the UiPath ecosystem"
55
readme = { file = "README.md", content-type = "text/markdown" }
66
requires-python = ">=3.11"

src/uipath/runtime/context.py

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@
2323

2424
logger = logging.getLogger(__name__)
2525

26+
OUTPUT_ARGUMENTS_SUFFIX = ".args"
27+
2628
_EXECUTION_SOURCE_BY_COMMAND: dict[str, str] = {
2729
"run": "runtime",
2830
"debug": "playground",
@@ -87,6 +89,14 @@ class UiPathRuntimeContext(BaseModel):
8789
"If not specified, path is constructed from runtime_dir and result_file."
8890
),
8991
)
92+
split_output_arguments: bool = Field(
93+
False,
94+
description=(
95+
"Write the output arguments to their own file alongside the result file, "
96+
"and carry an 'outputArgumentsFilePath' pointer in the result file "
97+
"instead of the inline 'output' value."
98+
),
99+
)
90100
state_file: str = Field("state.db", description="Filename for the state database")
91101
state_file_path: str | None = Field(
92102
None,
@@ -275,6 +285,21 @@ def __exit__(self, exc_type, exc_val, exc_tb):
275285

276286
content = self.result.to_dict()
277287

288+
# Read, not popped: output_file needs the arguments even when the split
289+
# does not run, and popping to re-insert would move "output" after
290+
# "status" in the envelope
291+
output_payload = content.get("output", {})
292+
293+
# Gated on job_id like the envelope write below: the pointer only has a
294+
# reader when there is a job, so without one there is nothing to point at it
295+
if self.split_output_arguments and self.job_id:
296+
output_arguments_path = self.resolved_output_arguments_file_path
297+
os.makedirs(os.path.dirname(output_arguments_path), exist_ok=True)
298+
with open(output_arguments_path, "w") as f:
299+
json.dump(output_payload, f, default=str)
300+
content.pop("output", None)
301+
content["outputArgumentsFilePath"] = output_arguments_path
302+
278303
# Always write output file at runtime, except for inner runtimes
279304
# Inner runtimes have execution_id
280305
if self.job_id:
@@ -283,7 +308,6 @@ def __exit__(self, exc_type, exc_val, exc_tb):
283308

284309
# Write the execution output to file if requested
285310
if self.output_file:
286-
output_payload = content.get("output", {})
287311
with open(self.output_file, "w") as f:
288312
json.dump(output_payload, f, default=str)
289313

@@ -343,6 +367,21 @@ def resolved_result_file_path(self) -> str:
343367
return os.path.join(self.runtime_dir, self.result_file)
344368
return os.path.join("__uipath", "output.json")
345369

370+
@cached_property
371+
def resolved_output_arguments_file_path(self) -> str:
372+
"""Get the full path to the output arguments file.
373+
374+
Derived from the result file, not configured: the host cannot put the two
375+
in different places, and inserting the suffix before the extension keeps
376+
them distinct whatever the result file is called.
377+
"""
378+
result_path = Path(os.path.abspath(self.resolved_result_file_path))
379+
return str(
380+
result_path.with_name(
381+
f"{result_path.stem}{OUTPUT_ARGUMENTS_SUFFIX}{result_path.suffix}"
382+
)
383+
)
384+
346385
@cached_property
347386
def resolved_state_file_path(self) -> str:
348387
"""Get the full path to the state file."""
@@ -406,6 +445,7 @@ def from_config(
406445
mapping = {
407446
"dir": "runtime_dir",
408447
"outputFile": "result_file", # we need this to maintain back-compat with serverless runtime
448+
"splitOutputArguments": "split_output_arguments",
409449
"stateFile": "state_file",
410450
"logsFile": "logs_file",
411451
}

tests/test_context.py

Lines changed: 273 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
import json
2+
import os
23
from pathlib import Path
34
from typing import Any
45

56
import pytest
67
from uipath.core.errors import ErrorCategory, UiPathFaultedTriggerError
8+
from uipath.core.triggers import UiPathResumeTrigger
79

810
from uipath.runtime.context import UiPathRuntimeContext
911
from uipath.runtime.errors import (
@@ -427,3 +429,274 @@ def test_from_config_accepts_maestro_flow_voice_mode(tmp_path: Path) -> None:
427429
ctx = UiPathRuntimeContext.from_config(str(config_path))
428430

429431
assert ctx.voice_mode == "maestro_flow"
432+
433+
434+
def test_from_config_maps_split_output_arguments(tmp_path: Path) -> None:
435+
"""runtime.splitOutputArguments should map onto the knob."""
436+
cfg = {"runtime": {"splitOutputArguments": True}}
437+
config_path = tmp_path / "uipath.json"
438+
config_path.write_text(json.dumps(cfg))
439+
440+
ctx = UiPathRuntimeContext.from_config(config_path=str(config_path))
441+
442+
assert ctx.split_output_arguments is True
443+
444+
445+
def test_split_output_arguments_defaults_off_when_config_key_absent(
446+
tmp_path: Path,
447+
) -> None:
448+
"""The split stays off when the config omits the key."""
449+
cfg = {"runtime": {"outputFile": "my_output.json"}}
450+
config_path = tmp_path / "uipath.json"
451+
config_path.write_text(json.dumps(cfg))
452+
453+
ctx = UiPathRuntimeContext.from_config(config_path=str(config_path))
454+
455+
assert ctx.split_output_arguments is False
456+
457+
458+
def test_output_arguments_file_is_a_sibling_of_the_result_file(
459+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
460+
) -> None:
461+
"""The arguments file lands next to the result file, never in the process CWD.
462+
463+
The host names the directory once, through runtime_dir, and both files follow
464+
it. The filename is not configurable, so the knob has exactly one encoding and
465+
the two files cannot be pointed at different directories.
466+
"""
467+
cwd = tmp_path / "cwd"
468+
cwd.mkdir()
469+
monkeypatch.chdir(cwd)
470+
runtime_dir = tmp_path / "runtime"
471+
ctx = UiPathRuntimeContext(
472+
job_id="job-sibling",
473+
runtime_dir=str(runtime_dir),
474+
result_file="result.json",
475+
split_output_arguments=True,
476+
)
477+
478+
arguments_path = Path(ctx.resolved_output_arguments_file_path)
479+
assert arguments_path.parent == Path(ctx.resolved_result_file_path).parent
480+
assert arguments_path.parent == runtime_dir
481+
assert arguments_path.name == "result.args.json"
482+
assert arguments_path.is_absolute()
483+
assert cwd not in arguments_path.parents
484+
485+
486+
def test_output_arguments_file_cannot_collide_with_the_result_file(
487+
tmp_path: Path,
488+
) -> None:
489+
"""The suffix goes before the extension, so the two names can never converge.
490+
491+
Naming the result file after the arguments file used to produce one path for
492+
both: the envelope overwrote the arguments and then pointed at itself.
493+
"""
494+
ctx = UiPathRuntimeContext(
495+
job_id="job-collide",
496+
runtime_dir=str(tmp_path / "runtime"),
497+
result_file="output.args.json",
498+
split_output_arguments=True,
499+
)
500+
501+
assert Path(ctx.resolved_output_arguments_file_path).name == "output.args.args.json"
502+
assert ctx.resolved_output_arguments_file_path != os.path.abspath(
503+
ctx.resolved_result_file_path
504+
)
505+
506+
507+
def test_output_arguments_file_not_written_without_a_job(tmp_path: Path) -> None:
508+
"""No job means no envelope, so the pointer would have no reader and no file.
509+
510+
A local `uipath run` has no UIPATH_JOB_KEY; writing the payload there would
511+
leave a full copy on disk that nothing references.
512+
"""
513+
runtime_dir = tmp_path / "runtime"
514+
ctx = UiPathRuntimeContext(
515+
runtime_dir=str(runtime_dir),
516+
result_file="result.json",
517+
split_output_arguments=True,
518+
)
519+
520+
with ctx:
521+
ctx.result = UiPathRuntimeResult(
522+
status=UiPathRuntimeStatus.SUCCESSFUL,
523+
output={"foo": "bar"},
524+
)
525+
526+
assert not Path(ctx.resolved_output_arguments_file_path).exists()
527+
assert not Path(ctx.resolved_result_file_path).exists()
528+
529+
530+
def test_result_file_keeps_output_inline_when_split_disabled(
531+
tmp_path: Path,
532+
) -> None:
533+
"""Without the knob, the result file is byte-identical to the legacy envelope."""
534+
runtime_dir = tmp_path / "runtime"
535+
ctx = UiPathRuntimeContext(
536+
job_id="job-inline",
537+
runtime_dir=str(runtime_dir),
538+
result_file="result.json",
539+
)
540+
541+
with ctx:
542+
ctx.result = UiPathRuntimeResult(
543+
status=UiPathRuntimeStatus.SUCCESSFUL,
544+
output={"foo": "bar"},
545+
)
546+
547+
result_path = Path(ctx.resolved_result_file_path)
548+
# The envelope is written in text mode, so json's newline reaches disk as os.linesep
549+
expected = json.dumps(
550+
{"output": {"foo": "bar"}, "status": "successful"}, indent=2
551+
).replace("\n", os.linesep)
552+
assert result_path.read_bytes() == expected.encode()
553+
554+
content = json.loads(result_path.read_bytes())
555+
assert "outputArgumentsFilePath" not in content
556+
assert not Path(ctx.resolved_output_arguments_file_path).exists()
557+
558+
559+
def test_output_arguments_written_to_separate_file(tmp_path: Path) -> None:
560+
"""With the knob, the arguments move out and the envelope carries the path."""
561+
runtime_dir = tmp_path / "nested" / "runtime"
562+
ctx = UiPathRuntimeContext(
563+
job_id="job-split",
564+
runtime_dir=str(runtime_dir),
565+
result_file="result.json",
566+
split_output_arguments=True,
567+
)
568+
569+
with ctx:
570+
ctx.result = UiPathRuntimeResult(
571+
status=UiPathRuntimeStatus.SUCCESSFUL,
572+
output={"foo": "bar"},
573+
)
574+
575+
arguments_path = Path(ctx.resolved_output_arguments_file_path)
576+
# Parent directory is created on demand
577+
assert json.loads(arguments_path.read_text()) == {"foo": "bar"}
578+
579+
content = json.loads(Path(ctx.resolved_result_file_path).read_text())
580+
assert "output" not in content
581+
assert content["status"] == UiPathRuntimeStatus.SUCCESSFUL.value
582+
assert content["outputArgumentsFilePath"] == str(arguments_path)
583+
assert Path(content["outputArgumentsFilePath"]).is_absolute()
584+
585+
586+
def test_output_file_receives_bare_arguments_when_split_enabled(
587+
tmp_path: Path,
588+
) -> None:
589+
"""--output-file keeps receiving the bare arguments when both are set."""
590+
runtime_dir = tmp_path / "runtime"
591+
output_path = tmp_path / "output.json"
592+
ctx = UiPathRuntimeContext(
593+
job_id="job-both",
594+
runtime_dir=str(runtime_dir),
595+
result_file="result.json",
596+
output_file=str(output_path),
597+
split_output_arguments=True,
598+
)
599+
600+
with ctx:
601+
ctx.result = UiPathRuntimeResult(
602+
status=UiPathRuntimeStatus.SUCCESSFUL,
603+
output={"foo": "bar"},
604+
)
605+
606+
assert json.loads(output_path.read_text()) == {"foo": "bar"}
607+
arguments_path = Path(ctx.resolved_output_arguments_file_path)
608+
assert json.loads(arguments_path.read_text()) == {"foo": "bar"}
609+
610+
611+
def test_faulted_run_keeps_status_and_error_inline_when_split_enabled(
612+
tmp_path: Path,
613+
) -> None:
614+
"""status and error stay in the envelope when the arguments are split out."""
615+
runtime_dir = tmp_path / "runtime"
616+
ctx = UiPathRuntimeContext(
617+
job_id="job-faulted-split",
618+
runtime_dir=str(runtime_dir),
619+
result_file="result.json",
620+
split_output_arguments=True,
621+
)
622+
623+
with pytest.raises(RuntimeError, match="Stream blew up"):
624+
with ctx:
625+
raise RuntimeError("Stream blew up")
626+
627+
content = json.loads(Path(ctx.resolved_result_file_path).read_text())
628+
assert content["status"] == UiPathRuntimeStatus.FAULTED.value
629+
assert content["error"]["code"] == "ERROR_RuntimeError"
630+
assert "Stream blew up" in content["error"]["detail"]
631+
assert "output" not in content
632+
633+
# The pointer must never advertise a file that was not actually written
634+
arguments_path = Path(content["outputArgumentsFilePath"])
635+
assert arguments_path.exists()
636+
assert json.loads(arguments_path.read_text()) == {}
637+
638+
639+
def test_resume_triggers_stay_inline_when_split_enabled(tmp_path: Path) -> None:
640+
"""resume and resumeTriggers must never be moved out of the envelope.
641+
642+
They are what makes a suspended job resumable, so a split that swept them
643+
into the arguments file would strand the job.
644+
"""
645+
runtime_dir = tmp_path / "runtime"
646+
ctx = UiPathRuntimeContext(
647+
job_id="job-suspended-split",
648+
runtime_dir=str(runtime_dir),
649+
result_file="result.json",
650+
split_output_arguments=True,
651+
)
652+
653+
trigger = UiPathResumeTrigger(item_key="k")
654+
with ctx:
655+
ctx.result = UiPathRuntimeResult(
656+
status=UiPathRuntimeStatus.SUSPENDED,
657+
output={"foo": "bar"},
658+
trigger=trigger,
659+
triggers=[trigger],
660+
)
661+
662+
content = json.loads(Path(ctx.resolved_result_file_path).read_text())
663+
assert content["status"] == UiPathRuntimeStatus.SUSPENDED.value
664+
assert content["resume"]["itemKey"] == "k"
665+
assert len(content["resumeTriggers"]) == 1
666+
assert content["resumeTriggers"][0]["itemKey"] == "k"
667+
# Only the output moved out
668+
assert "output" not in content
669+
arguments_path = Path(content["outputArgumentsFilePath"])
670+
assert json.loads(arguments_path.read_text()) == {"foo": "bar"}
671+
672+
673+
def test_failed_arguments_write_faults_the_run(tmp_path: Path) -> None:
674+
"""A failing arguments write faults the run, like every other write in __exit__.
675+
676+
Falling back to the inline value would write the same bytes to the same volume,
677+
so it cannot rescue the failure that actually matters, and it would hand the
678+
consumer the payload the split exists to keep out of its heap.
679+
"""
680+
runtime_dir = tmp_path / "runtime"
681+
runtime_dir.mkdir()
682+
# A directory cannot be opened for writing, so the split write fails
683+
(runtime_dir / "result.args.json").mkdir()
684+
ctx = UiPathRuntimeContext(
685+
job_id="job-failed-write",
686+
runtime_dir=str(runtime_dir),
687+
result_file="result.json",
688+
split_output_arguments=True,
689+
)
690+
691+
with pytest.raises(RuntimeError) as excinfo:
692+
with ctx:
693+
ctx.result = UiPathRuntimeResult(
694+
status=UiPathRuntimeStatus.SUCCESSFUL,
695+
output={"foo": "bar"},
696+
)
697+
698+
assert "RUNTIME_SHUTDOWN_ERROR" in str(excinfo.value)
699+
700+
content = json.loads(Path(ctx.resolved_result_file_path).read_text())
701+
assert content["status"] == UiPathRuntimeStatus.FAULTED.value
702+
assert content["error"]["code"] == "RUNTIME_SHUTDOWN_ERROR"

uv.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)