Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
275 changes: 232 additions & 43 deletions backend/app/store.py

Large diffs are not rendered by default.

17 changes: 16 additions & 1 deletion backend/orbit_sdk.py
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,7 @@ class RunnerContext:
mode: str
loop_index: int
environment: dict[str, str] = field(default_factory=lambda: dict(os.environ))
_active_workflow_functions: set[str] = field(default_factory=set, init=False, repr=False)

def __post_init__(self) -> None:
self.phase = canonical_phase(self.phase)
Expand All @@ -255,6 +256,10 @@ def function(self, function_id: str):
"""Record one graph-annotated function's outcome within this lifecycle phase."""
if not function_id.strip():
raise ValueError("function_id must not be empty")
if function_id in self._active_workflow_functions:
yield
return
self._active_workflow_functions.add(function_id)
started = datetime.now(UTC)
self.log(f"workflow function started: {function_id}")
self.emit_result(
Expand Down Expand Up @@ -299,6 +304,8 @@ def function(self, function_id: str):
]
}
)
finally:
self._active_workflow_functions.discard(function_id)

@property
def resources(self) -> dict[str, object]:
Expand Down Expand Up @@ -1259,10 +1266,13 @@ def complete_model(self, prompt: str) -> dict[str, str]:
aws_profile=str(profile.get("aws_profile", "")),
)
provider = AzureOpenAIProvider() if settings.provider == "azure-openai" else BedrockProvider()
self.log(f"target model request started: {settings.provider}/{settings.model}")
response = provider.complete(settings, prompt)
self.log(f"target model request completed: {settings.provider}/{settings.model}")
return {
"profile_name": str(profile.get("profile_name", "")),
"model": settings.model,
"response": provider.complete(settings, prompt),
"response": response,
}

