Fix OpenCode defaults and false health failures (#1866)

Fixes the DigestBox agent path end to end:

- use the ArchiveBox collection root as the default OpenCode workdir
- configure OpenCode's free `opencode/big-pickle` model for
fresh/schema-only state while preserving administrator config
- remove the redundant two-second version gate and three-second browser
health poll that falsely hid a working server and generated continuous
traffic
- retire the private health endpoint and its tests

Verification: `uv run pytest archivebox/tests/test_opencode_agent.py -q`
(20 passed)

<!-- This is an auto-generated description by cubic. -->
---
## Summary by cubic
Fixes OpenCode agent startup so fresh installs get working defaults and
a healthy server no longer appears unavailable.

- Uses the ArchiveBox collection root as the default OpenCode workdir
instead of an unused subdirectory.
- Writes the `opencode/big-pickle` default model only when
`opencode.jsonc` is missing or contains only `$schema`; existing admin
config is left untouched.
- Removes the two-second version gate and three-second browser health
poll that falsely marked healthy servers unavailable and generated
continuous traffic.
- Deletes the private `/_archivebox/health` endpoint and its tests.

<sup>Written for commit 9a3f058f73.
Summary will update on new commits.</sup>

<a
href="https://cubic.dev/pr/ArchiveBox/ArchiveBox/pull/1866?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>

<!-- End of auto-generated description by cubic. -->
This commit is contained in:
Nick Sweeting 2026-09-03 11:07:37 -07:00 committed by GitHub
commit 0b08388e97
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 75 additions and 137 deletions

View File

@ -206,78 +206,6 @@
});
})();
</script>
<script>
(() => {
const frame = document.querySelector(".opencode-agent-frame");
if (!frame) return;
const healthUrl = "{{ proxy_prefix|escapejs }}/global/health";
const monitorUrl = "{{ proxy_prefix|escapejs }}/_archivebox/health";
const fetchOptions = {
cache: "no-store",
credentials: "same-origin",
redirect: "manual",
};
let checking = false;
let waking = false;
let wasUnavailable = false;
let healthyChecks = 0;
let knownVersion = "{{ opencode_version|escapejs }}";
const wake = () => {
if (waking) return;
waking = true;
fetch(healthUrl, fetchOptions)
.catch(() => {})
.finally(() => { waking = false; });
};
const check = async () => {
if (checking || (document.hidden && wasUnavailable)) return;
checking = true;
try {
const response = await fetch(monitorUrl, fetchOptions);
if (!response.ok || response.redirected) {
wasUnavailable = true;
healthyChecks = 0;
if (response.status === 503) {
wake();
}
return;
}
const health = await response.json();
if (!health || health.healthy !== true) {
wasUnavailable = true;
healthyChecks = 0;
return;
}
const currentVersion = typeof health.version === "string" ? health.version : "";
if (knownVersion && currentVersion && currentVersion !== knownVersion) {
wasUnavailable = true;
}
if (currentVersion) {
knownVersion = currentVersion;
}
if (wasUnavailable && ++healthyChecks >= 2) {
wasUnavailable = false;
healthyChecks = 0;
frame.contentWindow.location.reload();
}
} catch {
wasUnavailable = true;
healthyChecks = 0;
} finally {
checking = false;
}
};
window.setInterval(check, 3000);
window.addEventListener("online", check);
document.addEventListener("visibilitychange", () => {
if (!document.hidden) check();
});
check();
})();
</script>
{% endif %}
</div>
{% endblock %}

View File

