diff --git a/backend/app/main.py b/backend/app/main.py index 503585f..3e7eb31 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -444,6 +444,10 @@ class RunnerAssetCreate(RunnerAssetUpdate): template_id: str = Field(default="custom", max_length=64) +class RunnerGraphPreview(BaseModel): + source: str = Field(min_length=1, max_length=100_000) + + class RunnerTemplateValues(BaseModel): id: str = Field(pattern=r"^[a-z][a-z0-9-]{2,63}$") name: str = Field(min_length=1, max_length=120) @@ -505,6 +509,11 @@ def runners(): return store.runners() +@app.post("/api/runners/preview-graph") +def preview_runner_graph(values: RunnerGraphPreview): + return safely(lambda: store.preview_runner_graph(values.source)) + + @app.post("/api/runners") def create_runner(values: RunnerAssetCreate): return safely(lambda: store.create_runner(values.model_dump())) @@ -994,6 +1003,7 @@ def template_translation_v1(values: TemplateTranslationRequest): class CycleAnalysisRequest(BaseModel): build_id: str = Field(min_length=1, max_length=200) + hours: int = Field(default=720, ge=1, le=8760) locale: str | None = Field( default=None, min_length=2, max_length=35, pattern=r"^[A-Za-z]{2,3}(?:-[A-Za-z0-9]{2,8})*$" ) @@ -1006,7 +1016,7 @@ def analyze_cycle(values: CycleAnalysisRequest): if not profile_name: raise HTTPException(409, "Select a System AI model in Settings first.") configured = profile(store.profiles(), profile_name) - analytics = store.improvement_analytics(720) + analytics = store.improvement_analytics(values.hours) trend = next( (item for item in analytics["iteration_trends"] if item["build_id"] == values.build_id), None, diff --git a/backend/app/store.py b/backend/app/store.py index 1ec42e3..78f989a 100644 --- a/backend/app/store.py +++ b/backend/app/store.py @@ -9,6 +9,7 @@ import signal import subprocess import sys +import tempfile import threading import time import uuid @@ -2386,6 +2387,20 @@ def _runner_graph_definition( self, runner_id: str, repository: str | None, runner_version: int | None = None ) -> dict[str, Any] | None: """Read the runner's optional visual-workflow declaration safely.""" + return self._runner_graph_from_entry(self._runner_entry_path(runner_id, runner_version), repository) + + def preview_runner_graph(self, source: str) -> dict[str, Any] | None: + """Build a visual workflow from unsaved runner source without retaining it.""" + source = self._canonicalize_runner_source(source) + compile(source, "runner-preview.py", "exec") + with tempfile.TemporaryDirectory(prefix="orbit-runner-graph-") as directory: + entry = Path(directory) / "runner.py" + entry.write_text(source, encoding="utf-8") + return self._runner_graph_from_entry(entry, None) + + @staticmethod + def _runner_graph_from_entry(entry: Path, repository: str | None) -> dict[str, Any] | None: + """Execute one runner's graph-only entrypoint and validate its response.""" environment = os.environ.copy() environment["PYTHONPATH"] = str(ROOT / "backend") + ( os.pathsep + environment["PYTHONPATH"] if environment.get("PYTHONPATH") else "" @@ -2394,7 +2409,7 @@ def _runner_graph_definition( environment["ORBIT_APP_DATA"] = str(APP_DATA) try: result = subprocess.run( - [sys.executable, str(self._runner_entry_path(runner_id, runner_version)), "--graph"], + [sys.executable, str(entry), "--graph"], cwd=repository or ROOT, text=True, stdout=subprocess.PIPE, diff --git a/backend/orbit_sdk.py b/backend/orbit_sdk.py index 25f4b7d..f9e8fda 100644 --- a/backend/orbit_sdk.py +++ b/backend/orbit_sdk.py @@ -1633,7 +1633,7 @@ def playwright_journey(self, cases: list[dict[str, object]] | None = None) -> di cases: Optional fixed cases to run. The build's selected cases are used when omitted. Returns: - Per-case pass/fail evidence, screenshots, and artifact directory metadata. + Per-case pass/fail evidence, screenshots, page HTML, and artifact directory metadata. """ build = self.build base_url = str( @@ -1664,7 +1664,7 @@ def playwright_journey(self, cases: list[dict[str, object]] | None = None) -> di } script = r"""const fs=require('fs'); const { chromium }=require(process.argv[1]); const input=JSON.parse(process.argv[2]); (async()=>{const launch={headless:input.headless}; if(input.executablePath)launch.executablePath=input.executablePath; else if(process.env.SIM_BROWSER_BIN)launch.executablePath=process.env.SIM_BROWSER_BIN; const browser=await chromium.launch(launch); const results=[]; -for(const item of input.cases){const page=await browser.newPage();const path=String(item.path||'/');const url=new URL(path,input.baseUrl).toString();const id=String(item.id||'case').replace(/[^a-zA-Z0-9_-]/g,'-');const screenshot=`${input.artifacts}/${id}.png`;try{await page.goto(url,{waitUntil:'domcontentloaded',timeout:30000});const expected=String(item.expected_text||'').trim();const passed=!expected||await page.getByText(expected,{exact:false}).first().isVisible({timeout:5000});await page.screenshot({path:screenshot,fullPage:true});results.push({id:item.id,name:item.name,url,passed,expected_text:expected,screenshot});}catch(error){try{await page.screenshot({path:screenshot,fullPage:true});}catch{}results.push({id:item.id,name:item.name,url,passed:false,error:String(error),screenshot});}finally{await page.close();}} +for(const item of input.cases){const page=await browser.newPage();const path=String(item.path||'/');const url=new URL(path,input.baseUrl).toString();const id=String(item.id||'case').replace(/[^a-zA-Z0-9_-]/g,'-');const screenshot=`${input.artifacts}/${id}.png`;const html=`${input.artifacts}/${id}.html`;try{await page.goto(url,{waitUntil:'domcontentloaded',timeout:30000});const expected=String(item.expected_text||'').trim();const passed=!expected||await page.getByText(expected,{exact:false}).first().isVisible({timeout:5000});await page.screenshot({path:screenshot,fullPage:true});await fs.promises.writeFile(html,await page.content());results.push({id:item.id,name:item.name,url,passed,expected_text:expected,screenshot,html});}catch(error){try{await page.screenshot({path:screenshot,fullPage:true});await fs.promises.writeFile(html,await page.content());}catch{}results.push({id:item.id,name:item.name,url,passed:false,error:String(error),screenshot,html});}finally{await page.close();}} await browser.close(); console.log(JSON.stringify({base_url:input.baseUrl,results}));})().catch(error=>{console.error(error);process.exit(1)});""" environment = dict(self.environment) library_path = str(build.get("browser_library_path", "")).strip() diff --git a/frontend/src/components/ui/python-editor.tsx b/frontend/src/components/ui/python-editor.tsx index 8623ea9..14f950e 100644 --- a/frontend/src/components/ui/python-editor.tsx +++ b/frontend/src/components/ui/python-editor.tsx @@ -3,7 +3,7 @@ import { python } from '@codemirror/lang-python' import { oneDark } from '@codemirror/theme-one-dark' import { EditorView } from '@codemirror/view' -export function PythonEditor({value,onChange,ariaLabel}:{value:string;onChange:(value:string)=>void;ariaLabel:string}){ +export function PythonEditor({value,onChange,onBlur,ariaLabel}:{value:string;onChange:(value:string)=>void;onBlur?:()=>void;ariaLabel:string}){ return } diff --git a/frontend/src/components/ui/status-badge.tsx b/frontend/src/components/ui/status-badge.tsx index b85a0f3..16879b8 100644 --- a/frontend/src/components/ui/status-badge.tsx +++ b/frontend/src/components/ui/status-badge.tsx @@ -1 +1 @@ -export function StatusBadge({value,label}:{value:string;label?:string}){const style=({succeeded:'good',running:'run',awaiting_approval:'wait',failed:'bad',committed:'good',evaluating:'run',enabled:'good',accepted:'good',applied:'good',rejected:'bad',proposed:'wait'}[value]??'muted');return {label??value}} +export function StatusBadge({value,label}:{value:string;label?:string}){const style=({queued:'wait',succeeded:'good',running:'run',awaiting_approval:'wait',failed:'bad',cancelled:'bad',committed:'good',evaluating:'run',enabled:'good',accepted:'good',applied:'good',rejected:'bad',proposed:'wait'}[value]??'muted');return {label??value}} diff --git a/frontend/src/features/assets/page.tsx b/frontend/src/features/assets/page.tsx index bd44204..237bf00 100644 --- a/frontend/src/features/assets/page.tsx +++ b/frontend/src/features/assets/page.tsx @@ -21,6 +21,7 @@ import type { TargetTestCaseSet, TestCase, Workflow, + WorkflowGraphDefinition, WorkflowStep, } from "../../domain/models"; import { @@ -35,6 +36,7 @@ import { ConfirmDialog } from "../../components/ui/confirm-dialog"; import { PanelHeader } from "../../components/ui/page-header"; import { SectionInfo } from "../../components/ui/section-info"; import { PythonEditor } from "../../components/ui/python-editor"; +import { WorkflowGraph } from "../../components/workflow-graph"; import { YamlEditor } from "../../components/ui/yaml-editor"; import { api } from "../../services/api"; import { useTemplateTranslations } from "../../services/use-template-translation"; @@ -377,8 +379,24 @@ function RunnerModal({ const [templates, setTemplates] = useState([]), [draft, setDraft] = useState(editing ?? undefined), [selectedVersion, setSelectedVersion] = useState(editing?.version ?? null), - [notice, setNotice] = useState(""); + [notice, setNotice] = useState(""), + [editorTab, setEditorTab] = useState<"code" | "graph">("code"), + [workflowGraph, setWorkflowGraph] = useState(null), + [graphSource, setGraphSource] = useState(""), + [graphLoading, setGraphLoading] = useState(false), + [graphError, setGraphError] = useState(""); const importInput = useRef(null); + const draftSource = useRef(draft?.source ?? ""), graphInFlight = useRef(null); + const emptyTemplate: RunnerTemplate = { + id: "empty", + name: copy.empty, + description: copy.emptyDescription, + source: "from orbit_sdk import runner\n\n\n@runner.phase(\"before_all\")\ndef before_all(ctx):\n pass\n\n\n@runner.phase(\"before_each\")\ndef before_each(ctx):\n pass\n\n\n@runner.phase(\"execute\")\ndef execute(ctx):\n pass\n\n\n@runner.phase(\"verify\")\ndef verify(ctx):\n pass\n\n\n@runner.phase(\"after_each\")\ndef after_each(ctx):\n pass\n\n\n@runner.phase(\"after_all\")\ndef after_all(ctx):\n pass\n\n\nif __name__ == \"__main__\":\n runner.main()\n", + }; + const templateOptions = [emptyTemplate, ...templates]; + useEffect(() => { + draftSource.current = draft?.source ?? ""; + }, [draft?.source]); useEffect(() => { if (!editing) api("/api/runner-templates") @@ -398,6 +416,27 @@ function RunnerModal({ setDraft(undefined); setNotice(""); }; + const refreshWorkflowGraph = (source = draft?.source ?? "") => { + if (!source || source === graphSource || source === graphInFlight.current) return; + graphInFlight.current = source; + setGraphLoading(true); + setGraphError(""); + api("/api/runners/preview-graph", "POST", { source }) + .then((graph) => { + if (draftSource.current !== source) return; + setWorkflowGraph(graph); + setGraphSource(source); + }) + .catch((error) => { + if (draftSource.current === source) setGraphError(error.message); + }) + .finally(() => { + if (graphInFlight.current === source) { + graphInFlight.current = null; + setGraphLoading(false); + } + }); + }; const importTemplate = async (file: File | undefined) => { if (!file) return; try { @@ -498,7 +537,7 @@ function RunnerModal({
- {templates.map((template) => ( + {templateOptions.map((template) => ( {notice}} ); - const selectedTemplate = templates.find( + const selectedTemplate = templateOptions.find( (template) => template.id === draft.template_id, ); const versions = editing?.versions ?? []; @@ -587,17 +626,15 @@ function RunnerModal({ )} {!editing &&

{copy.initialVersion}

} - +
+ + +
+ {editorTab === "code" ?
+ setDraft({ ...draft, source })} onBlur={() => refreshWorkflowGraph()} /> +
:
+ {graphLoading ?

{copy.loadingGraph}

: workflowGraph?.nodes.length ? :

{graphError || copy.noWorkflowGraph}

} +
}
{notice && {notice}}