diff --git a/backend/app/main.py b/backend/app/main.py index d2d96ab..4d3ef5d 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -204,6 +204,11 @@ def builds(): return store.builds() +@app.get("/api/builds/{build_id}/state") +def build_state(build_id: str): + return safely(lambda: store.build_state(build_id)) + + @app.get("/api/prompt-templates") def prompt_templates(): return store.prompt_templates() @@ -407,6 +412,10 @@ class BuildCreate(BaseModel): enabled: bool = True +class BuildStarUpdate(BaseModel): + starred: bool + + class PipelineCreate(BaseModel): """The requested execution mode for a project pipeline.""" @@ -520,6 +529,11 @@ def update_build(build_id: str, values: BuildCreate): return safely(lambda: store.update_build(build_id, values.model_dump())) +@app.patch("/api/builds/{build_id}/star") +def update_build_star(build_id: str, values: BuildStarUpdate): + return safely(lambda: store.set_build_star(build_id, values.starred)) + + @app.delete("/api/builds/{build_id}") def delete_build(build_id: str): return safely(lambda: store.delete_build(build_id)) diff --git a/backend/app/store.py b/backend/app/store.py index e7cf46a..13bf18a 100644 --- a/backend/app/store.py +++ b/backend/app/store.py @@ -79,11 +79,11 @@ def _application_data_dir() -> Path: Your final response must be exactly one JSON object: { - \"evaluation\": {\"score\":\"number from 0 to 10\",\"approval\":\"approved|rejected|pending\",\"summary\":\"string\",\"behavior_trace\": {\"purpose\":\"string\",\"rationale\":\"string\",\"observation\":\"string\",\"decision\":\"string\",\"next_action\":\"string\"}, \"behavior_summary\":\"legacy string, only when the evaluated target is an AI\"}, + \"evaluation\": {\"score\":\"number from 0 to 10\",\"approval\":\"approved|rejected|pending\",\"summary\":\"string\",\"behavior_trace\": {\"persona_goal\":\"string\",\"expectation\":\"string\",\"interpretation\":\"string\",\"evidence\":\"string\",\"impact\":\"string\",\"next_step\":\"string\"}}, \"improvements\": [{\"title\":\"string\",\"status\":\"proposed|adopted|rejected\",\"rationale\":\"string\",\"acceptanceEvidence\":\"string\"}], \"reported_issues\": [{\"title\":\"string\",\"severity\":\"low|medium|high|critical\",\"evidence\":\"string\",\"reproduction\":\"string\",\"status\":\"open|acknowledged|resolved\"}] } -For an evaluated AI, include behavior_trace and fill every field. It is an evidence-backed activity record for a person reviewing the run: purpose explains why this check or action matters now; rationale names only the observable evidence or declared plan behind it; observation records the material change or finding in this iteration; decision records what the target AI did or deliberately did not do; next_action states the specific next check or hypothesis. Compare with the immediately previous iteration when that evidence is supplied. Do not narrate repeated mechanics (navigation, waits, screenshots, or generic control inspection). When there is no material change, say so briefly and make next_action explain how the next check will differ or escalate. Do not reveal hidden reasoning or evaluator chain-of-thought. Do not include behavior_trace for non-AI targets. behavior_summary is optional legacy compatibility only; prefer behavior_trace. Always include both array keys, using empty arrays when there are no items.""" +For an evaluated AI, include behavior_trace and fill every field. This is an evidence-backed persona journey, not the evaluator's procedure and not hidden reasoning: persona_goal is the persona's stated goal in this session; expectation is the information or reassurance the persona needs before safely proceeding; interpretation is the persona's concise, first-person reading of the rendered experience; evidence states only the observable source-backed facts that support that reading; impact states how the experience affects the persona's confidence or ability to continue; next_step is the specific safe next check or journey step. Use the persona's wording where useful, but do not invent motives, feelings, beliefs, or facts beyond the declared persona and observed evidence. Compare with the immediately previous iteration when that evidence is supplied. Do not narrate repeated mechanics such as navigation, waits, screenshots, or generic control inspection. Do not reveal hidden reasoning or evaluator chain-of-thought. Do not include behavior_trace for non-AI targets. behavior_summary is deprecated and should be omitted. Always include both array keys, using empty arrays when there are no items.""" LEGACY_OPERATIONAL_MANAGER_PROMPT = """You are an approval-first operations manager for recurring AI evaluations. Preserve the task safety boundary, collect observable evidence, and never claim success without stated acceptance evidence. Escalate required approvals @@ -1190,7 +1190,19 @@ def display_fields(item: Any, fields: tuple[str, ...]) -> dict[str, str]: { "behavior_trace": display_fields( behavior_trace, - ("purpose", "rationale", "observation", "decision", "next_action"), + ( + "persona_goal", + "expectation", + "interpretation", + "evidence", + "impact", + "next_step", + "purpose", + "rationale", + "observation", + "decision", + "next_action", + ), ) } if isinstance(behavior_trace, dict) @@ -2239,10 +2251,14 @@ def migrate_runner_to_bundle(self, runner_id: str) -> dict[str, str]: def update_runner(self, runner_id: str, values: dict[str, str]) -> dict[str, str]: existing = self._runner(runner_id) return self._write_runner( - runner_id, {**existing, **{key: value for key, value in values.items() if value is not None}} + runner_id, + {**existing, **{key: value for key, value in values.items() if value is not None}}, + bundle=bool(existing.get("bundle")), ) - def _write_runner(self, runner_id: str, values: dict[str, str]) -> dict[str, str]: + def _write_runner( + self, runner_id: str, values: dict[str, str], *, bundle: bool = False + ) -> 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 []) @@ -2264,7 +2280,12 @@ def _write_runner(self, runner_id: str, values: dict[str, str]) -> dict[str, str } if not asset["name"] or not asset["description"]: raise ValueError("runner requires a name and description") - source_path, metadata_path = RUNNERS / f"{runner_id}.py", RUNNERS / f"{runner_id}.json" + source_path, metadata_path = ( + (RUNNERS / runner_id / "runner.py", RUNNERS / runner_id / "runner.json") + if bundle + else (RUNNERS / f"{runner_id}.py", RUNNERS / f"{runner_id}.json") + ) + source_path.parent.mkdir(parents=True, exist_ok=True) source_path.write_text(source, encoding="utf-8") metadata_path.write_text(json.dumps(asset, indent=2), encoding="utf-8") return {**asset, "source": source} @@ -2389,6 +2410,7 @@ def builds(self) -> list[dict[str, Any]]: # Existing builds predate this optional policy, so leave it off # unless an operator explicitly enabled it. build.setdefault("require_human_approval_before_apply", False) + build.setdefault("starred", False) self._hydrate_build_environment(build) build.update(self._repository_metadata(str(build.get("repository", "")))) build.setdefault("created_at", fallback) @@ -2909,6 +2931,31 @@ def build(self, build_id: str) -> dict[str, Any]: return build raise KeyError(build_id) + def build_state(self, build_id: str) -> list[dict[str, Any]]: + """Expose a build's SDK-managed state without treating it as run evidence.""" + self.build(build_id) + directory = APP_DATA / "runner-state" / build_id + if not directory.is_dir(): + return [] + states = [] + for path in sorted(directory.glob("*.json")): + try: + document = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + continue + if not isinstance(document, dict) or document.get("schema_version") != 1: + continue + states.append( + { + "name": path.stem, + "updated_at": document.get("updated_at"), + "run_id": document.get("run_id"), + "iteration": document.get("iteration"), + "value": document.get("value"), + } + ) + return sorted(states, key=lambda item: str(item.get("updated_at") or ""), reverse=True) + def workspaces(self, path: str | None = None) -> dict[str, Any]: if path is None: root = Path(Path.cwd().anchor) @@ -2963,6 +3010,7 @@ def create_build(self, values: dict[str, Any]) -> dict[str, Any]: "id": build_id, "name": values["name"], "enabled": values["enabled"], + "starred": False, "runner_id": runner["id"], "runner_version": int(runner_version) if runner_version is not None else None, "execution_environment_id": execution_environment.get("id", ""), @@ -3049,6 +3097,7 @@ def update_build(self, build_id: str, values: dict[str, Any]) -> dict[str, Any]: "id": build_id, "name": values["name"], "enabled": values["enabled"], + "starred": bool(existing.get("starred", False)), "runner_id": runner["id"], "runner_version": int(runner_version) if runner_version is not None else None, "execution_environment_id": execution_environment.get("id", ""), @@ -3092,6 +3141,17 @@ def update_build(self, build_id: str, values: dict[str, Any]) -> dict[str, Any]: temporary.replace(CONFIG / "builds.yaml") return build + def set_build_star(self, build_id: str, starred: bool) -> dict[str, Any]: + builds = self.builds() + index = next((i for i, build in enumerate(builds) if build["id"] == build_id), None) + if index is None: + raise KeyError(build_id) + builds[index]["starred"] = starred + temporary = CONFIG / "builds.tmp" + temporary.write_text(yaml.safe_dump(builds, allow_unicode=True, sort_keys=False), encoding="utf-8") + temporary.replace(CONFIG / "builds.yaml") + return builds[index] + def delete_build(self, build_id: str) -> None: builds = self.builds() remaining = [build for build in builds if build["id"] != build_id] @@ -4377,6 +4437,8 @@ def _execute(self, run_id: str) -> None: self._execute_step(run_id, step, loop_index, resources, allow_terminal=True) if self._load(run_id).status in {"failed", "cancelled"}: break + if run.execution_mode == "run" and self._load(run_id).status == "running": + self._complete_supervision(run_id) if ( loop_index < run.loop_limit and run.repeat_interval_minutes @@ -4506,13 +4568,20 @@ def _validated_supervisor_result(text: str) -> dict[str, Any]: raise ValueError("supervisor evaluation behavior_summary is invalid") if "behavior_trace" in evaluation: trace = evaluation["behavior_trace"] - trace_fields = {"purpose", "rationale", "observation", "decision", "next_action"} + persona_trace_fields = { + "persona_goal", + "expectation", + "interpretation", + "evidence", + "impact", + "next_step", + } + legacy_trace_fields = {"purpose", "rationale", "observation", "decision", "next_action"} if ( not isinstance(trace, dict) - or set(trace) != trace_fields - or not all( - isinstance(trace[field], str) and trace[field].strip() for field in trace_fields - ) + or frozenset(trace) + not in {frozenset(persona_trace_fields), frozenset(legacy_trace_fields)} + or not all(isinstance(trace[field], str) and trace[field].strip() for field in trace) ): raise ValueError("supervisor evaluation behavior_trace is invalid") return result diff --git a/backend/orbit_sdk.py b/backend/orbit_sdk.py index c651ec9..25f4b7d 100644 --- a/backend/orbit_sdk.py +++ b/backend/orbit_sdk.py @@ -11,6 +11,7 @@ import hashlib import json import os +import re import subprocess import tempfile import threading @@ -19,7 +20,7 @@ from datetime import UTC, datetime from functools import wraps from pathlib import Path -from typing import Any, Callable, Literal +from typing import Any, Callable, Iterator, Literal PROJECT_ROOT = Path(os.environ.get("ORBIT_TARGET_REPOSITORY", Path.cwd())).resolve() ORBIT_APP_DATA = Path(os.environ.get("ORBIT_APP_DATA", Path.home() / ".local" / "share" / "orbit")).resolve() @@ -218,6 +219,12 @@ def _proposal_history_path(project_root: Path) -> Path: return ORBIT_APP_DATA / "proposal-history" / project_key / "decisions.json" +def _state_name(name: str) -> str: + if not re.fullmatch(r"[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}", name): + raise ValueError("state name must be 1-64 letters, numbers, underscores, or hyphens") + return name + + def _repository_snapshot_paths(project_root: Path) -> tuple[Path, Path]: """Return the private manifest location for Git-object repository snapshots.""" project_key = _sha256(str(project_root).encode("utf-8")) @@ -252,8 +259,15 @@ def __post_init__(self) -> None: self.phase = canonical_phase(self.phase) @contextmanager - def function(self, function_id: str): - """Record one graph-annotated function's outcome within this lifecycle phase.""" + def function(self, function_id: str) -> Iterator[None]: + """Record one graph-annotated function's outcome within this lifecycle phase. + + Args: + function_id: Stable graph function identifier shown in retained run evidence. + + Yields: + Control to the wrapped function body. + """ if not function_id.strip(): raise ValueError("function_id must not be empty") if function_id in self._active_workflow_functions: @@ -314,6 +328,9 @@ def resources(self) -> dict[str, object]: The snapshot can contain the workflow, build, fixed test cases, model profile, and execution-environment settings. Prefer the typed convenience properties when one is available. + + Returns: + Immutable resource values supplied for this invocation. """ encoded = self.environment.get("ORBIT_RUNNER_RESOURCES", "") if not encoded: @@ -322,12 +339,20 @@ def resources(self) -> dict[str, object]: @property def project_root(self) -> Path: - """Evaluation target root; use this instead of a machine-specific path.""" + """Evaluation target root; use this instead of a machine-specific path. + + Returns: + Resolved root directory of the evaluated target. + """ return self.target_repository.resolve() @property def app_data(self) -> Path: - """Orbit's per-user writable data directory.""" + """Orbit's per-user writable data directory. + + Returns: + Writable Orbit AppData directory. + """ return ORBIT_APP_DATA def project_path(self, *parts: str) -> Path: @@ -345,13 +370,81 @@ def project_path(self, *parts: str) -> Path: return ORBIT_PROJECT_PATH(*parts) def managed_asset_dir(self, name: str) -> Path: - """Return a private, runner-managed AppData directory for a named asset set.""" + """Return a private, runner-managed AppData directory for a named asset set. + + Args: + name: Non-empty relative name for the runner-owned asset set. + + Returns: + Writable AppData directory isolated from the target repository. + """ if not name or any(part in {"", ".", ".."} for part in Path(name).parts): raise ValueError("managed asset name must be a relative, non-empty path") directory = self.app_data / "runner-assets" / _sha256(str(self.project_root).encode()) / name directory.mkdir(parents=True, exist_ok=True) return directory + def _state_path(self, name: str) -> Path: + state_name = _state_name(name) + build_id = str(self.build.get("id") or _sha256(str(self.project_root).encode())[:16]) + directory = self.app_data / "runner-state" / build_id + directory.mkdir(parents=True, exist_ok=True) + return directory / f"{state_name}.json" + + def load_state(self, name: str, default: object = None) -> object: + """Load mutable, build-scoped runner state from Orbit AppData. + + State is separate from immutable run evidence. Use it only for bounded + continuation data required by a later run, such as a persona's last + observation or next check. State values must be JSON-safe. + + Args: + name: Stable state name containing letters, numbers, underscores, or hyphens. + default: Value returned when no saved state exists. + + Returns: + The saved JSON value or ``default`` when the named state is absent. + """ + path = self._state_path(name) + try: + document = json.loads(path.read_text(encoding="utf-8")) + except FileNotFoundError: + return default + except json.JSONDecodeError as error: + raise RuntimeError(f"Orbit runner state is invalid: {name}") from error + if not isinstance(document, dict) or document.get("schema_version") != 1: + raise RuntimeError(f"Orbit runner state is invalid: {name}") + return document.get("value", default) + + def save_state(self, name: str, value: object) -> dict[str, object]: + """Atomically save mutable, build-scoped runner state in Orbit AppData. + + The value is not copied into run evidence. Emit a bounded summary with + :meth:`emit_result` when a particular state transition needs auditing. + + Args: + name: Stable state name containing letters, numbers, underscores, or hyphens. + value: JSON-safe value to retain for a later invocation of this build. + + Returns: + State name, update timestamp, and JSON byte size. + """ + path = self._state_path(name) + try: + encoded_value = json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":")) + except (TypeError, ValueError) as error: + raise ValueError("state value must be JSON serializable") from error + document = { + "schema_version": 1, + "updated_at": datetime.now(UTC).isoformat(), + "run_id": self.environment.get("ORBIT_RUN_ID") or None, + "iteration": self.loop_index, + "value": json.loads(encoded_value), + } + payload = json.dumps(document, ensure_ascii=False, indent=2, sort_keys=True).encode("utf-8") + _atomic_write(path, payload) + return {"name": _state_name(name), "updated_at": document["updated_at"], "size": len(payload)} + def materialize_assets(self, name: str, files: dict[str, str | bytes]) -> dict[str, object]: """Atomically materialize runner-owned files outside the target repository. @@ -498,7 +591,11 @@ def git_candidate( return result def git_head(self) -> str | None: - """Return the checked-out commit, or None when the target is not a Git repository.""" + """Return the checked-out commit, or None when the target is not a Git repository. + + Returns: + Checked-out commit SHA, or ``None`` when no HEAD is available. + """ result = subprocess.run( ["git", "rev-parse", "--verify", "HEAD"], cwd=self.project_root, @@ -682,7 +779,11 @@ def snapshot_repository(self, label: str, *, once: bool = False) -> dict[str, ob return record def repository_snapshots(self) -> list[dict[str, object]]: - """List retained repository snapshots, newest first.""" + """List retained repository snapshots, newest first. + + Returns: + Retained snapshot metadata ordered newest first. + """ _, _, manifest = self._snapshot_manifest() snapshots = manifest["snapshots"] assert isinstance(snapshots, list) @@ -734,6 +835,12 @@ def restore_repository_snapshot(self, snapshot_id: str) -> dict[str, object]: recorded state. It also removes files created after the snapshot, including ignored and untracked files inside the target repository. Call this only while Orbit exclusively owns the target repository. + + Args: + snapshot_id: Identifier returned by :meth:`snapshot_repository`. + + Returns: + Restored snapshot metadata and restoration timestamp. """ self._git_repository_root() _, _, manifest = self._snapshot_manifest() @@ -776,13 +883,21 @@ def restore_repository_snapshot(self, snapshot_id: str) -> dict[str, object]: return result def save_before_each_snapshot(self) -> dict[str, object]: - """Save this run's baseline once from a ``before_each`` handler.""" + """Save this run's baseline once from a ``before_each`` handler. + + Returns: + Baseline snapshot metadata, reusing an existing baseline for this run. + """ if self.phase != "before_each": raise ValueError("baseline snapshots may only be saved during before_each") return self.snapshot_repository("baseline", once=True) def save_first_after_each_snapshot(self) -> dict[str, object] | None: - """Save the first completed iteration from an ``after_each`` handler.""" + """Save the first completed iteration from an ``after_each`` handler. + + Returns: + First-iteration snapshot metadata, or ``None`` after iteration one. + """ if self.phase != "after_each": raise ValueError("iteration snapshots may only be saved during after_each") if self.loop_index != 1: @@ -790,7 +905,11 @@ def save_first_after_each_snapshot(self) -> dict[str, object] | None: return self.snapshot_repository("iteration-1", once=True) def restore_before_each_snapshot(self) -> dict[str, object]: - """Restore the baseline saved by :meth:`save_before_each_snapshot` in ``after_all``.""" + """Restore the baseline saved by :meth:`save_before_each_snapshot` in ``after_all``. + + Returns: + Restored baseline metadata. + """ if self.phase != "after_all": raise ValueError("baseline restoration may only be performed during after_all") baseline = next( @@ -813,7 +932,14 @@ def restore_before_each_snapshot(self) -> dict[str, object]: restore_setup_snapshot = restore_before_each_snapshot def record_commit_change(self, before: str | None) -> dict[str, object] | None: - """Retain commit-range evidence when a runner phase advances the target HEAD.""" + """Retain commit-range evidence when a runner phase advances the target HEAD. + + Args: + before: Commit SHA observed before the runner action. + + Returns: + Commit-range evidence when HEAD changed, otherwise ``None``. + """ after = self.git_head() if not before or not after or before == after: return None @@ -861,7 +987,14 @@ def record_commit_change(self, before: str | None) -> dict[str, object] | None: return result def windows_path(self, value: str | Path) -> str: - """Convert a WSL-mounted path to a Windows path for a Windows child process.""" + """Convert a WSL-mounted path to a Windows path for a Windows child process. + + Args: + value: Path below a WSL ``/mnt/`` mount. + + Returns: + Equivalent Windows drive path. + """ path = Path(value) parts = path.parts if len(parts) >= 4 and parts[1] == "mnt" and len(parts[2]) == 1: @@ -965,6 +1098,14 @@ def update_file( with the runner iteration, phase, run ID, timestamp, and SHA-256 hashes. Use :meth:`file_versions` to inspect retained versions and :meth:`rollback_file` to restore a selected one. + + Args: + relative_path: Target-repository-relative file path to update. + content: UTF-8 text or raw bytes to write. + encoding: Encoding used when ``content`` is text. + + Returns: + Change status, content hash, target path, and retained version metadata. """ target, relative, directory, manifest_path, manifest = self._load_file_history(relative_path) build = self.build @@ -1010,7 +1151,14 @@ def update_file( return {"changed": True, "path": relative, "sha256": _sha256(next_content), "version": record} def file_versions(self, relative_path: str | Path) -> list[dict[str, object]]: - """List retained pre-update versions for a project file, newest first.""" + """List retained pre-update versions for a project file, newest first. + + Args: + relative_path: Target-repository-relative file path. + + Returns: + Retained version metadata ordered newest first. + """ _, _, _, _, manifest = self._load_file_history(relative_path) return list(reversed(manifest["history"])) @@ -1020,6 +1168,13 @@ def rollback_file(self, relative_path: str | Path, version_id: str) -> dict[str, Rolling back first snapshots the current file as a new version. This makes a rollback reversible: call this method again using that newly returned version ID to return to the state before the rollback. + + Args: + relative_path: Target-repository-relative file path to restore. + version_id: Retained version identifier to restore. + + Returns: + Restored path, selected version ID, and metadata for the new rollback version. """ target, relative, directory, manifest_path, manifest = self._load_file_history(relative_path) version = next((item for item in manifest["history"] if item.get("id") == version_id), None) @@ -1056,7 +1211,11 @@ def rollback_file(self, relative_path: str | Path, version_id: str) -> dict[str, return {"path": relative, "restored_version": version_id, "version": record} def proposal_decisions(self) -> list[dict[str, object]]: - """Return recorded accepted/rejected proposals for this target, newest first.""" + """Return recorded accepted/rejected proposals for this target, newest first. + + Returns: + Proposal decision and application events ordered newest first. + """ path = _proposal_history_path(self.project_root) try: document = json.loads(path.read_text(encoding="utf-8")) @@ -1083,6 +1242,15 @@ def record_proposal_decision( Repeating the same decision for unchanged proposal content is idempotent, while a changed decision is appended as a new event. This produces a compact event stream for a future proposal-review UI. + + Args: + proposal: JSON-safe proposal payload being decided. + decision: ``"accepted"`` or ``"rejected"``. + proposal_id: Optional stable external identifier for the proposal. + rationale: Optional human-readable decision reason. + + Returns: + Whether an event was newly recorded and its decision record. """ if not proposal: raise ValueError("proposal must not be empty") @@ -1151,6 +1319,13 @@ def record_proposal_application( This is an append-only lifecycle event. It lets a review UI traverse from a decision to an exact prompt snapshot and its rollback version without mutating the original decision record. + + Args: + proposal_ids: Accepted proposal identifiers linked to the file update. + file_update: Metadata returned by :meth:`update_file`. + + Returns: + Newly recorded proposal-application events. """ version = file_update.get("version") if not isinstance(version, dict) or not version.get("id"): @@ -1209,7 +1384,16 @@ def record_proposal_application( def accept_proposal( self, proposal: dict[str, object], *, proposal_id: str | None = None, rationale: str = "" ) -> dict[str, object]: - """Record that a proposal was selected for this target's improvement history.""" + """Record that a proposal was selected for this target's improvement history. + + Args: + proposal: JSON-safe proposal payload to accept. + proposal_id: Optional stable external proposal identifier. + rationale: Optional human-readable acceptance reason. + + Returns: + Whether an event was newly recorded and its decision record. + """ return self.record_proposal_decision( proposal, "accepted", proposal_id=proposal_id, rationale=rationale ) @@ -1217,24 +1401,45 @@ def accept_proposal( def reject_proposal( self, proposal: dict[str, object], *, proposal_id: str | None = None, rationale: str = "" ) -> dict[str, object]: - """Record that a proposal was not selected for this target's improvement history.""" + """Record that a proposal was not selected for this target's improvement history. + + Args: + proposal: JSON-safe proposal payload to reject. + proposal_id: Optional stable external proposal identifier. + rationale: Optional human-readable rejection reason. + + Returns: + Whether an event was newly recorded and its decision record. + """ return self.record_proposal_decision( proposal, "rejected", proposal_id=proposal_id, rationale=rationale ) @property def workflow(self) -> dict[str, object]: - """Return the workflow snapshot supplied by Orbit for this invocation.""" + """Return the workflow snapshot supplied by Orbit for this invocation. + + Returns: + Immutable workflow metadata, or an empty mapping when unavailable. + """ return dict(self.resources.get("workflow", {})) @property def build(self) -> dict[str, object]: - """Return the build snapshot supplied by Orbit.""" + """Return the build snapshot supplied by Orbit. + + Returns: + Immutable build metadata, or an empty mapping when unavailable. + """ return dict(self.resources.get("build", {})) @property def test_cases(self) -> list[dict[str, object]]: - """Return the fixed target test cases selected for this build.""" + """Return the fixed target test cases selected for this build. + + Returns: + Selected fixed test-case definitions. + """ return list(self.resources.get("test_cases", [])) def resource(self, name: str, default: object = None) -> object: @@ -1243,6 +1448,9 @@ def resource(self, name: str, default: object = None) -> object: Args: name: Resource name, such as ``"model_profile"``. default: Value returned when the resource is absent. + + Returns: + The requested resource value or ``default`` when it is absent. """ return self.resources.get(name, default) @@ -1251,6 +1459,12 @@ def complete_model(self, prompt: str) -> dict[str, str]: The profile contains provider settings only; its credential remains in the configured environment variable and is never emitted as evidence. + + Args: + prompt: Target-AI input to send using the configured model profile. + + Returns: + Profile name, resolved model name, and target-AI response text. """ profile = self.resource("model_profile", {}) if not isinstance(profile, dict) or not str(profile.get("model", "")).strip(): @@ -1282,6 +1496,9 @@ def previous_supervisor_feedback(self) -> dict[str, object]: Runner phases run in separate subprocesses. Reading the retained run record lets the next iteration use the previous iteration's feedback without coupling a runner to a target-specific state file. + + Returns: + Latest completed supervisor response, or an empty mapping when unavailable. """ run_id = self.environment.get("ORBIT_RUN_ID", "").strip() if not run_id: @@ -1330,6 +1547,12 @@ def log(self, message: str) -> None: This is for runner lifecycle and adapter progress. It is intentionally different from :meth:`target_log`, which records events emitted by the evaluated target and appears separately in the run's Logs tab. + + Args: + message: Human-readable lifecycle progress message. + + Returns: + ``None``. The message is written to runner output. """ print(f"[orbit:{self.phase}] {message}", flush=True) @@ -1354,6 +1577,9 @@ def target_log( source: Optional stable target-service name, trimmed to 256 characters. timestamp: Optional ISO-8601 timestamp. UTC time is used when omitted. + Returns: + ``None``. The target log entry is attached to current-step evidence. + Raises: ValueError: If the message is empty, level is unsupported, or timestamp is not a string. @@ -1388,6 +1614,12 @@ def emit_result(self, values: dict[str, object]) -> None: proposal records. Values must be JSON serializable. Repeated result objects are merged by key, so prefer a single object for related data; use :meth:`target_log` for append-only target logging. + + Args: + values: JSON-safe evidence object to attach to the current step. + + Returns: + ``None``. The evidence is emitted to Orbit's runner protocol. """ print("__ORBIT_RESULT__" + json.dumps(values, ensure_ascii=False), flush=True) @@ -1396,6 +1628,12 @@ def playwright_journey(self, cases: list[dict[str, object]] | None = None) -> di The browser process is short lived. Scheduling, locking, and any application-server lifecycle remain Orbit's responsibility. + + Args: + cases: Optional fixed cases to run. The build's selected cases are used when omitted. + + Returns: + Per-case pass/fail evidence, screenshots, and artifact directory metadata. """ build = self.build base_url = str( @@ -1550,6 +1788,9 @@ def main(self) -> None: Place ``runner.main()`` behind an ``if __name__ == "__main__"`` guard in every runner asset. Orbit supplies the ``--phase`` argument and process environment; callers should not invoke this method directly. + + Returns: + ``None``. The process exits after dispatching the requested phase. """ parser = argparse.ArgumentParser(description="Orbit runner phase") command = parser.add_mutually_exclusive_group(required=True) diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index 3d5d348..a4b9721 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -7,6 +7,7 @@ import orbit_sdk as sdk import pytest +import yaml from app import main as main_module from app import providers from app import store as store_module @@ -519,6 +520,49 @@ def supervise(run_id): ] +def test_linear_runs_complete_supervision_after_each_iteration(tmp_path, monkeypatch): + monkeypatch.setattr(store_module, "RUNS", tmp_path / "runs") + store = store_module.ConsoleStore() + timestamp = store_module.now() + run = Run( + id="linear-supervision", + workflow_id="workflow", + workflow_name="Workflow", + execution_mode="run", + status="queued", + created_at=timestamp, + updated_at=timestamp, + loop_limit=2, + iteration_strategy="linear", + ) + store._save(run) + workflow = Workflow( + id="workflow", + name="Workflow", + description="", + kind="simulation", + risk="low", + steps=[Step(id="verify", phase="verify", name="Verify", command=[], working_directory=".")], + ) + monkeypatch.setattr(store, "_runner_execution_plan", lambda _: workflow) + completed_iterations = [] + + def execute_step(run_id, step, loop_index=1, resources=None, **_kwargs): + current = store._load(run_id) + current.step_results.append({"phase": step.phase, "loop_index": loop_index}) + store._save(current) + + def supervise(run_id): + completed_iterations.append(store._load(run_id).step_results[-1]["loop_index"]) + + monkeypatch.setattr(store, "_execute_step", execute_step) + monkeypatch.setattr(store, "_complete_supervision", supervise) + + store._execute(run.id) + + assert completed_iterations == [1, 2] + + def test_deleting_a_completed_run_removes_its_history(tmp_path, monkeypatch): monkeypatch.setattr(store_module, "RUNS", tmp_path / "runs") store = store_module.ConsoleStore() @@ -684,6 +728,35 @@ def test_runner_saves_immutable_versions_and_can_resolve_an_older_version(tmp_pa assert [step.phase for step in store._runner_execution_plan("versioned-runner", 2).steps] == ["verify"] +def test_bundle_runner_updates_in_place_and_keeps_immutable_versions(tmp_path, monkeypatch): + monkeypatch.setattr(store_module, "RUNNERS", tmp_path / "runners") + bundle = store_module.RUNNERS / "bundle-runner" + bundle.mkdir(parents=True) + initial_source = "from orbit_sdk import runner\n@runner.phase('execute')\ndef run(ctx): pass\n" + (bundle / "runner.py").write_text(initial_source, encoding="utf-8") + (bundle / "runner.json").write_text( + json.dumps({"id": "bundle-runner", "name": "Bundle", "description": "Versioned bundle."}), + encoding="utf-8", + ) + store = store_module.ConsoleStore() + + updated = store.update_runner( + "bundle-runner", + { + "name": "Bundle", + "description": "Versioned bundle.", + "source": "from orbit_sdk import runner\n@runner.phase('verify')\ndef run(ctx): pass\n", + }, + ) + + assert updated["version"] == 2 + assert [item["version"] for item in updated["versions"]] == [1, 2] + assert (bundle / "runner.py").read_text(encoding="utf-8") == updated["source"] + assert not (store_module.RUNNERS / "bundle-runner.py").exists() + assert [step.phase for step in store._runner_execution_plan("bundle-runner", 1).steps] == ["execute"] + assert [step.phase for step in store._runner_execution_plan("bundle-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() @@ -806,7 +879,18 @@ def test_supervisor_result_normalizes_a_numeric_string_score(): assert result["evaluation"]["score"] == 8.0 -def test_supervisor_result_accepts_a_structured_ai_behavior_trace(): +def test_supervisor_result_accepts_a_persona_journey_trace(): + result = store_module.ConsoleStore._validated_supervisor_result( + '{"evaluation":{"score":8,"approval":"pending","summary":"ok","behavior_trace":' + '{"persona_goal":"Confirm the account is usable","expectation":"A visible balance",' + '"interpretation":"I cannot confirm my balance yet","evidence":"The balance is still loading",' + '"impact":"I cannot safely continue","next_step":"Wait for the balance, then check the holdings"}},' + '"improvements":[],"reported_issues":[]}' + ) + assert result["evaluation"]["behavior_trace"]["interpretation"] == "I cannot confirm my balance yet" + + +def test_supervisor_result_accepts_a_legacy_behavior_trace_for_existing_runs(): result = store_module.ConsoleStore._validated_supervisor_result( '{"evaluation":{"score":8,"approval":"pending","summary":"ok","behavior_trace":' '{"purpose":"Verify recovery","rationale":"The prior attempt timed out","observation":"A retry completed",' @@ -1119,6 +1203,56 @@ def test_runner_templates_can_be_imported_into_app_data(tmp_path, monkeypatch): assert (tmp_path / "runner-templates" / "shared-browser-check.json").exists() +def test_build_star_is_persisted_without_changing_other_build_fields(tmp_path, monkeypatch): + monkeypatch.setattr(store_module, "CONFIG", tmp_path) + (tmp_path / "builds.yaml").write_text( + "- id: starred-build\n name: Starred build\n enabled: true\n repository: ''\n", + encoding="utf-8", + ) + + updated = store_module.ConsoleStore().set_build_star("starred-build", True) + + assert updated["starred"] is True + saved = yaml.safe_load((tmp_path / "builds.yaml").read_text(encoding="utf-8"))[0] + assert saved["starred"] is True + assert saved["name"] == "Starred build" + assert saved["enabled"] is True + + +def test_build_state_exposes_sdk_managed_state(tmp_path, monkeypatch): + monkeypatch.setattr(store_module, "CONFIG", tmp_path / "config") + monkeypatch.setattr(store_module, "APP_DATA", tmp_path / "app-data") + (tmp_path / "config").mkdir() + (tmp_path / "config" / "builds.yaml").write_text( + "- id: persona-quality\n name: Persona quality\n enabled: true\n repository: ''\n", + encoding="utf-8", + ) + state_dir = tmp_path / "app-data" / "runner-state" / "persona-quality" + state_dir.mkdir(parents=True) + (state_dir / "persona-journey.json").write_text( + json.dumps( + { + "schema_version": 1, + "updated_at": "2026-09-14T00:00:00+00:00", + "run_id": "run-1", + "iteration": 2, + "value": {"personas": {"haruka": {"stage": 2}}}, + } + ), + encoding="utf-8", + ) + + assert store_module.ConsoleStore().build_state("persona-quality") == [ + { + "name": "persona-journey", + "updated_at": "2026-09-14T00:00:00+00:00", + "run_id": "run-1", + "iteration": 2, + "value": {"personas": {"haruka": {"stage": 2}}}, + } + ] + + def test_manager_prompt_template_can_be_updated(tmp_path, monkeypatch): monkeypatch.setattr(store_module, "CONFIG", tmp_path) (tmp_path / "prompt-templates.yaml").write_text( diff --git a/backend/tests/test_orbit_sdk.py b/backend/tests/test_orbit_sdk.py index ca65f43..ccca7b8 100644 --- a/backend/tests/test_orbit_sdk.py +++ b/backend/tests/test_orbit_sdk.py @@ -140,6 +140,33 @@ def test_update_file_retains_previous_contents_and_metadata(tmp_path, monkeypatc assert json.loads(manifest.read_text(encoding="utf-8"))["history"][0]["id"] == version["id"] +def test_runner_state_is_build_scoped_and_retained_between_invocations(tmp_path, monkeypatch): + project = tmp_path / "project" + project.mkdir() + monkeypatch.setattr(sdk, "ORBIT_APP_DATA", tmp_path / "orbit-data") + resources = {"build": {"id": "persona-quality"}} + encoded = b64encode(json.dumps(resources).encode()).decode() + + saved = sdk.RunnerContext( + phase="execute", + target_repository=project, + mode="run", + loop_index=2, + environment={"ORBIT_RUNNER_RESOURCES": encoded, "ORBIT_RUN_ID": "run-456"}, + ).save_state("persona-journey", {"personas": {"haruka": {"stage": 2}}}) + + next_context = sdk.RunnerContext( + phase="execute", + target_repository=project, + mode="run", + loop_index=3, + environment={"ORBIT_RUNNER_RESOURCES": encoded}, + ) + assert saved["name"] == "persona-journey" + assert next_context.load_state("persona-journey") == {"personas": {"haruka": {"stage": 2}}} + assert next_context.load_state("missing", default={}) == {} + + def test_rollback_file_restores_a_version_and_can_be_undone(tmp_path, monkeypatch): project = tmp_path / "project" project.mkdir() diff --git a/frontend/src/domain/models.ts b/frontend/src/domain/models.ts index b845bc7..bb7faa0 100644 --- a/frontend/src/domain/models.ts +++ b/frontend/src/domain/models.ts @@ -3,14 +3,15 @@ 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;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 Build = { id:string; name:string; enabled:boolean; starred?: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 RunnerState = { name:string; updated_at?:string; run_id?:string; iteration?:number; value:unknown } 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 } export type WorkflowGraphNode = { id:string; title:string; phase?:string|null; inputs?:string[]; outputs?:string[]; description?:string|null; status?:'idle'|'running'|'succeeded'|'failed'|'skipped' } export type WorkflowGraphEdge = { source:string; target:string; kind?:'execution'|'data'|'condition'|'loop'|'error'; label?:string|null; source_port?:string|null; target_port?:string|null } export type WorkflowGraphDefinition = { nodes:WorkflowGraphNode[]; edges:WorkflowGraphEdge[] } -export type BehaviorTrace = { purpose:string; rationale:string; observation:string; decision:string; next_action:string } +export type BehaviorTrace = { persona_goal?:string; expectation?:string; interpretation?:string; evidence?:string; impact?:string; next_step?:string; purpose?:string; rationale?:string; observation?:string; decision?:string; next_action?:string } 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 } diff --git a/frontend/src/features/builds/page.tsx b/frontend/src/features/builds/page.tsx index 45b5394..0155ea9 100644 --- a/frontend/src/features/builds/page.tsx +++ b/frontend/src/features/builds/page.tsx @@ -8,6 +8,7 @@ import { Play, Plus, Sparkles, + Star, TestTube2, Trash2, Wrench, @@ -776,6 +777,7 @@ export function BuildsPage(props: { onTest: (id: string) => Promise; onCreate: (v: Draft) => Promise; onUpdate: (id: string, v: Draft) => Promise; + onToggleStar: (id: string, starred: boolean) => Promise; onDelete: (id: string) => void; onQuickStartCreate: ( id: string, @@ -798,6 +800,7 @@ export function BuildsPage(props: { onTest, onCreate, onUpdate, + onToggleStar, onDelete, onQuickStartCreate, quickStartRequest, @@ -908,7 +911,23 @@ export function BuildsPage(props: { { id: "name", header: locales[locale].evaluation.name, - render: (b) => b.name, + render: (b) => ( + + + {b.name} + + ), sortValue: (b) => b.name, }, { diff --git a/frontend/src/features/evaluations/page.tsx b/frontend/src/features/evaluations/page.tsx index 2198303..927d372 100644 --- a/frontend/src/features/evaluations/page.tsx +++ b/frontend/src/features/evaluations/page.tsx @@ -85,7 +85,7 @@ const copy = localeMessageMap>("evaluations"); type SupervisorResultTranslation = { prompt?: string; response: { - evaluation: { behavior_summary?: string; behavior_trace?: { purpose: string; rationale: string; observation: string; decision: string; next_action: string }; summary?: string }; + evaluation: { behavior_summary?: string; behavior_trace?: { persona_goal?: string; expectation?: string; interpretation?: string; evidence?: string; impact?: string; next_step?: string; purpose?: string; rationale?: string; observation?: string; decision?: string; next_action?: string }; summary?: string }; improvements: Record[]; reported_issues: Record[]; }; diff --git a/frontend/src/features/evaluations/run-detail-result-panel.tsx b/frontend/src/features/evaluations/run-detail-result-panel.tsx index 302a583..c2012f5 100644 --- a/frontend/src/features/evaluations/run-detail-result-panel.tsx +++ b/frontend/src/features/evaluations/run-detail-result-panel.tsx @@ -7,7 +7,7 @@ type BehaviorTrace = { iteration: number; recordedAt?: string; summary?: string; - trace?: { purpose?: string; rationale?: string; observation?: string; decision?: string; next_action?: string }; + trace?: { persona_goal?: string; expectation?: string; interpretation?: string; evidence?: string; impact?: string; next_step?: string; purpose?: string; rationale?: string; observation?: string; decision?: string; next_action?: string }; }; const time = (locale: Locale, value?: string) => value ? new Intl.DateTimeFormat(intlLocales[locale], { dateStyle: "medium", timeStyle: "medium" }).format(new Date(value)) : "—"; @@ -19,12 +19,24 @@ function ResultList({ items, kind, locale, empty }: { items: RecordItem[]; kind: } export function EvaluationResultPanel({ error, records, summaries, improvements, issues, l, locale }: { error?: ReactNode; records: unknown[]; summaries: BehaviorTrace[]; improvements: RecordItem[]; issues: RecordItem[]; l: Messages; locale: Locale }) { - const traceFields = (item: BehaviorTrace) => item.trace ? [ - [l.tracePurpose, item.trace.purpose], - [l.traceRationale, item.trace.rationale], - [l.traceObservation, item.trace.observation], - [l.traceDecision, item.trace.decision], - [l.traceNextAction, item.trace.next_action], - ].filter((field): field is [string | undefined, string] => Boolean(field[1])) : []; + const traceFields = (item: BehaviorTrace) => { + if (!item.trace) return []; + const trace = item.trace; + const fields = trace.persona_goal ? [ + [l.tracePersonaGoal, trace.persona_goal], + [l.traceExpectation, trace.expectation], + [l.traceInterpretation, trace.interpretation], + [l.traceEvidence, trace.evidence], + [l.traceImpact, trace.impact], + [l.traceNextStep, trace.next_step], + ] : [ + [l.tracePurpose, trace.purpose], + [l.traceRationale, trace.rationale], + [l.traceObservation, trace.observation], + [l.traceDecision, trace.decision], + [l.traceNextAction, trace.next_action], + ]; + return fields.filter((field): field is [string | undefined, string] => Boolean(field[1])); + }; return <>{error}{records.length ? <>{summaries.length > 0 &&

{l.observedBehavior}

{summaries.map((item) =>
{traceFields(item).length ?
{traceFields(item).map(([label, value]) =>
{label}
{value}
)}
:

{item.summary}

}
Iteration#{item.iteration}
)}
}

{l.proposals}

{l.issues}

:

{l.noMatchingResults}

}; } diff --git a/frontend/src/features/improvements/page.tsx b/frontend/src/features/improvements/page.tsx index 8fe2ccf..7a9334c 100644 --- a/frontend/src/features/improvements/page.tsx +++ b/frontend/src/features/improvements/page.tsx @@ -21,6 +21,7 @@ import type { ImprovementIterationData, Build, ProposalLifecycle, + RunnerState, SavedDataFile, } from "../../domain/models"; import { Modal } from "../../components/ui/modal"; @@ -81,6 +82,11 @@ type ImprovementCopy = { cancelled: string; running: string; selectBuild: string; + storedState: string; + storedStateHint: string; + noStoredState: string; + updated: string; + rawState: string; }; type ChartHints = { feedbackByBuild: string; @@ -763,6 +769,68 @@ function CycleImprovementAI({ ); } + +function StoredState({ + buildId, + locale, + t, +}: { + buildId: string; + locale: Locale; + t: (typeof copy)["en"]; +}) { + const [states, setStates] = useState([]); + useEffect(() => { + api(`/api/builds/${encodeURIComponent(buildId)}/state`) + .then(setStates) + .catch(() => setStates([])); + }, [buildId]); + return ( +
+ + {states.length ? ( +
+ {states.map((state) => { + const personas = + state.value && typeof state.value === "object" && !Array.isArray(state.value) + ? (state.value as { personas?: Record }).personas + : undefined; + return ( +
+
+ {state.name} + {t.updated} {timestamp(locale, state.updated_at)} +
+ {personas && Object.keys(personas).length > 0 && ( +
+ {Object.entries(personas).map(([persona, value]) => { + const record = value && typeof value === "object" && !Array.isArray(value) + ? value as Record + : {}; + return ( +
+ {persona} + {String(record.last_action_at ?? "—")} + {String(record.next_check ?? record.last_summary ?? "—")} +
+ ); + })} +
+ )} +
+ {t.rawState} +
{JSON.stringify(state.value, null, 2)}
+
+
+ ); + })} +
+ ) : ( +

{t.noStoredState}

+ )} +
+ ); +} export function ImprovementsPage() { const locale = resolveLocale(localStorage.getItem("orbit.locale")), t = copy[locale], @@ -777,6 +845,7 @@ export function ImprovementsPage() { current || [...next].sort( (left, right) => + Number(right.starred) - Number(left.starred) || lastRunTimestamp(right) - lastRunTimestamp(left) || left.name.localeCompare(right.name), )[0]?.id || @@ -789,6 +858,8 @@ export function ImprovementsPage() { const sortedBuilds = useMemo( () => [...builds].sort((left, right) => { + const starOrder = Number(right.starred) - Number(left.starred); + if (starOrder) return starOrder; const leftRun = lastRunTimestamp(left); const rightRun = lastRunTimestamp(right); return rightRun - leftRun || left.name.localeCompare(right.name); @@ -804,12 +875,13 @@ export function ImprovementsPage() { + {build && } {build && } diff --git a/frontend/src/locales/languages/en.json b/frontend/src/locales/languages/en.json index 6dcc586..8a57b1e 100644 --- a/frontend/src/locales/languages/en.json +++ b/frontend/src/locales/languages/en.json @@ -581,8 +581,13 @@ "succeeded": "Succeeded", "failed": "Failed", "cancelled": "Cancelled", - "running": "Running" - ,"selectBuild": "Build" + "running": "Running", + "selectBuild": "Build", + "storedState": "Stored state", + "storedStateHint": "Current build-scoped state retained by the runner between executions.", + "noStoredState": "No state has been saved for this build yet.", + "updated": "Updated", + "rawState": "View raw state" }, "quickStartLabels": { "openorbit.user-journey-smoke-test": { @@ -655,7 +660,13 @@ "score": "Score", "decision": "Decision", "proposals": "Proposed improvements", - "observedBehavior": "AI behavior", + "observedBehavior": "Persona journey", + "tracePersonaGoal": "Persona goal", + "traceExpectation": "Expectation", + "traceInterpretation": "Persona interpretation", + "traceEvidence": "Observed experience", + "traceImpact": "User impact", + "traceNextStep": "Next journey step", "tracePurpose": "Purpose", "traceRationale": "Rationale", "traceObservation": "Observation", diff --git a/frontend/src/locales/languages/ja.json b/frontend/src/locales/languages/ja.json index 21316ab..a8ffadb 100644 --- a/frontend/src/locales/languages/ja.json +++ b/frontend/src/locales/languages/ja.json @@ -580,8 +580,13 @@ "succeeded": "成功", "failed": "失敗", "cancelled": "キャンセル", - "running": "実行中" - ,"selectBuild": "ビルド" + "running": "実行中", + "selectBuild": "ビルド", + "storedState": "保存済み状態", + "storedStateHint": "Runner が実行間で保持する現在のビルド単位の状態です。", + "noStoredState": "このビルドにはまだ保存された状態がありません。", + "updated": "更新", + "rawState": "生の状態を表示" }, "quickStartLabels": { "openorbit.user-journey-smoke-test": { @@ -654,7 +659,13 @@ "score": "スコア", "decision": "判断", "proposals": "改善提案", - "observedBehavior": "評価対象AIの行動", + "observedBehavior": "ペルソナジャーニー", + "tracePersonaGoal": "ペルソナの目的", + "traceExpectation": "期待", + "traceInterpretation": "ペルソナの解釈", + "traceEvidence": "確認された体験", + "traceImpact": "ユーザーへの影響", + "traceNextStep": "次のジャーニー", "tracePurpose": "目的", "traceRationale": "根拠", "traceObservation": "観測", diff --git a/frontend/src/locales/languages/ko.json b/frontend/src/locales/languages/ko.json index 4422a11..dd06b6a 100644 --- a/frontend/src/locales/languages/ko.json +++ b/frontend/src/locales/languages/ko.json @@ -580,8 +580,13 @@ "succeeded": "성공", "failed": "실패", "cancelled": "취소됨", - "running": "실행 중" - ,"selectBuild": "빌드" + "running": "실행 중", + "selectBuild": "빌드", + "storedState": "저장된 상태", + "storedStateHint": "Runner가 실행 사이에 유지하는 현재 빌드별 상태입니다.", + "noStoredState": "이 빌드에 아직 저장된 상태가 없습니다.", + "updated": "갱신", + "rawState": "원본 상태 보기" }, "quickStartLabels": { "openorbit.user-journey-smoke-test": { @@ -654,7 +659,13 @@ "score": "점수", "decision": "결정", "proposals": "개선 제안", - "observedBehavior": "평가 대상 AI 행동", + "observedBehavior": "페르소나 여정", + "tracePersonaGoal": "페르소나 목표", + "traceExpectation": "기대", + "traceInterpretation": "페르소나의 해석", + "traceEvidence": "확인된 경험", + "traceImpact": "사용자 영향", + "traceNextStep": "다음 여정", "tracePurpose": "목적", "traceRationale": "근거", "traceObservation": "관찰", diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 58eaa68..8945004 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -215,6 +215,13 @@ export default function App() { room.refresh(); }) .catch((e) => room.setNotice(e.message)); + const toggleBuildStar = (id: string, starred: boolean) => + api(`/api/builds/${id}/star`, "PATCH", { starred }) + .then(() => room.refresh()) + .catch((e) => { + room.setNotice(e.message); + throw e; + }); const deleteAsset = ( kind: | "profile" @@ -305,6 +312,7 @@ export default function App() { onTest={testBuild} onCreate={createBuild} onUpdate={updateBuild} + onToggleStar={toggleBuildStar} onDelete={deleteBuild} onQuickStartCreate={createQuickStart} quickStartRequest={quickStartRequest} diff --git a/frontend/src/theme-overrides.css b/frontend/src/theme-overrides.css index af9107c..27c7922 100644 --- a/frontend/src/theme-overrides.css +++ b/frontend/src/theme-overrides.css @@ -55,6 +55,7 @@ footer { display:flex; align-items:center; justify-content:space-between; margin .iteration-trends__head { display:flex; align-items:center; justify-content:space-between; gap:12px; }.iteration-trends__head label { display:flex; align-items:center; gap:8px; color:var(--muted); font-size:11px; }.iteration-trends__head h3 { margin:0; }.iteration-trends__head select { width:auto; max-width:320px; } @media(max-width:720px){.iteration-trends__head { align-items:flex-start; flex-direction:column; }} .analytics-grid { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:14px; }.analytics-chart { min-width:0; padding:14px; border:1px solid var(--line); border-radius:8px; background:var(--bg); }.analytics-chart h3 { margin:0 0 12px; font-size:12px; }.analytics-chart .recharts-cartesian-axis-tick-value,.analytics-chart .recharts-legend-item-text { fill:var(--muted)!important; font-size:10px; }.analytics-chart .recharts-tooltip-wrapper { color:#182132; }.chart-select { display:flex; justify-content:flex-end; margin:-31px 0 10px; }.chart-select label { display:flex; align-items:center; gap:8px; color:var(--muted); font-size:11px; }.chart-select select { width:auto; max-width:320px; } @media(max-width:720px){.analytics-grid { grid-template-columns:1fr; }.chart-select { justify-content:flex-start; margin:0 0 10px; }} .improvements-build-selector { display:flex; justify-content:flex-end; margin-bottom:16px; }.improvements-build-selector label { display:flex; align-items:center; gap:8px; color:var(--muted); font-size:11px; }.improvements-build-selector select { width:auto; max-width:360px; } @media(max-width:720px){.improvements-build-selector { justify-content:flex-start; }} +.stored-state { display:grid; gap:12px; margin-bottom:16px; }.stored-state .panel-head { margin:0; }.stored-state__list { display:grid; gap:10px; }.stored-state__list article { display:grid; gap:10px; padding:12px; border:1px solid var(--line); border-radius:8px; background:var(--bg); }.stored-state__list header { display:flex; align-items:center; justify-content:space-between; gap:12px; }.stored-state__list header small { color:var(--muted); font:10px 'DM Mono',monospace; }.stored-state__personas { display:grid; gap:6px; }.stored-state__personas > div { display:grid; grid-template-columns:minmax(130px,.8fr) minmax(150px,1fr) minmax(0,2fr); gap:10px; padding:8px 10px; border-radius:6px; background:var(--surface-raised); font-size:11px; }.stored-state__personas span { overflow:hidden; color:var(--muted); text-overflow:ellipsis; white-space:nowrap; }.stored-state details { color:var(--muted); font-size:11px; }.stored-state summary { cursor:pointer; }.stored-state pre { max-height:280px; overflow:auto; margin:8px 0 0; padding:10px; border-radius:6px; color:var(--text); background:var(--surface-raised); font:10px/1.55 'DM Mono',monospace; } @media(max-width:720px){.stored-state__personas > div { grid-template-columns:1fr; gap:4px; }} .cycle-interventions { display:grid; gap:12px; }.cycle-interventions > .hint { margin:0; }.intervention-list { display:grid; gap:10px; }.intervention-list article { display:grid; gap:9px; padding:14px; border:1px solid var(--line); border-radius:8px; background:var(--bg); }.intervention-list header { display:flex; justify-content:space-between; gap:12px; }.intervention-list header div { display:grid; gap:5px; }.intervention-list small { color:var(--muted); font-size:10px; }.intervention-list p { margin:0; color:var(--muted); line-height:1.55; }.intervention-list dl { display:flex; gap:20px; margin:0; }.intervention-list dl div { display:grid; gap:3px; }.intervention-list dt { color:var(--muted); font-size:9px; text-transform:uppercase; }.intervention-list dd { margin:0; font:11px 'DM Mono',monospace; }.intervention-list pre { margin:0; padding:9px; overflow:auto; border-radius:6px; background:var(--surface-raised); color:#8fb8ff; font:11px/1.5 'DM Mono',monospace; white-space:pre-wrap; } .proposal-history { display:grid; gap:14px; }.proposal-history .panel-head { margin-bottom:3px; }.proposal-history .hint { max-width:650px; }.proposal-filters { display:flex; gap:8px; flex-wrap:wrap; }.proposal-filters select { width:auto; max-width:230px; }.proposal-summary { display:flex; gap:8px; flex-wrap:wrap; }.proposal-summary span { display:flex; align-items:baseline; gap:6px; padding:8px 10px; border:1px solid var(--line); border-radius:7px; color:var(--muted); background:var(--bg); font-size:10px; }.proposal-summary b { color:var(--text); font:600 16px 'DM Mono',monospace; }.proposal-list { display:grid; border-top:1px solid var(--line); }.proposal-row { display:flex; justify-content:space-between; gap:14px; width:100%; padding:13px 2px; border:0; border-bottom:1px solid var(--line); color:var(--text); background:transparent; text-align:left; }.proposal-row:hover { background:var(--surface-raised); }.proposal-row > div:first-child { display:grid; min-width:0; gap:4px; }.proposal-row small,.proposal-row span { color:var(--muted); font-size:10px; }.proposal-row strong { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; font-size:12px; }.proposal-row__meta { display:grid; justify-items:end; align-content:center; gap:6px; white-space:nowrap; }.modal--proposal-detail { width:min(760px,100%); }.proposal-detail { display:grid; gap:18px; }.proposal-detail__summary { display:flex; align-items:center; gap:10px; flex-wrap:wrap; color:var(--muted); font:10px 'DM Mono',monospace; }.proposal-detail section { display:grid; gap:8px; }.proposal-detail h3 { margin:0; font-size:12px; }.proposal-detail p { margin:0; color:var(--muted); font-size:12px; line-height:1.6; }.proposal-detail pre { margin:0; padding:10px; overflow:auto; border-radius:7px; background:var(--bg); color:#8fb8ff; font:11px/1.55 'DM Mono',monospace; white-space:pre-wrap; }.proposal-detail dl { display:flex; flex-wrap:wrap; gap:16px; margin:0; }.proposal-detail dl div { display:grid; min-width:120px; gap:3px; }.proposal-detail dt { color:var(--muted); font-size:9px; text-transform:uppercase; }.proposal-detail dd { max-width:480px; margin:0; overflow-wrap:anywhere; font:11px 'DM Mono',monospace; }.proposal-timeline { display:grid; gap:9px; margin:0; padding:0; list-style:none; }.proposal-timeline li { display:flex; align-items:center; gap:10px; padding-left:10px; border-left:2px solid var(--line); }.proposal-timeline li > div { display:grid; gap:3px; }.proposal-timeline strong { font-size:11px; }.proposal-timeline small { color:var(--muted); font-size:10px; } @media(max-width:720px){.proposal-history .trend-head { align-items:stretch; }.proposal-filters select { width:100%; max-width:none; }.proposal-row { align-items:flex-start; flex-direction:column; }.proposal-row__meta { justify-items:start; }.proposal-detail__summary { align-items:flex-start; flex-direction:column; }} .panel-title-action .panel-head { flex:1; }.panel-title-action__copy { flex:1; min-width:0; }.section-description { margin:5px 0 0; }.panel-title-action:has(.section-description) { margin-bottom:12px; }.panel-head:has(+ .section-description) { margin-bottom:0; }.panel-head + .section-description { margin:5px 0 12px; }.panel-title-action .approve { display:flex; align-items:center; gap:5px; white-space:nowrap; } @@ -97,6 +98,7 @@ footer { display:flex; align-items:center; justify-content:space-between; margin .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.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; } +.build-name { display:flex; align-items:center; gap:7px; min-width:0; }.build-star { display:grid; flex:none; place-items:center; width:24px; height:24px; padding:0; border:0; border-radius:5px; color:var(--muted); background:transparent; cursor:pointer; }.build-star:hover,.build-star:focus-visible { color:#f1c85b; background:color-mix(in srgb,#f1c85b 13%,transparent); outline:0; }.build-star--active { color:#f1c85b; } .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; }