@property
Expand Down Expand Up @@ -1396,6 +1406,7 @@ def playwright_journey(self, cases: list[dict[str, object]] | None = None) -> di
selected_cases = cases if cases is not None else self.test_cases
if not selected_cases:
raise ValueError("at least one fixed test case is required for a Playwright journey")
self.log(f"browser journey started: {len(selected_cases)} case(s) against {base_url}")
artifacts = (
self.app_data
/ "artifacts"
Expand Down Expand Up @@ -1438,6 +1449,8 @@ def playwright_journey(self, cases: list[dict[str, object]] | None = None) -> di
except (json.JSONDecodeError, IndexError) as error:
raise RuntimeError("Playwright did not return structured journey evidence") from error
evidence["artifacts_directory"] = str(artifacts)
passed = sum(bool(item.get("passed")) for item in evidence.get("results", []))
self.log(f"browser journey completed: {passed}/{len(selected_cases)} case(s) passed")
self.emit_result({"browser_journey": evidence})
return evidence

Expand Down Expand Up @@ -1497,7 +1510,9 @@ def forward_output() -> None:
reader.join()
output = "".join(lines)
if process.returncode:
self.log(f"exec failed with exit code {process.returncode}: {' '.join(command)}")
raise SystemExit(process.returncode)
self.log(f"exec completed: {' '.join(command)}")
return output


Expand Down
152 changes: 152 additions & 0 deletions backend/tests/test_api.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import base64
import json
import sys
import threading
import time
from types import SimpleNamespace

import orbit_sdk as sdk
import pytest
Expand Down Expand Up @@ -106,6 +109,54 @@ def test_runner_target_logs_are_retained_separately_from_runner_output(tmp_path,
assert all(entry["iteration"] == 3 and entry["phase"] == "execute" for entry in step["target_logs"])


def test_running_workflow_function_is_retained_before_its_step_finishes(tmp_path, monkeypatch):
monkeypatch.setattr(store_module, "RUNS", tmp_path / "runs")
project = tmp_path / "target"
project.mkdir()
runner = project / "runner.py"
runner.write_text(
"import time\n"
"from orbit_sdk import runner\n"
"@runner.phase('execute')\n"
"def run(ctx):\n"
" with ctx.function('collect-source-evidence'):\n"
" time.sleep(1)\n"
"if __name__ == '__main__': runner.main()\n",
encoding="utf-8",
)
timestamp = store_module.now()
store = store_module.ConsoleStore()
store._save(
Run(
id="live-function-run",
workflow_id="workflow",
workflow_name="Workflow",
repository=str(project),
status="running",
created_at=timestamp,
updated_at=timestamp,
)
)
step = Step(
id="run",
phase="execute",
name="Run",
command=[sys.executable, str(runner), "--phase", "execute"],
working_directory=str(project),
)

thread = threading.Thread(target=store._execute_step, args=("live-function-run", step, 1))
thread.start()
for _ in range(20):
results = store._load("live-function-run").step_results
if results and results[-1].get("result", {}).get("workflow_functions"):
break
time.sleep(0.1)
thread.join()

assert results[-1]["result"]["workflow_functions"][0]["status"] == "running"


def test_runner_data_files_are_retained_for_the_iteration(tmp_path, monkeypatch):
app_data = tmp_path / "orbit-data"
monkeypatch.setattr(store_module, "APP_DATA", app_data)
Expand Down Expand Up @@ -489,6 +540,42 @@ def test_deleting_a_completed_run_removes_its_history(tmp_path, monkeypatch):
assert not (store_module.RUNS / "completed-run.json").exists()


def test_completed_pipeline_run_can_be_retried(tmp_path, monkeypatch):
monkeypatch.setattr(store_module, "RUNS", tmp_path / "runs")
store = store_module.ConsoleStore()
timestamp = store_module.now()
store._save(
Run(
id="completed-run",
workflow_id="workflow",
workflow_name="Workflow",
execution_type="pipeline",
status="succeeded",
created_at=timestamp,
updated_at=timestamp,
finished_at=timestamp,
step_results=[{"step_id": "execute", "loop_index": 1, "output": "previous output"}],
supervisor_results=[{"iteration": 1, "response": {"evaluation": {"score": 8}}}],
runner_output="previous runner output",
)
)
monkeypatch.setattr(
store, "_runner_execution_plan", lambda runner_id: SimpleNamespace(id=runner_id, name="Workflow")
)
monkeypatch.setattr(store, "_runner_graph_definition", lambda *_: None)
monkeypatch.setattr(store, "_start", lambda _: None)

retried = store.retry("completed-run", restart_from_first=True)

assert retried.id == "completed-run"
assert retried.retry_of_run_id is None
assert retried.retry_mode == "restart"
assert retried.status == "queued"
assert retried.step_results == []
assert retried.supervisor_results == []
assert retried.runner_output == ""


def test_active_evaluations_count_feedback_across_all_iterations(monkeypatch):
store = store_module.ConsoleStore()
timestamp = store_module.now()
Expand Down Expand Up @@ -878,6 +965,71 @@ def test_site_exploration_quick_start_uses_the_langgraph_runner():
assert "logout|signout|delete" in runner["source"]


@pytest.mark.parametrize(
("quick_start_id", "phases"),
[
("openorbit.user-journey-smoke-test", ["before_all", "execute", "verify", "after_all"]),
("openorbit.site-exploration-review", ["before_all", "execute", "verify", "after_all"]),
(
"openorbit.agent-self-improvement",
["before_all", "before_each", "execute", "verify", "after_each", "after_all"],
),
(
"openorbit.ai-slo-drift-monitor",
["before_all", "before_each", "execute", "verify", "after_each", "after_all"],
),
],
)
def test_quick_start_runner_graph_matches_its_execution_purpose(monkeypatch, quick_start_id, phases):
store = store_module.ConsoleStore()
quick_start = next(item for item in store._built_in_quick_starts() if item["id"] == quick_start_id)
monkeypatch.setattr(sdk, "graph", sdk.Graph())

exec(
compile(quick_start["assets"]["runner"]["source"], quick_start_id, "exec"),
{"__name__": quick_start_id},
)

definition = sdk.graph.definition()
assert [node["phase"] for node in definition["nodes"]] == phases
assert definition["edges"]


@pytest.mark.parametrize(
("template_id", "phases"),
[
("user-journey-cycle", ["before_all", "before_each", "execute", "verify", "after_each", "after_all"]),
(
"external-command-adapter",
["before_all", "before_each", "execute", "verify", "after_each", "after_all"],
),
(
"native-improvement-cycle",
["before_all", "before_each", "execute", "verify", "after_each", "after_all"],
),
("site-exploration", ["before_all", "execute", "verify", "after_all"]),
("json-agent-cycle", ["before_all", "before_each", "execute", "verify", "after_each", "after_all"]),
(
"evidence-gated-probe-cycle",
["before_all", "before_each", "execute", "verify", "after_each", "after_all"],
),
],
)
def test_runner_templates_publish_a_lifecycle_graph(monkeypatch, template_id, phases):
source = next(
template["source"]
for template in store_module.ConsoleStore.runner_templates()
if template["id"] == template_id
)
monkeypatch.setattr(sdk, "graph", sdk.Graph())

exec(compile(source, template_id, "exec"), {"__name__": template_id})

definition = sdk.graph.definition()
assert [node["phase"] for node in definition["nodes"]] == phases
assert definition["edges"]


def test_ai_slo_drift_quick_start_uses_a_recurring_evidence_gate():
store = store_module.ConsoleStore()
quick_start = next(
Expand Down
12 changes: 12 additions & 0 deletions backend/tests/test_orbit_sdk.py
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,18 @@ def collect(ctx) -> None:
assert '"status": "failed"' in output


def test_nested_function_trace_for_the_same_node_is_emitted_once(tmp_path, capsys):
ctx = context(tmp_path, iteration=1)

with ctx.function("collect-source-evidence"):
with ctx.function("collect-source-evidence"):
pass

output = capsys.readouterr().out
assert output.count('"status": "running"') == 1
assert output.count('"status": "succeeded"') == 1


def context(project, *, iteration: int, run_id: str = "run-123"):
return sdk.RunnerContext(
phase="execute",
Expand Down
11 changes: 8 additions & 3 deletions frontend/src/components/ui/modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,14 @@ export function Modal({
onClose,
children,
className = "",
headerActions,
}: {
open: boolean;
title: string;
onClose: () => void;
children: ReactNode;
className?: string;
headerActions?: ReactNode;
}) {
useEffect(() => {
if (!open) return;
Expand All @@ -37,9 +39,12 @@ export function Modal({
>
<header className="modal-header">
<h2>{title}</h2>
<button className="modal-close" aria-label={close} onClick={onClose}>
<X size={18} />
</button>
<div className="modal-header-actions">
{headerActions}
<button className="modal-close" aria-label={close} onClick={onClose}>
<X size={18} />
</button>
</div>
</header>
{children}
</section>
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/domain/models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ export type TargetEnvironment = {id:string;name:string;repository:string;browser
export type Build = { id:string; name:string; enabled:boolean; runner_id:string; execution_environment_id?:string;target_environment_id?:string; repository:string; repository_name?:string; repository_is_git?:boolean; repository_error?:string; purpose:string;manager_template_id?:string;model_profile_name?:string;test_case_set_id?:string;test_cases?:TestCase[];browser_base_url?:string;browser_executable_path?:string;browser_library_path?:string;timezone:string;repeat_interval_minutes:number;run_limit:number;cadence_mode?:'after_completion'|'fixed';overrun_policy?:'wait'|'interrupt_eval';schedule_enabled?:boolean;schedule_weekdays?:number[];schedule_start_time?:string;schedule_end_time?:string;iteration_strategy?:'linear'|'score_select';candidates_per_iteration?:number;approval_score:number;require_human_approval_before_apply?:boolean;created_at?:string;last_run_at?:string;executor?:{type?:'local'|'remote-http';endpoint?:string;method?:'GET'|'POST'|'PUT';timeout_seconds?:number;headers?:Record<string,string>} }
export type PromptTemplate = { id:string; name:string; version:number; content:string; versions?:{version:number;content:string}[];created_at?:string }
export type SavedDataFile = { label?:string; filename:string; path:string; relative_path?:string; sha256?:string; size?:number; content_type?:string }
export type RunStepResult = { step_id:string; phase?:'before_all'|'before_each'|'execute'|'verify'|'after_each'|'after_all'; loop_index?:number; candidate_id?:string|null; name?:string; command?:string[]; working_directory?:string; started_at?:string; ended_at?:string; exit_code?:number; output?:string; error?:string; log_lines?:{timestamp:string;value:string}[]; target_logs?:{timestamp?:string;level?:string;source?:string;message:string;run_id?:string;iteration?:number;phase?:string}[]; data_files?:SavedDataFile[]; result?:Record<string,unknown> }
export type RunStepResult = { step_id:string; phase?:'before_all'|'before_each'|'execute'|'verify'|'after_each'|'after_all'; loop_index?:number; candidate_id?:string|null; name?:string; command?:string[]; working_directory?:string; started_at?:string; ended_at?:string; in_progress?:boolean; exit_code?:number; output?:string; error?:string; log_lines?:{timestamp:string;value:string}[]; target_logs?:{timestamp?:string;level?:string;source?:string;message:string;run_id?:string;iteration?:number;phase?:string}[]; data_files?:SavedDataFile[]; result?:Record<string,unknown> }
export type WorkflowGraphNode = { id:string; title:string; phase?:string|null; inputs?:string[]; outputs?:string[]; description?:string|null; status?:'idle'|'running'|'succeeded'|'failed'|'skipped' }
export type WorkflowGraphEdge = { source:string; target:string; kind?:'execution'|'data'|'condition'|'loop'|'error'; label?:string|null; source_port?:string|null; target_port?:string|null }
export type WorkflowGraphDefinition = { nodes:WorkflowGraphNode[]; edges:WorkflowGraphEdge[] }
Expand Down
Loading
Loading