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
12 changes: 11 additions & 1 deletion backend/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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()))
Expand Down Expand Up @@ -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})*$"
)
Expand All @@ -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,
Expand Down
17 changes: 16 additions & 1 deletion backend/app/store.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import signal
import subprocess
import sys
import tempfile
import threading
import time
import uuid
Expand Down Expand Up @@ -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 ""
Expand All @@ -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,
Expand Down
4 changes: 2 additions & 2 deletions backend/orbit_sdk.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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()
Expand Down
3 changes: 2 additions & 1 deletion frontend/src/components/ui/python-editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 <CodeMirror
aria-label={ariaLabel}
value={value}
Expand All @@ -13,6 +13,7 @@ export function PythonEditor({value,onChange,ariaLabel}:{value:string;onChange:(
// letting one line widen the editor and push the save action off screen.
extensions={[python(),EditorView.lineWrapping]}
onChange={onChange}
onBlur={onBlur}
basicSetup={{lineNumbers:true,highlightActiveLine:true,bracketMatching:true,foldGutter:true}}
/>
}
2 changes: 1 addition & 1 deletion frontend/src/components/ui/status-badge.tsx
Original file line number Diff line number Diff line change
@@ -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 <span className={`badge ${style}`}>{label??value}</span>}
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 <span className={`badge ${style}`}>{label??value}</span>}
70 changes: 53 additions & 17 deletions frontend/src/features/assets/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import type {
TargetTestCaseSet,
TestCase,
Workflow,
WorkflowGraphDefinition,
WorkflowStep,
} from "../../domain/models";
import {
Expand All @@ -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";
Expand Down Expand Up @@ -377,8 +379,24 @@ function RunnerModal({
const [templates, setTemplates] = useState<RunnerTemplate[]>([]),
[draft, setDraft] = useState<RunnerAsset | undefined>(editing ?? undefined),
[selectedVersion, setSelectedVersion] = useState<number | null>(editing?.version ?? null),
[notice, setNotice] = useState("");
[notice, setNotice] = useState(""),
[editorTab, setEditorTab] = useState<"code" | "graph">("code"),
[workflowGraph, setWorkflowGraph] = useState<WorkflowGraphDefinition | null>(null),
[graphSource, setGraphSource] = useState(""),
[graphLoading, setGraphLoading] = useState(false),
[graphError, setGraphError] = useState("");
const importInput = useRef<HTMLInputElement>(null);
const draftSource = useRef(draft?.source ?? ""), graphInFlight = useRef<string | null>(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<RunnerTemplate[]>("/api/runner-templates")
Expand All @@ -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<WorkflowGraphDefinition | null>("/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 {
Expand Down Expand Up @@ -498,7 +537,7 @@ function RunnerModal({
</div>
</div>
<div className="runner-template-grid">
{templates.map((template) => (
{templateOptions.map((template) => (
<RunnerTemplateCard
key={template.id}
template={template}
Expand All @@ -514,7 +553,7 @@ function RunnerModal({
{notice && <small className="hint">{notice}</small>}
</Modal>
);
const selectedTemplate = templates.find(
const selectedTemplate = templateOptions.find(
(template) => template.id === draft.template_id,
);
const versions = editing?.versions ?? [];
Expand Down Expand Up @@ -587,17 +626,15 @@ function RunnerModal({
</label>
)}
{!editing && <p className="hint">{copy.initialVersion}</p>}
<label className="runner-source">
<FieldLabel
label={copy.source}
description={fieldHelp[locale].source}
/>
<PythonEditor
ariaLabel={copy.source}
value={draft.source}
onChange={(source) => setDraft({ ...draft, source })}
/>
</label>
<div className="runner-editor-tabs" role="tablist" aria-label={copy.source}>
<button className={editorTab === "code" ? "selected" : ""} role="tab" aria-selected={editorTab === "code"} type="button" onClick={() => setEditorTab("code")}><SectionInfo title={copy.source} description={fieldHelp[locale].source} /></button>
<button className={editorTab === "graph" ? "selected" : ""} role="tab" aria-selected={editorTab === "graph"} type="button" onClick={() => { setEditorTab("graph"); refreshWorkflowGraph(); }}>{copy.workflowGraph}</button>
</div>
{editorTab === "code" ? <div className="runner-source">
<PythonEditor ariaLabel={copy.source} value={draft.source} onChange={(source) => setDraft({ ...draft, source })} onBlur={() => refreshWorkflowGraph()} />
</div> : <section className="runner-workflow-graph">
{graphLoading ? <p className="hint">{copy.loadingGraph}</p> : workflowGraph?.nodes.length ? <WorkflowGraph nodes={workflowGraph.nodes} edges={workflowGraph.edges} /> : <p className="hint">{graphError || copy.noWorkflowGraph}</p>}
</section>}
<div className="modal-actions">
{notice && <small className="hint">{notice}</small>}
<button className="ghost" type="button" onClick={() => save(true)}>
Expand Down Expand Up @@ -772,7 +809,6 @@ export function ProfileCatalog({
test={test}
save={saveProfile}
tested={tested}
onClose={() => setOpen(false)}
t={locales[locale].evaluation}
help={copy.profileForm}
/>
Expand Down Expand Up @@ -1639,9 +1675,9 @@ function EnvironmentCatalog({
}
setKind(null);
await onRefresh();
pushToast("Asset saved", "success");
pushToast(t.assetSaved, "success");
} catch (error) {
pushToast(error instanceof Error ? error.message : "Asset save failed");
pushToast(error instanceof Error ? error.message : t.assetSaveFailed);
}
};
const help = fieldHelp[locale], sectionDetails = localeMessages<Record<string, string>>(locale, "sectionDetails");
Expand Down
Loading
Loading