Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,21 @@ All notable changes to OpenOrbit are documented in this file.
The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/)
and this project uses [Semantic Versioning](https://semver.org/).

## [0.12.7] - 2026-09-25

### Changed

- Applied official OpenOrbit branding across the app, browser metadata, and README,
including installable web-app icons and favicons.

### Fixed

- Retried Runs that follow the latest Runner version now resolve the current
Runner revision instead of reusing a previously resolved version.
- Resolved Runner source only for Runner workflows, preventing unrelated
workflows from using Runner source resolution.
- Recorded Orbit Assistant failures in operational logs.

## [0.12.6] - 2026-09-25

### Added
Expand Down
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
*Read this in other languages: [한국어](i18n/ko/README.md)*

# OpenOrbit
<p align="center">
<img src="logo-lockup.png" width="520" alt="OpenOrbit" />
</p>

# What is OpenOrbit?

> **A local workspace where AI personas find product work, AI coding agents
> prepare changes, and your team reviews the result.**
Expand Down
119 changes: 98 additions & 21 deletions backend/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import asyncio
import json
import logging
import os
import re
import shutil
Expand All @@ -12,6 +13,7 @@
from fastapi import FastAPI, File, HTTPException, Query, Request, Response, UploadFile, WebSocket
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, StreamingResponse
from opentelemetry.trace import Status, StatusCode
from orbit_sdk.visual import visual_nodes
from pydantic import BaseModel, Field

Expand Down Expand Up @@ -82,10 +84,42 @@ async def application_lifespan(_: FastAPI):
allow_methods=["*"],
allow_headers=["*"],
)


@app.middleware("http")
async def record_failed_api_requests(request: Request, call_next):
"""Retain failed control-room API calls without recording request bodies."""
try:
response = await call_next(request)
except Exception as error:
if request.url.path.startswith("/api/"):
with store.tracer.start_as_current_span(
"api.request",
attributes={"http.request.method": request.method, "url.path": request.url.path},
) as span:
span.record_exception(error)
span.set_status(Status(StatusCode.ERROR))
span.add_event("api.request.failed", {"error.type": type(error).__name__})
raise
if request.url.path.startswith("/api/") and response.status_code >= 400:
with store.tracer.start_as_current_span(
"api.request",
attributes={
"http.request.method": request.method,
"url.path": request.url.path,
"http.response.status_code": response.status_code,
},
) as span:
span.set_status(Status(StatusCode.ERROR))
span.add_event("api.response.failed", {"http.response.status_code": response.status_code})
return response


# The API service owns in-memory scheduler threads, so it alone may mark a
# leftover local pipeline as interrupted when it starts.
store = ConsoleStore(recover_interrupted_runs=True)
assistant_ui_broker = AssistantUiBroker()
logger = logging.getLogger(__name__)


def bundled_directory(relative_path: Path, roots: tuple[Path, ...] | None = None) -> Path:
Expand Down Expand Up @@ -1334,21 +1368,42 @@ def chat(values: ChatMessage, request: Request):
settings = ModelSettings(
**{key: value for key, value in configured.items() if key in ModelSettings.__dataclass_fields__}
)
try:
provider = AzureOpenAIProvider() if settings.provider == "azure-openai" else BedrockProvider()
application = store.application_settings()
tool_executor = AssistantToolExecutor(
application["assistant_tools"], application["coding_agent_provider"]
)
prompt = build_assistant_prompt(
values.content,
[(turn.role, turn.content) for turn in values.history],
request_locale(request),
)
response = OrbitAssistantGraph(provider, settings, tool_executor).invoke(prompt)
return {"response": response, "profile_name": profile_name}
except RuntimeError as error:
raise HTTPException(409, str(error))
provider = AzureOpenAIProvider() if settings.provider == "azure-openai" else BedrockProvider()
application = store.application_settings()
tool_executor = AssistantToolExecutor(
application["assistant_tools"], application["coding_agent_provider"]
)
prompt = build_assistant_prompt(
values.content,
[(turn.role, turn.content) for turn in values.history],
request_locale(request),
)
with store.tracer.start_as_current_span(
"assistant.chat",
attributes={
"orbit.assistant.stream": False,
"gen_ai.provider.name": settings.provider,
"gen_ai.request.model": settings.model,
"orbit.assistant.profile": profile_name,
},
) as span:
try:
span.add_event("assistant.request.received")
response = OrbitAssistantGraph(provider, settings, tool_executor).invoke(prompt)
span.set_attribute("orbit.assistant.response.length", len(response))
span.add_event("assistant.response.completed")
return {"response": response, "profile_name": profile_name}
except RuntimeError as error:
span.record_exception(error)
span.set_status(Status(StatusCode.ERROR))
span.add_event("assistant.request.failed", {"error.type": type(error).__name__})
raise HTTPException(409, str(error))
except Exception as error:
span.record_exception(error)
span.set_status(Status(StatusCode.ERROR))
span.add_event("assistant.request.failed", {"error.type": type(error).__name__})
logger.exception("Orbit Assistant request failed")
raise HTTPException(500, "Chat request failed.")


@app.post("/api/chat/stream")
Expand Down Expand Up @@ -1400,16 +1455,38 @@ def activity(phase: str, tool: str | None = None) -> None:

