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
4 changes: 2 additions & 2 deletions .cursor/rules/hosted-web-contract.mdc
Original file line number Diff line number Diff line change
Expand Up @@ -153,9 +153,9 @@ Sandbox is API-only. The MOTO React frontend is NOT served from the hosted sandb
## Updater Policy

- **Authoritative update source**: GitHub `main` branch (not GitHub Releases)
- **Desktop**: launcher compares local build metadata against GitHub `main`. Auto-apply is only for clean `origin/main` git checkouts or ZIP/extracted installs with no launcher-managed instances still running. ZIP updates preserve active data/log roots, instance storage, launcher state, env files, and keyring-related namespaces.
- **Desktop**: launcher compares local build metadata against GitHub `main`. Remote update identity resolves from GitHub branch HEAD via the GitHub REST API, metadata uses the REST contents API instead of raw GitHub files, and ZIP overlays write the resolved manifest after apply to avoid stale committed-manifest loops. Auto-apply is only for clean `origin/main` git checkouts or ZIP/extracted installs with no launcher-managed instances still running. ZIP updates preserve active data/log roots, instance storage, launcher state, env files, and keyring-related namespaces.
- **Hosted**: sandboxes do NOT self-mutate. Redeploy/recreate uses the latest approved `main`-derived image. Recall/resume keeps the existing image. Hosted `POST /api/update/pull` must return unavailable instead of attempting in-place update.
- **Build metadata**: `version`, `build_commit`, `update_channel`, and `api_contract_version` exposed via `/api/features`; the committed `main`-branch manifest lives at `moto-update-manifest.json`
- **Build metadata**: `version`, `build_commit`, `update_channel`, and `api_contract_version` exposed via `/api/features`; git checkouts resolve `build_commit` from HEAD, ZIP installs use the stamped local manifest, and the committed `main`-branch manifest lives at `moto-update-manifest.json`

## Canonical Runtime Baselines

Expand Down
6 changes: 3 additions & 3 deletions .cursor/rules/program-directory-and-file-definitions.mdc
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ project-root/
│ │ ├── critique_memory.py # Paper critique persistence (saves up to 10 validator critiques per paper)
│ │ ├── critique_prompts.py # Default critique prompt and builder function for validator critiques
│ │ ├── secret_store.py # Secure API key persistence via OS keyring (OpenRouter, Wolfram Alpha); bypassed in generic mode (env-injected/in-memory)
│ │ ├── build_info.py # Build identity resolver (loads version/build_commit/update_channel/api_contract_version from moto-update-manifest.json + env overrides)
│ │ ├── build_info.py # Build identity resolver (manifest + git HEAD/ZIP stamp + env overrides)
│ │ ├── path_safety.py # Safe path resolution helpers (realpath/normpath containment checks)
│ │ ├── fastembed_provider.py # FastEmbed embedding wrapper (generic mode only, lazy-imported; ~30 lines)
│ │ ├── lean4_client.py # Lean 4 proof checker client (subprocess + optional LSP persistent mode; gated on `lean4_enabled` / `lean4_lsp_enabled`; offloads temp/workspace filesystem operations from the FastAPI event loop)
Expand Down Expand Up @@ -367,7 +367,7 @@ project-root/
- `Click To Launch MOTO.bat`: The only Windows consumer entrypoint. It stays thin and always delegates to the Python launcher.
- `linux-ubuntu-launcher.sh`: The Linux/Ubuntu consumer entrypoint. Same thin-wrapper contract as the `.bat`; delegates to `moto_launcher.py`.
- `moto_launcher.py`: Orchestrates the launcher flow in order: update check, runtime resolution, dependency install, LM Studio detection, detached backend/frontend startup, and browser launch.
- `moto_updater.py`: Owns Build 1 updater behavior, including GitHub `main` manifest fetch, install-state classification, clean-git fast-forward apply, ZIP overlay apply, rollback-aware relaunch, and launcher-managed instance safety checks.
- `moto_updater.py`: Owns Build 1 updater behavior, including GitHub REST contents metadata + branch-HEAD resolution, install-state classification, clean-git fast-forward apply, ZIP overlay apply with post-apply manifest stamping, rollback-aware relaunch, and launcher-managed instance safety checks.
- `.moto_launcher_state.json`: Local-only state written by the launcher so future launches can detect still-open backend/frontend windows from the same install and skip update-apply until those windows are closed.

