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}
}
-
+
{executionEnvironments.map((item) => (
onDelete("execution-environment", item.id)}
/>
))}
-
+
@@ -1398,7 +1440,7 @@ function EnvironmentAssets({
Reusable repository, browser URL, and native runner prompt-file
target.
-
+
{targetEnvironments.map((item) => (
onDelete("target-environment", item.id)}
/>
))}
-
+
-
- {loading ?
: executionEnvironments.map((item) => (
+
+ {executionEnvironments.map((item) => (
onDelete("execution-environment", item.id)}
/>
))}
-
+
@@ -1680,8 +1722,8 @@ function EnvironmentCatalog({
{t.create}
-
- {loading ?
: targetEnvironments.map((item) => (
+
+ {targetEnvironments.map((item) => (
onDelete("target-environment", item.id)}
/>
))}
-
+
.hint { margin:0 0 18px; }
.dashboard-feedback-trends { grid-template-columns:repeat(2,minmax(0,1fr)); }.dashboard-feedback-trends .trend-head,.dashboard-feedback-trends > .analytics-chart:last-child { grid-column:1/-1; }.dashboard-feedback-trends .trend-head:has(.section-description),.evaluation-feedback-trends .trend-head:has(.section-description) { margin-bottom:-6px; }.dashboard-feedback-trends > .analytics-grid { display:contents; } @media(max-width:720px){.dashboard-feedback-trends { grid-template-columns:1fr; }}
-.build-table .tr:not(.th):hover { background:var(--surface-raised); }.table-link { border:0; padding:0; color:inherit; background:transparent; font:inherit; font-weight:700; text-align:left; cursor:pointer; }
+.build-table .tr.th,.active-evaluation-table .tr.th { margin-bottom:6px; border-bottom:1px solid var(--line); }.build-table .tr:not(.th):hover { background:var(--surface-raised); }.table-link { border:0; padding:0; color:inherit; background:transparent; font:inherit; font-weight:700; text-align:left; cursor:pointer; }
.content { padding-inline:32px; }
.content .table { display:block; width:100%; }
.table-sort { display:inline-flex; align-items:center; gap:4px; padding:0; border:0; color:inherit; background:transparent; font:inherit; text-transform:inherit; cursor:pointer; }
From b0a55b71dcfa1e2552675eea316ff12747a5ac5c Mon Sep 17 00:00:00 2001
From: forthfate
Date: Mon, 14 Sep 2026 10:50:43 +0900
Subject: [PATCH 3/7] Refine asset table layout and copy
---
frontend/src/features/assets/page.tsx | 32 ++++++++++++++++----------
frontend/src/locales/languages/en.json | 9 +++++---
frontend/src/locales/languages/ja.json | 9 +++++---
frontend/src/locales/languages/ko.json | 9 +++++---
frontend/src/theme-overrides.css | 9 ++++----
5 files changed, 43 insertions(+), 25 deletions(-)
diff --git a/frontend/src/features/assets/page.tsx b/frontend/src/features/assets/page.tsx
index e0a9d6f..1b147d6 100644
--- a/frontend/src/features/assets/page.tsx
+++ b/frontend/src/features/assets/page.tsx
@@ -85,13 +85,15 @@ function AssetCatalog({
children,
loading = false,
emptyHint,
+ locale = "en",
}: {
children: React.ReactNode;
loading?: boolean;
emptyHint: string;
+ locale?: Locale;
}) {
const [sort, setSort] = useState<{
- key: "name" | "detail" | "createdAt";
+ key: "name" | "createdAt";
direction: "asc" | "desc";
}>({ key: "name", direction: "asc" }),
rows = Children.toArray(children).filter(isValidElement).sort((left, right) => {
@@ -128,19 +130,18 @@ function AssetCatalog({
{loading ? : rows.length ? (
<>
- {(["name", "detail", "createdAt"] as const).map((key) => {
+ {(["name", "createdAt"] as const).map((key) => {
const Icon = icon(key);
return (
);
})}
+ {text[locale].columnActions}
{rows}
>
@@ -191,7 +192,7 @@ function Catalog({
{button}
- {children}
+ {children}
)}
>
@@ -658,7 +659,14 @@ function AssetRow({
{name}
{detail}
- {createdAt && }
+
- setProfileOpen(false)}
- >
- setProfileOpen(false)}
- t={evaluation}
- help={settingsCopy.profileForm}
- />
-
>
);
}