async def run() -> None:
try:
response = await asyncio.to_thread(
OrbitAssistantGraph(
provider, settings, tool_executor, ui_tools=ui_tools, on_activity=activity
).invoke,
prompt,
)

def invoke() -> str:
with store.tracer.start_as_current_span(
"assistant.chat",
attributes={
"orbit.assistant.stream": True,
"orbit.assistant.ui_enabled": bool(ui_tools),
"gen_ai.provider.name": settings.provider,
"gen_ai.request.model": settings.model,
"orbit.assistant.profile": profile_name,
},
) as span:
try:
span.add_event("assistant.request.received")
response = OrbitAssistantGraph(
provider, settings, tool_executor, ui_tools=ui_tools, on_activity=activity
).invoke(prompt)
span.set_attribute("orbit.assistant.response.length", len(response))
span.add_event("assistant.response.completed")
return response
except Exception as error:
span.record_exception(error)
span.set_status(Status(StatusCode.ERROR))
span.add_event("assistant.request.failed", {"error.type": type(error).__name__})
raise

response = await asyncio.to_thread(invoke)
await queue.put({"type": "response", "response": response})
except RuntimeError as error:
await queue.put({"type": "error", "message": str(error)})
except Exception:
logger.exception("Orbit Assistant streaming request failed")
await queue.put({"type": "error", "message": "Chat request failed."})

task = asyncio.create_task(run())
Expand Down
59 changes: 55 additions & 4 deletions backend/app/store.py
Original file line number Diff line number Diff line change
Expand Up @@ -3223,6 +3223,34 @@ def orbit_logs(self) -> list[dict[str, str]]:
),
"",
)
if not message:
process_exit = next(
(
event.get("attributes", {}).get("process.exit_code")
for event in events
if event.get("name") == "process.completed"
and event.get("attributes", {}).get("process.exit_code") not in (None, 0)
),
None,
)
if process_exit is not None:
message = f"process exited with code {process_exit}"
if not message:
failure = next(
(
event
for event in events
if event.get("name", "").endswith(
(".failed", ".invalid", ".rejected", ".timeout")
)
),
None,
)
if failure:
error_type = failure.get("attributes", {}).get("error.type")
message = f"{failure.get('name')}" + (f" ({error_type})" if error_type else "")
if not message:
message = ", ".join(str(event.get("name")) for event in events[-2:] if event.get("name"))
entries.append(
{
"time": str(record.get("exportedAt", "")),
Expand Down Expand Up @@ -3645,6 +3673,9 @@ def create_run(
if execution_mode not in {"run", "test"}:
raise ValueError("execution_mode must be run or test")
runner_asset = self._runner(runner_id)
# ``None`` is a persistent "follow latest" selection. Resolve it for
# this queued execution, but do not replace the selection on the Run:
# a later retry must resolve the then-current latest version again.
resolved_runner_version = runner_version or int(runner_asset["version"])
runner = self._runner_execution_plan(runner_id, resolved_runner_version)
runner_source = next(
Expand All @@ -3657,7 +3688,7 @@ def create_run(
id=uuid.uuid4().hex[:12],
workflow_id=runner.id,
workflow_name=runner.name,
runner_version=resolved_runner_version,
runner_version=runner_version,
runner_source_sha256=hashlib.sha256(str(runner_source).encode("utf-8")).hexdigest(),
build_id=build_id,
build_name=build_name,
Expand Down Expand Up @@ -3931,6 +3962,22 @@ 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:
# A Run with no selected version follows the latest runner on every
# execution, including retries. Keep that selected version nullable;
# the checksum records the source actually resolved now.
runner_asset = self._runner(workflow.runner_id)
resolved_runner_version = run.runner_version or int(runner_asset["version"])
runner_source = next(
item["source"]
for item in runner_asset["versions"]
if int(item["version"]) == resolved_runner_version
)
run.runner_source_sha256 = hashlib.sha256(str(runner_source).encode("utf-8")).hexdigest()
run.workflow_graph = self._runner_graph_definition(
workflow.runner_id, run.repository, resolved_runner_version
)
run.updated_at = now()
self._save(run)
runner_path = self._runner_entry_path(workflow.runner_id, run.runner_version)
for step in [*workflow.steps, *(workflow.test_steps or [])]:
step.command = [
Expand Down Expand Up @@ -5068,12 +5115,16 @@ def retry(self, run_id: str, restart_from_first: bool, output_locale: str | None
run = self._load(run_id)
if run.execution_type != "pipeline" or run.status not in {"succeeded", "failed", "cancelled"}:
raise ValueError("Only completed, failed, or cancelled pipeline runs can be retried")
if run.build_id:
build = self.build(run.build_id)
# Older Runs resolved a latest build to a number at creation time.
# Restore the build's latest-selection semantics before retrying.
if build.get("runner_version") is None:
run.runner_version = None
if output_locale:
resolved_output_locale = output_locale.strip()
if run.build_id and resolved_output_locale:
prompt_source, prompt_snapshot = self._assembled_prompt(
self.build(run.build_id), resolved_output_locale
)
prompt_source, prompt_snapshot = self._assembled_prompt(build, resolved_output_locale)
run.output_locale = resolved_output_locale
run.prompt_source = prompt_source
run.prompt_snapshot = prompt_snapshot
Expand Down
Loading
Loading