### Hosted Runtime
Expand Down Expand Up @@ -397,7 +397,7 @@ project-root/
- `critique_memory.py`: Paper critique persistence (ratings, feedback, history, session-aware)
- `critique_prompts.py`: Default critique prompt and builder function
- `secret_store.py`: Secure API key persistence via OS keyring; bypassed in generic mode (keys are env-injected/in-memory only)
- `build_info.py`: Build identity helper that reads the committed `moto-update-manifest.json` contract and applies optional env overrides for runtime version/build stamping
- `build_info.py`: Build identity helper that reads the committed manifest contract, resolves git HEAD or ZIP-stamped build commits, and applies optional env overrides for runtime version/build stamping
- `fastembed_provider.py`: FastEmbed embedding wrapper (generic mode only); lazy-imported so default installs are unaffected
- `lean4_client.py`: Lean 4 proof checker client. Subprocess mode by default; optional persistent LSP mode when `lean4_lsp_enabled`. Silent no-op when `lean4_enabled=False`. Never bundled into the hosted image.
- `smt_client.py`: Optional Z3/SMT launcher-managed subprocess wrapper. Silent no-op when `smt_enabled=False`. SMT results are hint-only; Lean 4 remains authoritative. Never bundled into the hosted image.
Expand Down
106 changes: 78 additions & 28 deletions backend/api/routes/update.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@
import logging
import os
import re
import shutil
import tempfile
from pathlib import Path
from typing import Any, Dict, Tuple
Expand Down Expand Up @@ -49,8 +48,20 @@ def _detect_install_kind() -> str:
return "zip"


async def _run_git_command(*args: str) -> tuple[int, str]:
proc = await asyncio.create_subprocess_exec(
"git",
*args,
cwd=str(_REPO_ROOT),
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.STDOUT,
)
stdout, _ = await proc.communicate()
return proc.returncode, stdout.decode("utf-8", errors="replace").strip()


async def _run_git_pull() -> None:
"""Execute git pull for git-clone installs, pulling from the configured update_channel."""
"""Fast-forward a clean git checkout to the configured update channel."""
import sys
if str(_REPO_ROOT) not in sys.path:
sys.path.insert(0, str(_REPO_ROOT))
Expand Down Expand Up @@ -83,24 +94,67 @@ async def _run_git_pull() -> None:
_pull_state["status"] = "done"
return

proc = await asyncio.create_subprocess_exec(
"git", "pull", "origin", channel,
cwd=str(_REPO_ROOT),
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.STDOUT,
status_code, status_output = await _run_git_command("status", "--porcelain", "--untracked-files=no")
if status_code != 0:
_pull_state["output_lines"].append(status_output or "Failed to inspect git status.")
_pull_state["returncode"] = status_code
_pull_state["status"] = "error"
return
if status_output.strip():
_pull_state["output_lines"].append(
"Refused: tracked files have local modifications. Clean the checkout before updating."
)
_pull_state["returncode"] = 1
_pull_state["status"] = "error"
return

fetch_code, fetch_output = await _run_git_command("fetch", "origin", channel, "--quiet")
if fetch_code != 0:
_pull_state["output_lines"].append(fetch_output or f"Failed to fetch origin/{channel}.")
_pull_state["returncode"] = fetch_code
_pull_state["status"] = "error"
return

divergence_code, divergence_output = await _run_git_command(
"rev-list",
"--left-right",
"--count",
f"HEAD...origin/{channel}",
)
if divergence_code != 0:
_pull_state["output_lines"].append(divergence_output or f"Failed to compare HEAD with origin/{channel}.")
_pull_state["returncode"] = divergence_code
_pull_state["status"] = "error"
return

assert proc.stdout is not None
while True:
line = await proc.stdout.readline()
if not line:
break
decoded = line.decode("utf-8", errors="replace").rstrip("\n")
_pull_state["output_lines"].append(decoded)

await proc.wait()
_pull_state["returncode"] = proc.returncode
_pull_state["status"] = "done" if proc.returncode == 0 else "error"
try:
ahead_str, behind_str = divergence_output.split()
ahead = int(ahead_str)
behind = int(behind_str)
except (ValueError, TypeError):
_pull_state["output_lines"].append("Failed to parse git divergence counts.")
_pull_state["returncode"] = 1
_pull_state["status"] = "error"
return

if ahead:
_pull_state["output_lines"].append(
f"Refused: this checkout is ahead of origin/{channel} and cannot be updated automatically."
)
_pull_state["returncode"] = 1
_pull_state["status"] = "error"
return
if not behind:
_pull_state["output_lines"].append("Already up to date.")
_pull_state["returncode"] = 0
_pull_state["status"] = "done"
return

merge_code, merge_output = await _run_git_command("merge", "--ff-only", f"origin/{channel}")
if merge_output:
_pull_state["output_lines"].extend(merge_output.splitlines())
_pull_state["returncode"] = merge_code
_pull_state["status"] = "done" if merge_code == 0 else "error"
except Exception as exc:
logger.exception("git pull failed with exception")
_pull_state["output_lines"].append(f"Exception: {exc}")
Expand All @@ -122,10 +176,11 @@ def _run_zip_update_sync(state_lines: list) -> None:
fetch_remote_manifest,
fetch_branch_head_fallback,
archive_url_for_manifest,
_download_archive,
_extract_archive,
cleanup_path,
_write_installed_manifest,
)
import urllib.request
import zipfile