@ -1,11 +1,10 @@
from django.urls import path, re_path
from archivebox.opencode.views import agent_health_view, agent_view, opencode_proxy_view
from archivebox.opencode.views import agent_view, opencode_proxy_view
urlpatterns = [
path("", agent_view, name="opencode-agent"),
path("opencode/_archivebox/health", agent_health_view, name="opencode-health"),
re_path(
r"^opencode(?:/(?P<path>.*))?$",
opencode_proxy_view,

View File

@ -2,6 +2,7 @@ from __future__ import annotations
import atexit
import base64
import json
import logging
import os
import re
@ -26,7 +27,6 @@ from django.http import (
HttpRequest,
HttpResponse,
HttpResponseForbidden,
JsonResponse,
StreamingHttpResponse,
)
from django.shortcuts import redirect, render
@ -42,6 +42,7 @@ _PROXY_PREFIX = "/admin/agent/opencode"
_PROXY_PREFIX_REGEX = _PROXY_PREFIX.replace("/", r"\/")
_PROXY_PREFIX_NO_SLASH_REGEX = _PROXY_PREFIX.lstrip("/").replace("/", r"\/")
_CONFIG_PATH = Path(opencode_plugin.__file__).with_name("config.json")
_DEFAULT_MODEL = "opencode/big-pickle"
_TEXT_CONTENT_TYPES = (
"text/",
@ -219,7 +220,7 @@ def _settings(config: dict) -> dict:
str(_config_value(config, "OPENCODE_STATE_DIR", default_data_dir / "opencode")),
).expanduser()
workdir = Path(
str(_config_value(config, "OPENCODE_WORKDIR", opencode_dir / "workdir")),
str(_config_value(config, "OPENCODE_WORKDIR", default_data_dir)),
).expanduser()
binary = str(_config_value(config, "OPENCODE_BINARY", "opencode"))
timeout = int(_config_value(config, "OPENCODE_TIMEOUT", 120))
@ -321,6 +322,21 @@ def _ensure_project_files(settings: dict) -> None:
opencode_skill_path.unlink()
opencode_skill_path.symlink_to(editable_skill_path)
opencode_config_path = settings["config_home"] / "opencode" / "opencode.jsonc"
default_config = {
"$schema": "https://opencode.ai/config.json",
"model": _DEFAULT_MODEL,
}
if not opencode_config_path.exists():
opencode_config_path.write_text(f"{json.dumps(default_config, indent=2)}\n")
else:
try:
existing_config = json.loads(opencode_config_path.read_text())
except (OSError, ValueError):
existing_config = None
if isinstance(existing_config, dict) and set(existing_config) <= {"$schema"}:
opencode_config_path.write_text(f"{json.dumps(default_config, indent=2)}\n")
def _ensure_default_session(settings: dict) -> str:
workdir = settings["workdir"].resolve()
@ -393,19 +409,6 @@ def _health(settings: dict, timeout: float = 2) -> bool:
return False
def _opencode_version(settings: dict) -> str:
try:
response = requests.get(
f"{settings['origin']}/global/health",
timeout=2,
)
response.raise_for_status()
data = response.json()
return str(data.get("version") or "") if isinstance(data, dict) else ""
except (requests.RequestException, ValueError):
return ""
def _ensure_opencode(settings: dict) -> tuple[bool, str]:
global _PROCESS, _PROCESS_READY
started_process: subprocess.Popen | None = None
@ -539,16 +542,13 @@ def agent_view(request: HttpRequest):
settings["archivebox_api_url"] = api_url
ok, error = _ensure_opencode(settings)
recent_session_id = ""
opencode_version = ""
if ok:
try:
with _SESSION_LOCK:
recent_session_id = _ensure_default_session(settings)
opencode_version = _opencode_version(settings)
if not opencode_version:
ok = False
error = "OpenCode health check did not report its version."
except (requests.RequestException, RuntimeError, ValueError) as err:
if isinstance(err, requests.RequestException) and _owned_process_ready():
_stop_owned_process()
ok = False
error = f"OpenCode project initialization failed: {err}"
from archivebox.core.admin_site import archivebox_admin
@ -565,7 +565,6 @@ def agent_view(request: HttpRequest):
# transient new-session route and appear to have lost prior sessions.
"proxy_url": _project_route(settings["workdir"], recent_session_id),
"proxy_prefix": _PROXY_PREFIX,
"opencode_version": opencode_version,
"workdir": str(settings["workdir"].resolve()),
"recent_session_id": recent_session_id,
}
@ -577,23 +576,6 @@ def agent_view(request: HttpRequest):
)
def agent_health_view(request: HttpRequest):
config = _machine_config()
_require_enabled(config)
auth_response = _require_superuser(request)
if auth_response:
return auth_response
version = _opencode_version(_settings(config))
healthy = bool(version)
response = JsonResponse(
{"healthy": healthy, "version": version},
status=200 if healthy else 503,
)
response.headers["Cache-Control"] = "no-store"
return response
def _proxy_url(settings: dict, path: str | None) -> str:
rel = "/" if not path else f"/{path}"
return urljoin(settings["origin"], rel)

View File

