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..bd44204 100644
--- a/frontend/src/features/assets/page.tsx
+++ b/frontend/src/features/assets/page.tsx
@@ -81,6 +81,77 @@ const pipelineYaml = (workflow: Workflow | null | undefined) =>
})
.join("\n\n");
+function AssetCatalog({
+ children,
+ loading = false,
+ emptyHint,
+ locale = "en",
+}: {
+ children: React.ReactNode;
+ loading?: boolean;
+ emptyHint: string;
+ locale?: Locale;
+}) {
+ const [sort, setSort] = useState<{
+ key: "name" | "createdAt";
+ direction: "asc" | "desc";
+ }>({ key: "createdAt", direction: "desc" }),
+ rows = Children.toArray(children).filter(isValidElement).sort((left, right) => {
+ const a = String(
+ (left.props as { name?: string; detail?: string; createdAt?: string })[
+ sort.key
+ ] ?? "",
+ );
+ const b = String(
+ (right.props as { name?: string; detail?: string; createdAt?: string })[
+ sort.key
+ ] ?? "",
+ );
+ const value = a.localeCompare(b, undefined, {
+ numeric: true,
+ sensitivity: "base",
+ });
+ return sort.direction === "asc" ? value : -value;
+ }),
+ changeSort = (key: typeof sort.key) =>
+ setSort((current) => ({
+ key,
+ direction:
+ current.key === key && current.direction === "asc" ? "desc" : "asc",
+ })),
+ icon = (key: typeof sort.key) =>
+ sort.key !== key
+ ? ChevronsUpDown
+ : sort.direction === "asc"
+ ? ChevronUp
+ : ChevronDown;
+ return (
+
+ {loading ?
: rows.length ? (
+ <>
+
+ {(["name", "createdAt"] as const).map((key) => {
+ const Icon = icon(key);
+ return (
+
+ );
+ })}
+ {text[locale].columnActions}
+
+ {rows}
+ >
+ ) : (
+
{emptyHint}
+ )}
+
+ );
+}
+
function Catalog({
title,
tooltip,
@@ -104,16 +175,7 @@ function Catalog({
loading?: boolean;
locale: Locale;
}) {
- const isLegacyWorkflowSection = title === text[locale].flows,
- [sort, setSort] = useState<{ key: "name" | "detail" | "createdAt"; direction: "asc" | "desc" }>({ key: "name", direction: "asc" }),
- rows = Children.toArray(children).filter(isValidElement).sort((left, right) => {
- const a = String((left.props as { name?: string; detail?: string; createdAt?: string })[sort.key] ?? "");
- const b = String((right.props as { name?: string; detail?: string; createdAt?: string })[sort.key] ?? "");
- const value = a.localeCompare(b, undefined, { numeric: true, sensitivity: "base" });
- return sort.direction === "asc" ? value : -value;
- }),
- changeSort = (key: typeof sort.key) => setSort(current => ({ key, direction: current.key === key && current.direction === "asc" ? "desc" : "asc" })),
- icon = (key: typeof sort.key) => sort.key !== key ? ChevronsUpDown : sort.direction === "asc" ? ChevronUp : ChevronDown;
+ const isLegacyWorkflowSection = title === text[locale].flows;
return (
<>
{showRunners && runners && onRefresh && (
@@ -130,18 +192,7 @@ function Catalog({
{button}
-
- {loading ?
: Children.count(children) ? (
- <>
-
- {(["name", "detail", "createdAt"] as const).map((key) => { const Icon = icon(key); return ; })}
-
- {rows}
- >
- ) : (
-
{emptyHint}
- )}
-
+ {children}
)}
>
@@ -325,6 +376,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 +392,7 @@ function RunnerModal({
description: template.description,
template_id: template.id,
source: template.source,
+ version: 1,
});
const changeTemplate = () => {
setDraft(undefined);
@@ -368,16 +421,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 +517,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)}
/>
))}
-
+
@@ -1367,7 +1448,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)}
/>
))}
-
+
@@ -1649,8 +1730,8 @@ function EnvironmentCatalog({
{t.create}
-
- {loading ?
: targetEnvironments.map((item) => (
+
+ {targetEnvironments.map((item) => (
onDelete("target-environment", item.id)}
/>
))}
-
+
({
id: copy ? "" : b.id,
name: copy ? `${b.name} copy` : b.name,
runner_id: b.runner_id,
+ runner_version: b.runner_version ?? null,
execution_environment_id: b.execution_environment_id ?? "",
target_environment_id: b.target_environment_id ?? "",
purpose: b.purpose,
@@ -378,7 +382,7 @@ function Direct({
+
+
+
{
);
return `${Math.floor(seconds / 60)}m ${seconds % 60}s`;
};
+const displayedIteration = (run: Run) => {
+ const observed = Math.max(
+ 0,
+ ...(run.step_results ?? []).map((step) => step.loop_index ?? 0),
+ );
+ return run.loop_limit ? Math.min(observed, run.loop_limit) : observed;
+};
const copy = localeMessageMap>("evaluations");
type SupervisorResultTranslation = {
prompt?: string;
@@ -469,6 +476,12 @@ export function EvaluationsPage({
? new Date(r.finished_at).getTime() - new Date(r.created_at ?? 0).getTime()
: 0,
},
+ {
+ id: "status",
+ header: t.status,
+ render: (r) => ,
+ sortValue: (r) => label(r.status),
+ },
{
id: "phase",
header: t.phase,
@@ -493,12 +506,6 @@ export function EvaluationsPage({
sortValue: (r) => r.approved_improvements ?? 0,
},
{ id: "issues", header: t.issues, render: (r) => r.reported_issues ?? 0, sortValue: (r) => r.reported_issues ?? 0 },
- {
- id: "status",
- header: t.status,
- render: (r) => ,
- sortValue: (r) => label(r.status),
- },
{
id: "actions",
header: locales[locale].evaluation.action,
@@ -545,17 +552,14 @@ export function EvaluationsPage({
},
];
columns[1].header = l.task;
- columns.splice(4, 0, {
+ columns.splice(5, 0, {
id: "iteration",
header: l.iteration,
render: (r) => {
- const current = Math.max(
- 0,
- ...(r.step_results ?? []).map((step) => step.loop_index ?? 0),
- );
+ const current = displayedIteration(r);
return current ? `${current}/${r.loop_limit ?? current}` : "—";
},
- sortValue: (r) => Math.max(0, ...(r.step_results ?? []).map((step) => step.loop_index ?? 0)),
+ sortValue: displayedIteration,
});
const steps = useMemo(() => selected?.step_results ?? [], [selected?.step_results]);
const iterations =
@@ -932,7 +936,7 @@ export function EvaluationsPage({
setCandidateTab(r.iteration_candidates?.find((candidate) => candidate.iteration === latest && candidate.selected)?.id ?? null);
}}
className="active-evaluation-table"
- gridTemplateColumns="36px minmax(220px,2fr) minmax(145px,1fr) 82px 90px 72px 96px 96px 82px 94px 72px 72px"
+ gridTemplateColumns="36px minmax(220px,2fr) minmax(145px,1fr) 82px 72px 90px 72px 96px 96px 82px 94px 72px"
empty={t.noRuns}
/>
diff --git a/frontend/src/features/improvements/page.tsx b/frontend/src/features/improvements/page.tsx
index d10b399..8fe2ccf 100644
--- a/frontend/src/features/improvements/page.tsx
+++ b/frontend/src/features/improvements/page.tsx
@@ -98,6 +98,17 @@ const tick = (value: string) =>
});
const timestamp = (locale: Locale, value?: string) =>
value ? new Date(value).toLocaleString(intlLocales[locale]) : "—";
+const compactTimestamp = (locale: Locale, value?: string) =>
+ value
+ ? new Intl.DateTimeFormat(intlLocales[locale], {
+ month: "numeric",
+ day: "numeric",
+ hour: "2-digit",
+ minute: "2-digit",
+ }).format(new Date(value))
+ : "—";
+const lastRunTimestamp = (build: Build) =>
+ build.last_run_at ? Date.parse(build.last_run_at) || 0 : 0;
function Card({
title,
description,
@@ -762,11 +773,28 @@ export function ImprovementsPage() {
api("/api/builds")
.then((next) => {
setBuilds(next);
- setBuild((current) => current || next[0]?.id || "");
+ setBuild((current) =>
+ current ||
+ [...next].sort(
+ (left, right) =>
+ lastRunTimestamp(right) - lastRunTimestamp(left) ||
+ left.name.localeCompare(right.name),
+ )[0]?.id ||
+ "",
+ );
})
.catch(() => setBuilds([]))
.finally(() => setInitialLoading(false));
}, []);
+ const sortedBuilds = useMemo(
+ () =>
+ [...builds].sort((left, right) => {
+ const leftRun = lastRunTimestamp(left);
+ const rightRun = lastRunTimestamp(right);
+ return rightRun - leftRun || left.name.localeCompare(right.name);
+ }),
+ [builds],
+ );
if (initialLoading) return <>>;
return (
<>
@@ -774,7 +802,11 @@ export function ImprovementsPage() {
diff --git a/frontend/src/features/settings/page.tsx b/frontend/src/features/settings/page.tsx
index f9788b4..7fac949 100644
--- a/frontend/src/features/settings/page.tsx
+++ b/frontend/src/features/settings/page.tsx
@@ -1,5 +1,5 @@
import { useEffect, useState } from "react";
-import { AlertTriangle, Database, Pencil, Plus, Save, Trash2 } from "lucide-react";
+import { AlertTriangle, Database, Pencil, Save } from "lucide-react";
import type { Locale } from "../../locales";
import { localeMessages, localeOptions, locales } from "../../locales";
import { Modal } from "../../components/ui/modal";
@@ -8,7 +8,7 @@ import { SectionInfo } from "../../components/ui/section-info";
import type { OrbitLog, Settings } from "../../domain/models";
import { api } from "../../services/api";
import { useToast } from "../../components/ui/toast-context";
-import { ProfileForm, type ProfileFormCopy } from "../builds/page";
+import { ProfileCatalog } from "../assets/page";
import { OrbitLogs } from "./orbit-logs";
type ApplicationSettings = {
@@ -20,18 +20,9 @@ type ApplicationData = { path: string; size_bytes: number };
type ManagerCopy = { title:string; description:string; warning:string; edit:string; content:string; save:string; cancel:string; empty:string; saved:string };
-const profileBlank: Settings = {
- profile_name: "",
- provider: "azure-openai",
- model: "",
- endpoint: "",
- region: "us-east-1",
- secret_env: "AZURE_OPENAI_API_KEY",
- aws_profile: "",
-};
type ProfileCopy = { title:string; description:string; create:string; edit:string; empty:string; delete:string; chatProfile:string; chatProfileHint:string; selectChatProfile:string; saveChatProfile:string; chatProfileSaved:string };
type StorageCopy = { title:string; description:string; location:string; locationHint:string; size:string; calculating:string; save:string; saved:string };
-type SettingsCopy = { manager: ManagerCopy; profiles: ProfileCopy; storage: StorageCopy; profileForm: ProfileFormCopy };
+type SettingsCopy = { manager: ManagerCopy; profiles: ProfileCopy; storage: StorageCopy };
const bytes = (value: number) => {
const units = ["B", "KB", "MB", "GB", "TB"];
const index = value ? Math.min(Math.floor(Math.log(value) / Math.log(1024)), units.length - 1) : 0;
@@ -69,8 +60,7 @@ export function SettingsPage({
settingsCopy = localeMessages(locale, "settingsPage"),
l = settingsCopy.manager,
p = settingsCopy.profiles,
- sectionDetails = localeMessages>(locale, "sectionDetails"),
- evaluation = locales[locale].evaluation;
+ sectionDetails = localeMessages>(locale, "sectionDetails");
const [prompt, setPrompt] = useState(""),
[chatProfile, setChatProfile] = useState(""),
[dataPath, setDataPath] = useState(""),
@@ -78,8 +68,7 @@ export function SettingsPage({
[dataSize, setDataSize] = useState(null),
[dataLoading, setDataLoading] = useState(true),
[dataEditing, setDataEditing] = useState(false),
- [open, setOpen] = useState(false),
- [profileOpen, setProfileOpen] = useState(false);
+ [open, setOpen] = useState(false);
const { pushToast } = useToast();
useEffect(() => {
api("/api/application-settings")
@@ -129,7 +118,6 @@ export function SettingsPage({
pushToast(p.chatProfileSaved, "success");
})
.catch((error) => pushToast(error.message));
- const saveProfile = () => save().then(() => setProfileOpen(false));
const saveDataLocation = () => {
setDataLoading(true);
api("/api/application-data", "PUT", { path: dataPathDraft })
@@ -217,53 +205,17 @@ export function SettingsPage({
{dataLoading ? settingsCopy.storage.calculating : bytes(dataSize ?? 0)}
-
-
-
-
} />
-
{p.description}
-
-
-
-
- {profiles.length ? (
- profiles.map((profile) => (
-
-
-
-
- ))
- ) : (
-
{p.empty}
- )}
-
-
+
} />
{p.chatProfileHint}
@@ -337,22 +289,6 @@ export function SettingsPage({
- setProfileOpen(false)}
- >
- setProfileOpen(false)}
- t={evaluation}
- help={settingsCopy.profileForm}
- />
-
>
);
}
diff --git a/frontend/src/locales/languages/en.json b/frontend/src/locales/languages/en.json
index 42a993c..6dcc586 100644
--- a/frontend/src/locales/languages/en.json
+++ b/frontend/src/locales/languages/en.json
@@ -22,6 +22,9 @@
"addFlow": "Create workflow",
"id": "ID",
"name": "Name",
+ "columnName": "Name",
+ "columnCreated": "Created",
+ "columnActions": "Actions",
"version": "Version",
"body": "Content",
"description": "Description",
@@ -87,7 +90,7 @@
"settingsPage": {
"manager": {
"title": "Operational manager prompt",
- "description": "Defines Orbit-wide manager behavior and the required response contract. The selected build manager template is inserted at __ORBIT_MANAGER_AI_PROMPT__.",
+ "description": "Defines Orbit-wide manager behavior and the required response contract.",
"warning": "This prompt contains Orbit-reserved instructions, placeholders, and a response contract. Preserve all reserved sections and their structure; changing or removing them can stop evaluations or make results unreadable.",
"edit": "Edit",
"content": "Prompt content",
@@ -147,6 +150,11 @@
"label": "Runner",
"hint": "The reusable automation runner that performs this evaluation."
},
+ "runnerVersion": {
+ "label": "Runner version",
+ "hint": "Latest follows future Runner saves; a selected version stays fixed for this build.",
+ "latest": "Latest"
+ },
"targetEnvironment": {
"label": "Target environment",
"hint": "The target application or service being evaluated."
@@ -508,6 +516,10 @@
"id": "ID",
"name": "Name",
"description": "Description",
+ "version": "Base version for the new save",
+ "versionHint": "Save the selected code as a new version. Saved versions remain unchanged.",
+ "current": "current",
+ "initialVersion": "New runners are created as v1.",
"source": "Runner code",
"saveAndOpen": "Save and open in VS Code",
"save": "Save runner",
@@ -714,8 +726,8 @@
"targetEnvironment": "Repository, browser URL, and managed prompt path are used only by builds that select this target.",
"applicationSettings": "Language affects the console and manager-result display; theme changes are saved locally in this browser.",
"applicationData": "Changing the location affects future local state and evidence; existing data is not moved automatically.",
- "managerPrompt": "A console-wide operating prompt, separate from reusable build templates.",
- "systemAiModel": "This profile powers Orbit system features such as chat and cycle analysis, independently of a build's supervisor profile.",
+ "managerPrompt": "The manager prompt template selected by a build is inserted at __ORBIT_MANAGER_AI_PROMPT__.",
+ "systemAiModel": "This profile powers Orbit system features such as chat and cycle analysis.",
"orbitLogs": "Entries are local operational events. They help diagnose console and runner activity but are not evaluation evidence."
},
"chartHints": {
diff --git a/frontend/src/locales/languages/ja.json b/frontend/src/locales/languages/ja.json
index 22de036..21316ab 100644
--- a/frontend/src/locales/languages/ja.json
+++ b/frontend/src/locales/languages/ja.json
@@ -21,6 +21,9 @@
"addFlow": "ワークフローを作成",
"id": "ID",
"name": "名前",
+ "columnName": "名前",
+ "columnCreated": "作成日",
+ "columnActions": "操作",
"version": "バージョン",
"body": "本文",
"description": "説明",
@@ -86,7 +89,7 @@
"settingsPage": {
"manager": {
"title": "運用管理者プロンプト",
- "description": "Orbit 全体における管理 AI の振る舞いと必須の応答契約を定義します。ビルドで選択した管理者プロンプトテンプレートは __ORBIT_MANAGER_AI_PROMPT__ の位置に挿入されます。",
+ "description": "Orbit 全体における管理 AI の振る舞いと必須の応答契約を定義します。",
"warning": "このプロンプトには、Orbit が予約した指示、プレースホルダー、応答契約が含まれます。予約領域とその構造をすべて維持してください。変更または削除すると、評価が停止したり Orbit が結果を読めなくなる場合があります。",
"edit": "編集",
"content": "プロンプト本文",
@@ -146,6 +149,11 @@
"label": "ランナー",
"hint": "この評価を実行する再利用可能な自動化ランナーです。"
},
+ "runnerVersion": {
+ "label": "ランナーバージョン",
+ "hint": "最新は以後のランナー保存に追従し、選択したバージョンはこのビルドに固定されます。",
+ "latest": "最新"
+ },
"targetEnvironment": {
"label": "対象環境",
"hint": "評価対象のアプリケーションまたはサービスです。"
@@ -507,6 +515,10 @@
"id": "ID",
"name": "名前",
"description": "説明",
+ "version": "新しい保存の基準バージョン",
+ "versionHint": "選択したコードを新しいバージョンとして保存します。保存済みのバージョンは変更されません。",
+ "current": "現在",
+ "initialVersion": "新しいランナーは v1 として作成されます。",
"source": "ランナーコード",
"saveAndOpen": "保存して VS Code で開く",
"save": "ランナーを保存",
@@ -713,8 +725,8 @@
"targetEnvironment": "リポジトリ・ブラウザーURL・管理プロンプトパスは、この対象を選択したビルドにのみ適用されます。",
"applicationSettings": "言語はコンソールと管理者結果の表示に適用され、テーマ変更はこのブラウザーにローカル保存されます。",
"applicationData": "保存先の変更は今後のローカル状態と証跡に適用されます。既存データは自動移動されません。",
- "managerPrompt": "コンソール全体の運用プロンプトで、ビルド用テンプレートとは別に管理します。",
- "systemAiModel": "このプロファイルはビルドの監督者プロファイルとは別に、Orbit チャットやサイクル分析などのシステム機能で使用されます。",
+ "managerPrompt": "ビルドで選択した管理者プロンプトテンプレートは __ORBIT_MANAGER_AI_PROMPT__ の位置に挿入されます。",
+ "systemAiModel": "このプロファイルは Orbit チャットやサイクル分析などのシステム機能で使用されます。",
"orbitLogs": "項目はローカル運用イベントです。コンソールとランナー活動の診断用であり、評価の証跡には含まれません。"
},
"chartHints": {
diff --git a/frontend/src/locales/languages/ko.json b/frontend/src/locales/languages/ko.json
index 547f05a..4422a11 100644
--- a/frontend/src/locales/languages/ko.json
+++ b/frontend/src/locales/languages/ko.json
@@ -21,6 +21,9 @@
"addFlow": "워크플로 생성",
"id": "ID",
"name": "이름",
+ "columnName": "이름",
+ "columnCreated": "생성일",
+ "columnActions": "작업",
"version": "버전",
"body": "본문",
"description": "설명",
@@ -86,7 +89,7 @@
"settingsPage": {
"manager": {
"title": "운영 관리자 프롬프트",
- "description": "Orbit 전체의 관리자 AI 행동과 필수 응답 계약을 정의합니다. 빌드에서 선택한 관리자 프롬프트 템플릿은 __ORBIT_MANAGER_AI_PROMPT__ 위치에 삽입됩니다.",
+ "description": "Orbit 전체의 관리자 AI 행동과 필수 응답 계약을 정의합니다.",
"warning": "이 프롬프트에는 Orbit이 예약한 지시문, 플레이스홀더, 응답 계약이 포함됩니다. 예약된 영역과 구조를 모두 유지하세요. 변경하거나 삭제하면 평가가 중단되거나 Orbit이 결과를 읽지 못할 수 있습니다.",
"edit": "편집",
"content": "프롬프트 본문",
@@ -146,6 +149,11 @@
"label": "러너",
"hint": "이 평가를 수행하는 재사용 가능한 자동화 러너입니다."
},
+ "runnerVersion": {
+ "label": "러너 버전",
+ "hint": "최신은 이후 러너 저장을 따르고, 선택한 버전은 이 빌드에 고정됩니다.",
+ "latest": "최신"
+ },
"targetEnvironment": {
"label": "대상 환경",
"hint": "평가 대상 애플리케이션 또는 서비스입니다."
@@ -507,6 +515,10 @@
"id": "ID",
"name": "이름",
"description": "설명",
+ "version": "새 버전의 기준",
+ "versionHint": "선택한 코드에서 새 버전을 저장합니다. 저장된 버전은 변경되지 않습니다.",
+ "current": "현재",
+ "initialVersion": "새 러너는 v1으로 생성됩니다.",
"source": "러너 코드",
"saveAndOpen": "저장 후 VS Code에서 열기",
"save": "러너 저장",
@@ -713,8 +725,8 @@
"targetEnvironment": "저장소·브라우저 URL·관리 프롬프트 경로는 이 대상을 선택한 빌드에만 적용됩니다.",
"applicationSettings": "언어는 콘솔과 관리자 결과 표시 언어에 적용되고, 테마 변경은 이 브라우저에 로컬로 저장됩니다.",
"applicationData": "저장소 위치 변경은 이후의 로컬 상태와 근거에 적용됩니다. 기존 데이터는 자동으로 이동하지 않습니다.",
- "managerPrompt": "콘솔 전체 운영용 프롬프트이며 빌드 템플릿 자산과는 별개입니다.",
- "systemAiModel": "이 프로필은 빌드의 감독관 프로필과 별도로 Orbit 챗봇과 사이클 분석 같은 시스템 기능에 사용됩니다.",
+ "managerPrompt": "빌드에서 선택한 관리자 프롬프트 템플릿은 __ORBIT_MANAGER_AI_PROMPT__ 위치에 삽입됩니다.",
+ "systemAiModel": "이 프로필은 Orbit 챗봇과 사이클 분석 같은 시스템 기능에 사용됩니다.",
"orbitLogs": "항목은 로컬 운영 이벤트입니다. 콘솔과 러너 활동을 진단하는 용도이며 평가 근거에는 포함되지 않습니다."
},
"chartHints": {
diff --git a/frontend/src/theme-overrides.css b/frontend/src/theme-overrides.css
index 7156cba..af9107c 100644
--- a/frontend/src/theme-overrides.css
+++ b/frontend/src/theme-overrides.css
@@ -71,6 +71,7 @@ footer { display:flex; align-items:center; justify-content:space-between; margin
.build-list-toolbar { display:flex; justify-content:flex-start; margin:-2px 0 14px; }
.icon-button { width:28px; height:28px; padding:0; display:grid; place-items:center; }
.content .tr > span { display:flex; align-items:center; min-height:50px; }
+.build-table .tr > span,.active-evaluation-table .tr > span { min-height:28px; padding-block:0; }
.content .tr > span > .build-actions { padding:0; min-height:28px; white-space:normal; }
.build-table .tr { min-width:0; }.build-table .tr > span { min-width:0; padding-inline:6px; justify-content:flex-start!important; text-align:left; }
.lifecycle-editor { display:grid; gap:12px; margin-top:8px; }.lifecycle-editor > p { margin:0; color:var(--muted); font-size:11px; }.lifecycle-editor fieldset { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:10px; margin:0; padding:12px; border:1px solid var(--line); border-radius:8px; }.lifecycle-editor legend { padding:0 5px; color:var(--accent); font:600 11px 'DM Mono',monospace; text-transform:uppercase; }.lifecycle-editor label { display:grid; gap:5px; color:var(--muted); font-size:10px; }.lifecycle-editor label:nth-child(2),.lifecycle-editor label:nth-child(3) { grid-column:1/-1; }.command-editor { position:relative; min-height:68px; overflow:hidden; border:1px solid var(--line); border-radius:7px; background:var(--bg); }.command-editor pre,.command-editor textarea { box-sizing:border-box; width:100%; min-height:68px; margin:0; padding:9px; border:0; border-radius:0; font:11px/1.55 'DM Mono',monospace; white-space:pre-wrap; overflow-wrap:anywhere; }.command-editor pre { pointer-events:none; color:var(--text); }.command-editor textarea { position:absolute; inset:0; resize:vertical; color:transparent; caret-color:var(--text); background:transparent; }.command-string { color:#d9b97d; }.command-flag { color:#8fcddd; }.command-number { color:#c5ef87; }
@@ -95,7 +96,7 @@ footer { display:flex; align-items:center; justify-content:space-between; margin
.dashboard-hero-actions { display:flex; margin-top:6px; }.dashboard-hero-actions .approve { padding:9px 13px; font-size:11px; font-weight:700; }
.dashboard-overview .panel-head { margin-bottom:5px; }.dashboard-overview > .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-top:0; 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; }
@@ -112,9 +113,9 @@ footer { display:flex; align-items:center; justify-content:space-between; margin
.proposal-tree__iteration-meta,.proposal-tree__item-meta { display:flex!important; align-items:center; gap:8px; flex:none; }.proposal-tree__score { padding:3px 6px; border:1px solid color-mix(in srgb,var(--accent) 45%,var(--line)); border-radius:999px; color:var(--accent); background:color-mix(in srgb,var(--accent) 10%,var(--bg)); font:600 10px 'DM Mono',monospace; white-space:nowrap; }
.cycle-interventions__head { display:flex; align-items:flex-start; justify-content:space-between; gap:16px; }.cycle-interventions__head .panel-head { margin:0; }.cycle-interventions__head label { display:grid; gap:6px; color:var(--muted); font-size:10px; }.cycle-interventions__head select { min-width:220px; }.cycle-health { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:10px; margin:16px 0; }.cycle-health article { display:grid; gap:5px; padding:12px; border:1px solid var(--line); border-radius:8px; background:var(--bg); }.cycle-health small { color:var(--muted); font-size:10px; }.cycle-health strong { font:600 14px 'DM Mono',monospace; }.cycle-ai-action { display:grid; gap:10px; margin:16px 0; }.cycle-ai-action .approve { justify-self:start; }.cycle-ai-action pre { max-height:340px; overflow:auto; margin:0; padding:12px; border:1px solid var(--line); border-radius:8px; background:var(--bg); white-space:pre-wrap; font:11px/1.6 'DM Mono',monospace; } @media(max-width:720px){.cycle-interventions__head { flex-direction:column; }.cycle-interventions__head select { min-width:0; }.cycle-health { grid-template-columns:1fr; }}
.cycle-ai-response { max-height:340px; overflow:auto; padding:14px; border:1px solid var(--line); border-radius:8px; background:var(--bg); font-size:12px; line-height:1.65; }.cycle-ai-response > :first-child { margin-top:0; }.cycle-ai-response > :last-child { margin-bottom:0; }.cycle-ai-response h1,.cycle-ai-response h2,.cycle-ai-response h3 { margin:14px 0 7px; font-size:13px; }.cycle-ai-response p,.cycle-ai-response ul,.cycle-ai-response ol { margin:8px 0; }.cycle-ai-response ul,.cycle-ai-response ol { padding-left:20px; }.cycle-ai-response code { font:10px 'DM Mono',monospace; }.cycle-ai-response pre { overflow:auto; padding:9px; border-radius:6px; background:var(--surface-raised); }
-.catalog-list { display:grid; gap:6px; }.catalog-row { width:100%; display:grid; gap:4px; padding:12px 4px; border:0; border-top:1px solid var(--line); color:var(--text); background:transparent; text-align:left; }.catalog-row:first-child { border-top:0; }.catalog-row:hover { color:var(--accent); background:var(--surface-raised); }.catalog-row strong { font-size:12px; }.catalog-row span { color:var(--muted); font-size:11px; }.catalog-empty { margin:0; padding:14px 4px; color:var(--muted); font-size:12px; line-height:1.55; border-top:1px solid var(--line); }
-.catalog-list__header { display:grid; grid-template-columns:1.2fr 1fr 120px; gap:8px; padding:0 4px 6px; border-bottom:1px solid var(--line); }.catalog-list__header button { display:inline-flex; align-items:center; gap:4px; padding:0; border:0; color:var(--muted); background:transparent; font:10px 'DM Mono',monospace; text-transform:uppercase; cursor:pointer; text-align:left; }.catalog-list__header button:hover { color:var(--accent); }
-.catalog-row-wrap { display:flex; align-items:center; gap:8px; padding-inline:4px; border-top:1px solid var(--line); }.catalog-row-wrap:hover,.catalog-row-wrap:has(.catalog-row:focus-visible) { background:var(--surface-raised); }.catalog-row-wrap:hover .catalog-row,.catalog-row-wrap:has(.catalog-row:focus-visible) .catalog-row { color:var(--accent); background:transparent; }.catalog-row-wrap:hover .catalog-row__created,.catalog-row-wrap:has(.catalog-row:focus-visible) .catalog-row__created { color:var(--accent); }.catalog-row-wrap .catalog-row { flex:1; min-width:0; border-top:0; }.catalog-row-wrap .icon-button,.catalog-row__created { flex:none; }.catalog-row__created { margin-left:auto; white-space:nowrap; }
+.catalog-list { display:grid; gap:6px; max-height:360px; overflow-y:auto; overscroll-behavior:contain; }.catalog-row { width:100%; display:grid; min-height:28px; gap:4px; padding:0; border:0; border-top:1px solid var(--line); color:var(--text); background:transparent; text-align:left; }.catalog-row:first-child { border-top:0; }.catalog-row:hover { color:var(--accent); background:var(--surface-raised); }.catalog-row strong { font-size:12px; }.catalog-row span { color:var(--muted); font-size:11px; }.catalog-empty { margin:0; padding:14px 6px; color:var(--muted); font-size:12px; line-height:1.55; border-top:1px solid var(--line); }
+.catalog-list__header { position:sticky; top:0; z-index:1; display:grid; min-height:28px; grid-template-columns:minmax(0,1fr) 140px 56px; align-items:center; gap:8px; padding:0 6px; border-bottom:1px solid var(--line); background:var(--surface); }.catalog-list__header button,.catalog-list__header > span { display:inline-flex; align-items:center; gap:4px; padding:0; border:0; color:var(--muted); background:transparent; font:10px 'DM Mono',monospace; text-transform:uppercase; text-align:left; }.catalog-list__header button { cursor:pointer; }.catalog-list__header button:hover { color:var(--accent); }
+.catalog-row-wrap { display:grid; grid-template-columns:minmax(0,1fr) 140px 56px; align-items:center; gap:8px; padding-inline:6px; border-top:1px solid var(--line); }.catalog-row-wrap:hover,.catalog-row-wrap:has(.catalog-row:focus-visible) { background:var(--surface-raised); }.catalog-row-wrap:hover .catalog-row,.catalog-row-wrap:has(.catalog-row:focus-visible) .catalog-row { color:var(--accent); background:transparent; }.catalog-row-wrap:hover .catalog-row__created,.catalog-row-wrap:has(.catalog-row:focus-visible) .catalog-row__created { color:var(--accent); }.catalog-row-wrap .catalog-row { min-width:0; border-top:0; }.catalog-row-wrap .icon-button,.catalog-row__created { min-width:0; }.catalog-row__created { white-space:nowrap; }
.telemetry-tree,.telemetry-tree ul { display:grid; gap:6px; margin:0; padding:0; list-style:none; }.telemetry-tree ul { margin-left:13px; padding-left:15px; border-left:1px solid var(--line); }.trace-node { display:flex; width:100%; align-items:flex-start; gap:8px; padding:8px 10px; border:1px solid var(--line); border-radius:7px; color:var(--text); background:var(--bg); text-align:left; }.trace-node:not(:disabled) { cursor:pointer; }.trace-node:not(:disabled):hover { border-color:var(--accent); background:var(--surface-raised); }.trace-node:disabled { cursor:default; }.trace-node > div { display:grid; gap:3px; min-width:0; }.trace-node strong { font:600 11px 'DM Mono',monospace; }.trace-node small { overflow:hidden; color:var(--muted); text-overflow:ellipsis; white-space:nowrap; font-size:10px; }.trace-status { width:7px; height:7px; flex:none; margin-top:4px; border-radius:50%; }.trace-status--ok { background:#bce989; }.trace-status--error { background:#eaa89f; }
.test-case-hint { margin:0; color:var(--muted); font-size:11px; line-height:1.55; }.test-case-editor fieldset label { display:grid; gap:5px; color:var(--muted); font-size:11px; }.test-case-editor fieldset textarea { min-height:84px; }
.build-wizard .test-case-editor { display:none; }