state_lines.append("Detecting update target...")

Expand Down Expand Up @@ -159,22 +214,17 @@ def _run_zip_update_sync(state_lines: list) -> None:

journal = None
try:
request = urllib.request.Request(archive_url, headers={"User-Agent": "MOTO-Build1-Updater"})
with urllib.request.urlopen(request, timeout=60) as response, archive_path.open("wb") as output:
shutil.copyfileobj(response, output)
_download_archive(remote_manifest, archive_path)
state_lines.append("Download complete. Extracting...")

with zipfile.ZipFile(archive_path) as archive:
archive.extractall(extract_root)

children = [child for child in extract_root.iterdir()]
extracted_source = children[0] if len(children) == 1 and children[0].is_dir() else extract_root
extracted_source = _extract_archive(archive_path, extract_root)

state_lines.append("Applying update (preserving data/config)...")

active_instances = cleanup_launcher_state()
preserved_relatives = collect_preserved_relatives(os.environ, active_instances)
journal = sync_snapshot_into_install(extracted_source, _REPO_ROOT, preserved_relatives, backup_root)
_write_installed_manifest(remote_manifest)

state_lines.append(
f"Update applied: {local_manifest.version} ({local_manifest.short_commit}) "
Expand Down
23 changes: 23 additions & 0 deletions backend/shared/build_info.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import logging
import os
from pathlib import Path
import subprocess
from typing import Any

logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -85,6 +86,24 @@ def _coerce_manifest_version(value: Any) -> int:
return int(_DEFAULT_BUILD_INFO["manifest_version"])


def _current_git_head() -> str | None:
if not (REPO_ROOT / ".git").exists():
return None
try:
result = subprocess.run(
["git", "rev-parse", "HEAD"],
cwd=str(REPO_ROOT),
capture_output=True,
text=True,
timeout=2,
check=False,
)
except (OSError, subprocess.TimeoutExpired):
return None
head = result.stdout.strip()
return head if result.returncode == 0 and head else None


@lru_cache(maxsize=1)
def get_build_info() -> BuildInfo:
"""Resolve build identity from the committed manifest with env overrides."""
Expand All @@ -106,6 +125,10 @@ def get_build_info() -> BuildInfo:
BUILD_MANIFEST_PATH,
)

git_head = _current_git_head()
if git_head:
payload["build_commit"] = git_head

for env_name, field_name in _ENV_OVERRIDES.items():
override = os.environ.get(env_name, "").strip()
if override:
Expand Down
20 changes: 14 additions & 6 deletions frontend/src/components/UpdateNotificationBanner.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export default function UpdateNotificationBanner({ notice, onDismiss }) {
const [errorMessage, setErrorMessage] = useState('');
const logRef = useRef(null);
const pollRef = useRef(null);
const canAutoApply = notice.can_auto_apply !== false;

useEffect(() => {
return () => {
Expand Down Expand Up @@ -75,12 +76,14 @@ export default function UpdateNotificationBanner({ notice, onDismiss }) {
<div className="update-notice-banner">
<div className="update-notice-content">
<div className="update-notice-actions" style={{ gap: '0.75rem' }}>
<button
className="update-notice-pull-btn"
onClick={handlePull}
>
Update
</button>
{canAutoApply && (
<button
className="update-notice-pull-btn"
onClick={handlePull}
>
Update
</button>
)}
<button
className="update-notice-dismiss"
onClick={onDismiss}
Expand All @@ -96,6 +99,11 @@ export default function UpdateNotificationBanner({ notice, onDismiss }) {
{' '}&rarr;{' '}
{notice.available_version} ({notice.available_commit})
</span>
{!canAutoApply && notice.message && (
<span className="update-notice-detail">
{notice.message}
</span>
)}
</div>
</div>
);
Expand Down
4 changes: 0 additions & 4 deletions frontend/src/components/WorkflowPanel.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,10 +39,6 @@ export default function WorkflowPanel({ isRunning }) {
// No persistence. Resets every time isRunning goes true.
const hasPoppedThisSession = useRef(false);

// Auto-open: pop open exactly once, 10 minutes after user presses Start.
// No persistence. Resets every time isRunning goes true.
const hasPoppedThisSession = useRef(false);

const expandPanel = useCallback(() => {
setCollapsed(false);
localStorage.setItem('workflow_panel_collapsed', 'false');
Expand Down
5 changes: 4 additions & 1 deletion moto-update-manifest.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
{
"manifest_version": 1,
"version": "1.0.8",
"build_commit": "06298fc647e267117e7468bb019a4563275dde69",
"build_commit": "7d86e422f615dcea682160126ae88006699aff34",
"update_channel": "main",
"api_contract_version": "build5-v12"
}



Loading
Loading