@ -1,4 +1,5 @@
import asyncio
import json
import os
import signal
import socket
@ -189,14 +190,8 @@ def test_opencode_agent_superuser_gets_admin_wrapper(admin_client, live_opencode
assert b'id="header"' in response.content
assert b'id="progress-monitor"' in response.content
assert response.context["proxy_prefix"] == views._PROXY_PREFIX
assert response.context["opencode_version"]
assert b"const healthUrl" in response.content
assert b"/admin/agent/opencode/global/health" in response.content
assert b"/admin/agent/opencode/_archivebox/health" in response.content
assert b'redirect: "manual"' in response.content
assert b"let waking = false" in response.content
assert b"wake();" in response.content
assert b"frame.contentWindow.location.reload()" in response.content
assert b"/_archivebox/health" not in response.content
assert b"window.setInterval(check, 3000)" not in response.content
assert response.headers["X-Frame-Options"] == "DENY"
assert response.headers["Content-Security-Policy"] == "frame-ancestors 'none'"
@ -210,21 +205,6 @@ def test_opencode_agent_superuser_gets_admin_wrapper(admin_client, live_opencode
assert session.headers["Content-Security-Policy"] == "frame-ancestors 'self'"
def test_opencode_health_monitor_does_not_start_server(admin_client, live_opencode):
from archivebox.opencode import views
views._stop_owned_process()
response = admin_client.get(
"/admin/agent/opencode/_archivebox/health",
HTTP_HOST=ADMIN_TEST_HOST,
)
assert response.status_code == 503
assert response.json() == {"healthy": False, "version": ""}
assert response.headers["Cache-Control"] == "no-store"
assert views._PROCESS is None
def test_opencode_proxy_serves_real_project_and_session(admin_client, live_opencode):
workdir = str(live_opencode.config.data_dir.resolve())
encoded_workdir = quote(workdir)
@ -422,8 +402,15 @@ def test_opencode_starts_with_isolated_state(live_opencode):
)
project.raise_for_status()
config = requests.get(
f"{live_opencode.settings['origin']}/global/config",
timeout=live_opencode.settings["timeout"],
)
config.raise_for_status()
assert Path(live_opencode.settings["workdir"]).resolve() == Path(workdir)
assert Path(str(project.json()["worktree"])).resolve() == Path(workdir)
assert config.json()["model"] == "opencode/big-pickle"
assert live_opencode.process.poll() is None
assert (live_opencode.config.data_dir / ".git").is_dir()
assert (state_dir / "data" / "opencode" / "opencode.db").is_file()
@ -455,15 +442,57 @@ def test_opencode_state_dir_is_separate_from_workdir(tmp_path):
assert loaded_skill.is_symlink()
assert loaded_skill.resolve() == editable_skill.resolve()
assert f"ArchiveBox collection directory: {settings['archivebox_data_dir']}" in editable_skill.read_text()
assert json.loads((state_dir / "config" / "opencode" / "opencode.jsonc").read_text())["model"] == "opencode/big-pickle"
def test_opencode_default_workdir_does_not_scan_the_collection():
def test_opencode_preserves_existing_config(tmp_path):
from archivebox.opencode import views
state_dir = tmp_path / "state"
config_path = state_dir / "config" / "opencode" / "opencode.jsonc"
config_path.parent.mkdir(parents=True)
existing_config = '{\n // Keep the administrator-selected model.\n "model": "anthropic/claude-sonnet-4-5"\n}\n'
config_path.write_text(existing_config)
views._ensure_project_files(
views._settings(
{
"OPENCODE_WORKDIR": str(tmp_path / "workdir"),
"OPENCODE_STATE_DIR": str(state_dir),
},
),
)
assert config_path.read_text() == existing_config
def test_opencode_adds_default_model_to_schema_only_config(tmp_path):
from archivebox.opencode import views
state_dir = tmp_path / "state"
config_path = state_dir / "config" / "opencode" / "opencode.jsonc"
config_path.parent.mkdir(parents=True)
config_path.write_text('{"$schema": "https://opencode.ai/config.json"}\n')
views._ensure_project_files(
views._settings(
{
"OPENCODE_WORKDIR": str(tmp_path / "workdir"),
"OPENCODE_STATE_DIR": str(state_dir),
},
),
)
assert json.loads(config_path.read_text())["model"] == "opencode/big-pickle"
def test_opencode_defaults_to_the_archivebox_collection():
from archivebox.opencode import views
settings = views._settings({})
assert settings["opencode_dir"] == settings["archivebox_data_dir"] / "opencode"
assert settings["workdir"] == settings["opencode_dir"] / "workdir"
assert settings["workdir"] == settings["archivebox_data_dir"]
assert settings["timeout"] == 120