From 36905bb135cb363aa2b8d681fb7795f7b665cf9d Mon Sep 17 00:00:00 2001 From: forthfate Date: Mon, 14 Sep 2026 09:59:48 +0900 Subject: [PATCH 1/7] Add versioned runner execution --- backend/app/main.py | 1 + backend/app/models.py | 3 + backend/app/store.py | 147 ++++++++++++++++++++++--- backend/tests/test_api.py | 29 +++++ frontend/src/domain/models.ts | 6 +- frontend/src/features/assets/page.tsx | 47 ++++++-- frontend/src/features/builds/page.tsx | 12 +- frontend/src/locales/languages/en.json | 9 ++ frontend/src/locales/languages/ja.json | 9 ++ frontend/src/locales/languages/ko.json | 9 ++ 10 files changed, 245 insertions(+), 27 deletions(-) diff --git a/backend/app/main.py b/backend/app/main.py index e558fc5..d2d96ab 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -373,6 +373,7 @@ class BuildCreate(BaseModel): id: str = Field(pattern=r"^[a-z][a-z0-9-]{2,63}$") name: str = Field(min_length=1, max_length=120) runner_id: str + runner_version: int | None = Field(default=None, ge=1) repository: str = "" # Legacy target-environment input. target_environment_id: str = "" execution_environment_id: str = "" diff --git a/backend/app/models.py b/backend/app/models.py index c84e6c8..5d49baf 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -45,6 +45,7 @@ class Workflow(BaseModel): risk: Literal["low", "medium", "high"] tags: list[str] = [] runner_id: str | None = None + runner_version: int | None = None steps: list[Step] test_steps: list[Step] | None = None @@ -65,6 +66,8 @@ class Run(BaseModel): id: str workflow_id: str workflow_name: str + runner_version: int | None = None + runner_source_sha256: str | None = None build_id: str | None = None build_name: str | None = None repository: str | None = None diff --git a/backend/app/store.py b/backend/app/store.py index 7650c49..e7cf46a 100644 --- a/backend/app/store.py +++ b/backend/app/store.py @@ -2155,17 +2155,61 @@ def runners(self) -> list[dict[str, str]]: metadata = path.with_suffix(".json") if metadata.exists(): values = json.loads(metadata.read_text(encoding="utf-8")) - assets.append({**values, "source": path.read_text(encoding="utf-8")}) + source = path.read_text(encoding="utf-8") + versions = values.get("versions") or [ + { + "version": int(values.get("version", 1)), + "source": source, + "created_at": values.get("created_at"), + } + ] + latest = max(versions, key=lambda item: int(item.get("version", 0))) + assets.append( + { + **values, + "version": int(latest["version"]), + "versions": versions, + "source": str(latest["source"]), + } + ) for directory in sorted(path for path in RUNNERS.iterdir() if path.is_dir()): entry, metadata = directory / "runner.py", directory / "runner.json" if entry.exists() and metadata.exists(): values = json.loads(metadata.read_text(encoding="utf-8")) - assets.append({**values, "source": entry.read_text(encoding="utf-8"), "bundle": True}) + source = entry.read_text(encoding="utf-8") + versions = values.get("versions") or [ + { + "version": int(values.get("version", 1)), + "source": source, + "created_at": values.get("created_at"), + } + ] + latest = max(versions, key=lambda item: int(item.get("version", 0))) + assets.append( + { + **values, + "version": int(latest["version"]), + "versions": versions, + "source": str(latest["source"]), + "bundle": True, + } + ) return assets - @staticmethod - def _runner_entry_path(runner_id: str) -> Path: + def _runner_entry_path(self, runner_id: str, version: int | None = None) -> Path: """Return a bundle entrypoint when present, otherwise the legacy runner file.""" + if version is not None: + runner = self._runner(runner_id) + selected = next( + (item for item in runner.get("versions", []) if int(item.get("version", 0)) == version), None + ) + if selected is None: + raise ValueError(f"runner version {version} does not exist") + path = RUNNERS / ".versions" / runner_id / f"v{version}.py" + path.parent.mkdir(parents=True, exist_ok=True) + if not path.exists(): + path.write_text(str(selected["source"]), encoding="utf-8") + return path bundled = RUNNERS / runner_id / "runner.py" return bundled if bundled.is_file() else RUNNERS / f"{runner_id}.py" @@ -2201,12 +2245,22 @@ def update_runner(self, runner_id: str, values: dict[str, str]) -> dict[str, str def _write_runner(self, runner_id: str, values: dict[str, str]) -> dict[str, str]: source = self._canonicalize_runner_source(str(values["source"])) compile(source, f"{runner_id}.py", "exec") + existing_versions = list(values.get("versions") or []) + version = max((int(item.get("version", 0)) for item in existing_versions), default=0) + 1 + version_record = { + "version": version, + "source": source, + "sha256": hashlib.sha256(source.encode("utf-8")).hexdigest(), + "created_at": now().isoformat(), + } asset = { "id": runner_id, "name": str(values["name"]).strip(), "description": str(values["description"]).strip(), "template_id": str(values.get("template_id", "custom")), "created_at": str(values.get("created_at") or now().isoformat()), + "version": version, + "versions": [*existing_versions, version_record], } if not asset["name"] or not asset["description"]: raise ValueError("runner requires a name and description") @@ -2225,7 +2279,7 @@ def _open_in_vscode(path: Path) -> None: def open_runner_in_vscode(self, runner_id: str) -> dict[str, str]: self._runner(runner_id) - self._open_in_vscode(self._runner_entry_path(runner_id)) + self._open_in_vscode(self._runner_entry_path(runner_id, int(self._runner(runner_id)["version"]))) return {"status": "opened"} def delete_runner(self, runner_id: str) -> None: @@ -2235,13 +2289,26 @@ def delete_runner(self, runner_id: str) -> None: (RUNNERS / f"{runner_id}.py").unlink(missing_ok=True) (RUNNERS / f"{runner_id}.json").unlink(missing_ok=True) - def _runner_execution_plan(self, runner_id: str) -> Workflow: + def _runner_execution_plan(self, runner_id: str, runner_version: int | None = None) -> Workflow: """Build the lifecycle declared by a runner without a workflow asset.""" runner = self._runner(runner_id) + source = runner["source"] + if runner_version is not None: + selected = next( + ( + item + for item in runner.get("versions", []) + if int(item.get("version", 0)) == runner_version + ), + None, + ) + if selected is None: + raise ValueError(f"runner version {runner_version} does not exist") + source = str(selected["source"]) lifecycle_order = ("before_all", "before_each", "execute", "verify", "after_each", "after_all") declared = { PHASE_ALIASES.get(phase, phase) - for phase in re.findall(r'@runner\.phase\(\s*["\']([^"\']+)["\']\s*\)', runner["source"]) + for phase in re.findall(r'@runner\.phase\(\s*["\']([^"\']+)["\']\s*\)', source) } phases = [phase for phase in lifecycle_order if phase in declared] if not phases: @@ -2251,7 +2318,12 @@ def _runner_execution_plan(self, runner_id: str) -> Workflow: id=phase, phase=phase, name=phase, - command=[sys.executable, str(self._runner_entry_path(runner_id)), "--phase", phase], + command=[ + sys.executable, + str(self._runner_entry_path(runner_id, runner_version)), + "--phase", + phase, + ], working_directory=str(ROOT), timeout_seconds=86_400 if phase == "execute" else 300, approval="not_required", @@ -2270,11 +2342,14 @@ def _runner_execution_plan(self, runner_id: str) -> Workflow: enabled=True, risk="medium", runner_id=runner_id, + runner_version=runner_version or int(runner["version"]), steps=steps, test_steps=deepcopy(steps), ) - def _runner_graph_definition(self, runner_id: str, repository: str | None) -> dict[str, Any] | None: + 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.""" environment = os.environ.copy() environment["PYTHONPATH"] = str(ROOT / "backend") + ( @@ -2284,7 +2359,7 @@ def _runner_graph_definition(self, runner_id: str, repository: str | None) -> di environment["ORBIT_APP_DATA"] = str(APP_DATA) try: result = subprocess.run( - [sys.executable, str(self._runner_entry_path(runner_id)), "--graph"], + [sys.executable, str(self._runner_entry_path(runner_id, runner_version)), "--graph"], cwd=repository or ROOT, text=True, stdout=subprocess.PIPE, @@ -2855,6 +2930,11 @@ def create_build(self, values: dict[str, Any]) -> dict[str, Any]: if any(build["id"] == build_id for build in self.builds()): raise ValueError("같은 ID의 빌드가 이미 있습니다.") runner = self._runner(values["runner_id"]) + runner_version = values.get("runner_version") + if runner_version is not None and not any( + int(item.get("version", 0)) == int(runner_version) for item in runner.get("versions", []) + ): + raise ValueError("runner version does not exist") execution_environment, target_environment = self._build_environment_values(values) executor = execution_environment["executor"] repository_value = str(target_environment["repository"]) @@ -2884,6 +2964,7 @@ def create_build(self, values: dict[str, Any]) -> dict[str, Any]: "name": values["name"], "enabled": values["enabled"], "runner_id": runner["id"], + "runner_version": int(runner_version) if runner_version is not None else None, "execution_environment_id": execution_environment.get("id", ""), "target_environment_id": target_environment.get("id", ""), "repository": repository_value @@ -2934,6 +3015,11 @@ def update_build(self, build_id: str, values: dict[str, Any]) -> dict[str, Any]: if values["id"] != build_id: raise ValueError("build ID cannot be changed") runner = self._runner(values["runner_id"]) + runner_version = values.get("runner_version") + if runner_version is not None and not any( + int(item.get("version", 0)) == int(runner_version) for item in runner.get("versions", []) + ): + raise ValueError("runner version does not exist") execution_environment, target_environment = self._build_environment_values(values) executor = execution_environment["executor"] repository_value = str(target_environment["repository"]) @@ -2964,6 +3050,7 @@ def update_build(self, build_id: str, values: dict[str, Any]) -> dict[str, Any]: "name": values["name"], "enabled": values["enabled"], "runner_id": runner["id"], + "runner_version": int(runner_version) if runner_version is not None else None, "execution_environment_id": execution_environment.get("id", ""), "target_environment_id": target_environment.get("id", ""), "repository": repository_value @@ -3874,6 +3961,7 @@ def create_run( self, runner_id: str, execution_mode: str = "run", + runner_version: int | None = None, build_id: str | None = None, build_name: str | None = None, supervisor_profile_name: str | None = None, @@ -3899,12 +3987,21 @@ def create_run( ) -> Run: if execution_mode not in {"run", "test"}: raise ValueError("execution_mode must be run or test") - runner = self._runner_execution_plan(runner_id) + runner_asset = self._runner(runner_id) + resolved_runner_version = runner_version or int(runner_asset["version"]) + runner = self._runner_execution_plan(runner_id, resolved_runner_version) + runner_source = next( + item["source"] + for item in runner_asset["versions"] + if int(item["version"]) == resolved_runner_version + ) needs_approval = False run = Run( id=uuid.uuid4().hex[:12], workflow_id=runner.id, workflow_name=runner.name, + runner_version=resolved_runner_version, + runner_source_sha256=hashlib.sha256(str(runner_source).encode("utf-8")).hexdigest(), build_id=build_id, build_name=build_name, repository=repository, @@ -3922,7 +4019,7 @@ def create_run( start_iteration=max(1, min(start_iteration, max(1, loop_limit))), retry_of_run_id=retry_of_run_id, retry_mode=retry_mode if retry_mode in {"restart", "resume"} else None, - workflow_graph=self._runner_graph_definition(runner_id, repository), + workflow_graph=self._runner_graph_definition(runner_id, repository, resolved_runner_version), repeat_interval_minutes=max(0, repeat_interval_minutes), cadence_mode="fixed" if cadence_mode == "fixed" else "after_completion", overrun_policy="interrupt_eval" if overrun_policy == "interrupt_eval" else "wait", @@ -4123,7 +4220,11 @@ def _start(self, run_id: str) -> None: def _execute(self, run_id: str) -> None: run = self._load(run_id) - workflow = self._runner_execution_plan(run.workflow_id) + workflow = ( + self._runner_execution_plan(run.workflow_id, run.runner_version) + if run.runner_version is not None + else self._runner_execution_plan(run.workflow_id) + ) resources: dict[str, Any] = { "workflow": workflow.model_dump(mode="json"), "build": {}, @@ -4154,7 +4255,7 @@ def _execute(self, run_id: str) -> None: # Insighta user simulator). Keep each step's runner directory intact; # the build repository is still captured on the Run and in its prompt. if workflow.runner_id: - runner_path = self._runner_entry_path(workflow.runner_id) + runner_path = self._runner_entry_path(workflow.runner_id, run.runner_version) for step in [*workflow.steps, *(workflow.test_steps or [])]: step.command = [sys.executable, str(runner_path), "--phase", step.phase] step.working_directory = run.repository or str(ROOT) @@ -5077,7 +5178,8 @@ def invoke_remote_build(self, build_id: str, execution_mode: str = "run") -> Run if executor.get("type") != "remote-http": return self.create_run( build["runner_id"], - execution_mode, + runner_version=build.get("runner_version"), + execution_mode=execution_mode, build_id=build["id"], build_name=build["name"], supervisor_profile_name=build.get("model_profile_name"), @@ -5104,6 +5206,21 @@ def invoke_remote_build(self, build_id: str, execution_mode: str = "run") -> Run id=uuid.uuid4().hex[:12], workflow_id=build["runner_id"], # Legacy Run field: stores the direct runner ID. workflow_name=self._runner(build["runner_id"])["name"], + runner_version=( + int(build["runner_version"]) + if build.get("runner_version") is not None + else int(self._runner(build["runner_id"])["version"]) + ), + runner_source_sha256=next( + item.get("sha256") + for item in self._runner(build["runner_id"])["versions"] + if int(item["version"]) + == ( + int(build["runner_version"]) + if build.get("runner_version") is not None + else int(self._runner(build["runner_id"])["version"]) + ) + ), build_id=build["id"], build_name=build["name"], supervisor_profile_name=build.get("model_profile_name"), diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index 4882865..3d5d348 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -655,6 +655,35 @@ def test_runner_execution_plan_stops_when_its_run_phase_fails(tmp_path, monkeypa assert workflow.steps_for("test")[0].on_failure == "stop" +def test_runner_saves_immutable_versions_and_can_resolve_an_older_version(tmp_path, monkeypatch): + monkeypatch.setattr(store_module, "RUNNERS", tmp_path / "runners") + store = store_module.ConsoleStore() + initial = store.create_runner( + { + "id": "versioned-runner", + "name": "Versioned runner", + "description": "Keeps runner source revisions.", + "source": "from orbit_sdk import runner\n@runner.phase('execute')\ndef run(ctx): pass\n", + } + ) + updated = store.update_runner( + "versioned-runner", + { + "name": initial["name"], + "description": initial["description"], + "source": "from orbit_sdk import runner\n@runner.phase('verify')\ndef verify(ctx): ctx.log('v2')\n", + }, + ) + + assert initial["version"] == 1 + assert [item["version"] for item in initial["versions"]] == [1] + assert [item["version"] for item in updated["versions"]] == [1, 2] + assert store._runner_entry_path("versioned-runner", 1).read_text(encoding="utf-8") == initial["source"] + assert "v2" in store._runner_entry_path("versioned-runner", 2).read_text(encoding="utf-8") + assert [step.phase for step in store._runner_execution_plan("versioned-runner", 1).steps] == ["execute"] + assert [step.phase for step in store._runner_execution_plan("versioned-runner", 2).steps] == ["verify"] + + def test_legacy_saved_runner_is_planned_with_canonical_phases(tmp_path, monkeypatch): monkeypatch.setattr(store_module, "RUNNERS", tmp_path / "runners") store_module.RUNNERS.mkdir() diff --git a/frontend/src/domain/models.ts b/frontend/src/domain/models.ts index 91245a8..b845bc7 100644 --- a/frontend/src/domain/models.ts +++ b/frontend/src/domain/models.ts @@ -3,7 +3,7 @@ export type TestCase = { id:string; name:string; prompt:string; acceptance:strin export type TargetTestCaseSet = { id:string; name:string; description:string; cases:TestCase[]; created_at?:string } export type ExecutionEnvironment = {id:string;name:string;executor:{type:'local'|'remote-http';endpoint?:string;method?:'GET'|'POST'|'PUT';timeout_seconds?:number;headers?:Record};browser_executable_path?:string;browser_library_path?:string;environment_variables?:Record;created_at?:string} export type TargetEnvironment = {id:string;name:string;repository:string;browser_base_url?:string;managed_prompt_path?:string;created_at?:string} -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} } +export type Build = { id:string; name:string; enabled:boolean; runner_id:string;runner_version?:number|null; 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} } 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; 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 } @@ -14,7 +14,7 @@ export type BehaviorTrace = { purpose:string; rationale:string; observation:stri export type SupervisorEvaluation = { score:number; approval:'approved'|'rejected'|'pending'; behavior_summary?:string; behavior_trace?:BehaviorTrace; summary:string } export type SupervisorResult = { evaluation?:SupervisorEvaluation; improvements:Record[]; reported_issues:Record[] } export type SupervisorRecord = { iteration:number; candidate_id?:string|null; status:'pending'|'completed'|'not_configured'|'invalid_response'|'failed'; prompt?:string; response?:SupervisorResult; error?:string; recorded_at?:string } -export type Run = { id:string; workflow_id:string; workflow_name:string; build_id?:string; build_name?:string; execution_mode?:'run'|'test'; execution_type?:'pipeline'|'invoke'; loop_limit?:number;start_iteration?:number;retry_of_run_id?:string;retry_mode?:'restart'|'resume';iteration_strategy?:'linear'|'score_select';candidates_per_iteration?:number;iteration_candidates?:{id:string;iteration:number;score?:number;status:string;selected?:boolean}[]; status:string; created_at?:string; updated_at?:string; finished_at?:string; current_phase?:string; pid?:number; last_pid?:number; telemetry_trace_id?:string; prompt_source?:string; prompt_snapshot?:string; supervisor_status?:'pending'|'completed'|'not_configured'|'invalid_response'|'failed'; supervisor_response?:SupervisorResult; supervisor_error?:string; supervisor_results?:SupervisorRecord[]; workflow_graph?:WorkflowGraphDefinition|null; step_results?:RunStepResult[]; proposed_improvements?:number; approved_improvements?:number; reported_issues?:number; approval_score?:number } +export type Run = { id:string; workflow_id:string; workflow_name:string;runner_version?:number;runner_source_sha256?:string; build_id?:string; build_name?:string; execution_mode?:'run'|'test'; execution_type?:'pipeline'|'invoke'; loop_limit?:number;start_iteration?:number;retry_of_run_id?:string;retry_mode?:'restart'|'resume';iteration_strategy?:'linear'|'score_select';candidates_per_iteration?:number;iteration_candidates?:{id:string;iteration:number;score?:number;status:string;selected?:boolean}[]; status:string; created_at?:string; updated_at?:string; finished_at?:string; current_phase?:string; pid?:number; last_pid?:number; telemetry_trace_id?:string; prompt_source?:string; prompt_snapshot?:string; supervisor_status?:'pending'|'completed'|'not_configured'|'invalid_response'|'failed'; supervisor_response?:SupervisorResult; supervisor_error?:string; supervisor_results?:SupervisorRecord[]; workflow_graph?:WorkflowGraphDefinition|null; step_results?:RunStepResult[]; proposed_improvements?:number; approved_improvements?:number; reported_issues?:number; approval_score?:number } export type TelemetrySpan = { name:string; traceId:string; spanId:string; parentSpanId?:string|null; startTime?:number; endTime?:number; attributes?:Record; status?:string; events?:{name:string;attributes?:Record}[] } export type RunTelemetry = { trace_id?:string; spans:TelemetrySpan[] } export type PromptRevision = { iteration?:number; phase?:string; path:string; status:'initial'|'applied'|'blocked'|'rolled_back'|'unchanged'; reason?:string|null; recorded_at?:string; version_id?:string|null; run_id?:string|null; before?:string|null; after?:string|null; before_sha256?:string|null; after_sha256?:string|null } @@ -29,7 +29,7 @@ export type OrbitLog = { time:string; name:string; status:string; message:string export type Settings = { profile_name:string; provider:string; model:string; endpoint:string; region:string; secret_env:string; aws_profile?:string;created_at?:string } export type WorkflowStep = { id:string; phase:'before_all'|'before_each'|'execute'|'verify'|'after_each'|'after_all'; name:string; command:string[]; working_directory:string; timeout_seconds:number; approval:'not_required'|'required'; on_failure:'stop'|'continue'; minimum_interval_seconds?:number } export type Workflow = { id:string; name:string; description:string; kind:string; enabled:boolean; risk:string; steps?:WorkflowStep[] } -export type RunnerAsset = { id:string; name:string; description:string; template_id:string; source:string;created_at?:string } +export type RunnerAsset = { id:string; name:string; description:string; template_id:string; source:string;version:number;versions?:{version:number;source:string;sha256?:string;created_at?:string}[];created_at?:string } export type RunnerTemplate = { id:string; name:string; description:string; source:string; origin?:'built-in'|'user' } export type QuickStartParameter = {key:string;label:string;type:'string'|'workspace'|'url'|'model_profile'|'select';required?:boolean;default?:string;description?:string;placeholder?:string;options?:{value:string;label:string}[]} export type QuickStart = {schema_version:number;id:string;version:string;name:string;description:string;publisher?:{name:string;url?:string};parameters:QuickStartParameter[]} diff --git a/frontend/src/features/assets/page.tsx b/frontend/src/features/assets/page.tsx index a649f7e..a927a9d 100644 --- a/frontend/src/features/assets/page.tsx +++ b/frontend/src/features/assets/page.tsx @@ -325,6 +325,7 @@ function RunnerModal({ const copy = runnerLabels[locale]; const [templates, setTemplates] = useState([]), [draft, setDraft] = useState(editing ?? undefined), + [selectedVersion, setSelectedVersion] = useState(editing?.version ?? null), [notice, setNotice] = useState(""); const importInput = useRef(null); useEffect(() => { @@ -340,6 +341,7 @@ function RunnerModal({ description: template.description, template_id: template.id, source: template.source, + version: 1, }); const changeTemplate = () => { setDraft(undefined); @@ -368,16 +370,23 @@ function RunnerModal({ const save = (openInVsCode = false) => { if (!draft) return; const runnerId = editing?.id ?? draft.id; + const values = editing + ? { + name: draft.name, + description: draft.description, + source: draft.source, + } + : { + id: draft.id, + name: draft.name, + description: draft.description, + template_id: draft.template_id, + source: draft.source, + }; api( editing ? `/api/runners/${editing.id}` : "/api/runners", editing ? "PUT" : "POST", - editing - ? { - name: draft.name, - description: draft.description, - source: draft.source, - } - : draft, + values, ) .then(async () => { onSaved(); @@ -457,6 +466,13 @@ function RunnerModal({ const selectedTemplate = templates.find( (template) => template.id === draft.template_id, ); + const versions = editing?.versions ?? []; + const selectVersion = (version: number) => { + const selected = versions.find((item) => item.version === version); + if (!selected) return; + setSelectedVersion(version); + setDraft({ ...draft, source: selected.source }); + }; return ( + {editing && ( + + )} + {!editing &&

{copy.initialVersion}

}