From 0a41c1193167e67d5ab988da76d403ab682d549c Mon Sep 17 00:00:00 2001 From: sam2tom Date: Sat, 19 Sep 2026 20:22:54 +0800 Subject: [PATCH] feat(web): guide local Core recovery --- .env.example | 10 + README.md | 9 + README.zh-CN.md | 6 + apps/web/e2e/agents-lifecycle.spec.ts | 133 ++++++++--- apps/web/src/App.tsx | 30 ++- apps/web/src/components/ConnectionModal.css | 222 ++++++++++++++++++ .../src/components/ConnectionModal.test.tsx | 53 ++++- apps/web/src/components/ConnectionModal.tsx | 185 ++++++++++++--- .../src/features/dashboard/DashboardView.css | 60 +++++ .../features/dashboard/DashboardView.test.tsx | 19 ++ .../src/features/dashboard/DashboardView.tsx | 50 +++- apps/web/src/lib/core-readiness.test.ts | 18 ++ apps/web/src/lib/core-readiness.ts | 24 ++ apps/web/src/lib/docker-guide-config.test.ts | 49 +++- apps/web/src/lib/docker-guide-config.ts | 49 +++- apps/web/src/vite-env.d.ts | 6 + apps/web/vite.config.ts | 7 +- docs/core-connection.md | 37 +++ docs/protocol-coverage.md | 9 + playwright.config.ts | 2 +- 20 files changed, 885 insertions(+), 93 deletions(-) create mode 100644 apps/web/src/lib/core-readiness.test.ts create mode 100644 apps/web/src/lib/core-readiness.ts diff --git a/.env.example b/.env.example index 40f7aa2..b58a813 100644 --- a/.env.example +++ b/.env.example @@ -26,6 +26,16 @@ AGENTS_API_PROXY_TOKEN_FILE=~/.parsar/agents-api/web-token # exact workspace_directory root. # AGENTS_CORE_WEB_ENVIRONMENT_FILES=1 +# Optional local-only Docker backend recovery guide shown in the connection +# panel. Web renders copyable `docker start` and loopback health commands; it +# never accesses the Docker socket or executes them. Values are compiled into +# the browser bundle and must contain non-secret container names only. +# AGENTS_CORE_WEB_DOCKER_BACKEND_GUIDE=1 +# AGENTS_CORE_WEB_DOCKER_DATABASE_CONTAINER=parsar-agents-api-web-smoke-db +# AGENTS_CORE_WEB_DOCKER_API_CONTAINER=agents-core-web-api +# AGENTS_CORE_WEB_DOCKER_DAEMON_CONTAINER=agents-core-web-daemon +# AGENTS_CORE_WEB_DOCKER_CORE_PORT=8091 + # Optional local-only Docker connection recipe. This renders a copyable command; # it never gives the browser Docker access or reads the credential file. Every # value below is compiled into the browser bundle, so values must be non-secret. diff --git a/README.md b/README.md index dab1370..d70eac9 100644 --- a/README.md +++ b/README.md @@ -180,6 +180,15 @@ runs on caller-managed Linux executor compute. See [Connecting Agent Core](docs/core-connection.md#optional-self-hosted-session-creation) for the exact boundary. +The separately opt-in `AGENTS_CORE_WEB_DOCKER_BACKEND_GUIDE=1` profile turns Dashboard +gateway failures into an explicit recovery entry point. For an existing stack, the +connection panel shows validated, copyable commands for the configured database, Core +API, and daemon containers plus the loopback health check. For first-time use, it shows +the Core image build command and immutable Parsar container/daemon setup links. Parsar +still requires operator-created database and credential state; Web never runs these +commands, accesses Docker, or invents secrets. Container names remain non-secret +operator configuration from `.env.example`. + For the reviewed local loopback stack, an operator can additionally enable the default-off `AGENTS_CORE_WEB_DOCKER_GUIDE=1` profile and its required non-secret `AGENTS_CORE_WEB_DOCKER_*` settings from `.env.example`. The connection panel then diff --git a/README.zh-CN.md b/README.zh-CN.md index 8ded618..67d7470 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -158,6 +158,12 @@ Core 返回的 Environment ID、executor origin、连接状态和安全 launcher 运维方签发的 executor credential 文件始终留在 Web 之外。完整边界见 [连接 Agent Core](docs/core-connection.md#optional-self-hosted-session-creation)。 +可单独启用 `AGENTS_CORE_WEB_DOCKER_BACKEND_GUIDE=1`。Dashboard 遇到网关错误时会明确 +提示 Core 后端未就绪。已有容器时,连接面板会根据 `.env.example` 中经过校验的非秘密 +容器名,展示数据库、Core API、daemon 和 loopback 健康检查的可复制命令;首次使用时, +则展示 Core 镜像构建命令以及固定 Parsar 版本的容器和 daemon 初始化文档。Parsar 首次 +初始化仍需要运维方创建独立数据库和凭据,Web 不执行命令、不访问 Docker,也不猜测密钥。 + 对于已核对的本地 loopback 栈,还可以配置 `.env.example` 中默认关闭的 `AGENTS_CORE_WEB_DOCKER_GUIDE=1` 以及完整的非秘密 `AGENTS_CORE_WEB_DOCKER_*` 参数。连接面板会在原生 launcher 之外提供可复制的 Docker 命令;Web 仍不会读取 diff --git a/apps/web/e2e/agents-lifecycle.spec.ts b/apps/web/e2e/agents-lifecycle.spec.ts index f7e32ac..af6d5a4 100644 --- a/apps/web/e2e/agents-lifecycle.spec.ts +++ b/apps/web/e2e/agents-lifecycle.spec.ts @@ -120,7 +120,7 @@ async function expectSelectedDeleteAbortsSessionRead( async function openAgents(page: Page, request: APIRequestContext) { await resetFixture(request); - await page.goto("/"); + await openSessionsFromHome(page); await page.getByRole("button", { name: "Agents" }).click(); await expect(page.getByRole("list", { name: "Agents", exact: true })).toBeVisible(); } @@ -177,6 +177,61 @@ async function attachElementScreenshot(locator: Locator, testInfo: TestInfo, nam }); } +async function openSessionsFromHome(page: Page) { + await page.goto("/"); + await page.getByRole("button", { name: "Sessions", exact: true }).click(); +} + +test("opens Dashboard as the default landing page", async ({ page, request }) => { + await resetFixture(request); + await page.goto("/"); + + await expect(page.getByRole("button", { name: "Dashboard", exact: true })).toHaveAttribute("aria-current", "page"); + await expect(page.getByRole("heading", { name: "Dashboard", exact: true })).toBeVisible(); + await expect(page.locator(".dashboard-page")).toBeVisible(); +}); + +test("explains a 502 Core backend failure and opens copyable Docker recovery steps", async ({ page }) => { + await page.route("**/v1/agents**", async (route) => { + await route.fulfill({ + status: 502, + contentType: "application/json", + body: JSON.stringify({ + error: { message: "Agent core request failed (502).", type: "gateway_error" }, + }), + }); + }); + await page.goto("/"); + + const recovery = page.getByRole("button", { + name: "Agent Core backend is not ready. Open Docker startup guide", + }); + await expect(recovery).toBeVisible(); + await expect(recovery).toContainText("HTTP 502"); + await expect(page.getByText("Agents: Agent core request failed (502).", { exact: true })).toHaveCount(0); + await expect(page.getByText( + "Agent Core backend is not ready. Open the Dashboard startup guide.", + { exact: true }, + )).toHaveCount(1); + await expect(page.getByText("Agent core request failed (502).", { exact: true })).toHaveCount(0); + await recovery.click(); + + const dialog = page.getByRole("dialog", { name: "Connect an Agent Core" }); + await expect(dialog).toContainText("Start a local Docker backend"); + await expect(dialog).toContainText("docker start parsar-agents-api-web-smoke-db"); + await expect(dialog).toContainText("docker start agents-core-web-api agents-core-web-daemon"); + await expect(dialog).toContainText("http://127.0.0.1:8091/healthz"); + await expect(dialog).toContainText("First time on this computer"); + await expect(dialog).toContainText("make docker-build-agents-api"); + await expect(dialog.getByRole("link", { name: "Parsar container setup · pinned revision" })).toHaveAttribute( + "href", + /dadf64a76bde58255281f3b6c3e939f8b556be09\/services\/agents-api\/CONTAINER\.md$/, + ); + await expect(dialog.getByRole("button", { name: "Copy database start command" })).toBeVisible(); + await expect(dialog.getByRole("button", { name: "Copy Core image build command" })).toBeVisible(); + await expect(dialog.getByRole("button", { name: "Test connection" })).toBeVisible(); +}); + test("retrieves the latest Agent and opens its validated edit setup directly", async ({ page, request }, testInfo) => { const browserErrors: string[] = []; page.on("console", (message) => { @@ -366,7 +421,7 @@ test("creates, previews, edits, and removes bounded Function and anonymous HTTP test("keeps Source Files controls out of the System status page", async ({ page, request }) => { await resetFixture(request); - await page.goto("/"); + await openSessionsFromHome(page); await page.getByRole("button", { name: "System", exact: true }).click(); await expect(page.getByRole("region", { name: "Source Files" })).toHaveCount(0); await expect(page.locator(".system-page")).not.toContainText("Source Files"); @@ -703,7 +758,7 @@ test("derives manual Vault attachments for anonymous and explicit MCP Credential expect(response.status()).toBe(201); } - await page.goto("/"); + await openSessionsFromHome(page); await page.getByRole("button", { name: "Agents", exact: true }).click(); await expect(page.getByRole("list", { name: "Agents", exact: true })).toBeVisible(); @@ -792,7 +847,7 @@ test("clears manual Vault attachments when overrides remove HTTP MCP tools", asy } }); expect(agentResponse.status()).toBe(201); - await page.goto("/"); + await openSessionsFromHome(page); await page.getByRole("button", { name: "Agents", exact: true }).click(); await page.getByRole("button", { name: /^Start a Session with MCP Clear Agent/ }).click(); const dialog = page.getByRole("dialog", { name: "Create a Session" }); @@ -995,7 +1050,7 @@ test("blocks hosted MCP before persistence while allowing a Function-only manage } }); expect(fn.status()).toBe(201); - await page.goto("/"); + await openSessionsFromHome(page); await page.getByRole("button", { name: "Agents", exact: true }).click(); const before = (await fixtureRequests(request)).filter((entry) => ( entry.method === "POST" && entry.path === "/v1/agents/sessions" @@ -1027,7 +1082,7 @@ test("blocks hosted MCP before persistence while allowing a Function-only manage test("keeps managed Environment resource and terminal event states fail-closed", async ({ page, request }) => { await resetFixture(request); await controlFixture(request, { environmentScenario: 8, environmentResourceStatus: "expired" }); - await page.goto("/"); + await openSessionsFromHome(page); let trigger = environmentTrigger(page); await expect(trigger).toContainText("Managed Environment expired"); let opened = await openEnvironmentDialog(page); @@ -1095,7 +1150,7 @@ test("creates an inline Session without saved Agents and preserves ordered user- expect(deleted.ok()).toBe(true); } await page.getByRole("button", { name: "Refresh Agents" }).click(); - await expect(page.getByRole("heading", { name: "No saved Agents" })).toBeVisible(); + await expect(page.getByText("No saved Agents", { exact: true })).toBeVisible(); await page.getByRole("button", { name: "Sessions", exact: true }).click(); const newSession = page.getByRole("button", { name: "New Session" }); @@ -1403,7 +1458,7 @@ test("filters every Session page by Agent and aborts a stale filter read", async await resetFixture(request); await controlFixture(request, { sessionListPageSize: 1 }); - await page.goto("/"); + await openSessionsFromHome(page); const filter = page.getByLabel("Filter Sessions by Agent"); await expect(filter).toBeVisible(); await expect(page.locator(".session-row")).toHaveCount(1); @@ -1482,7 +1537,7 @@ test("filters every Session page by Agent and aborts a stale filter read", async test("fences the filtered workspace across loading, errors, unavailable Agents, and deletes", async ({ page, request }) => { await resetFixture(request); - await page.goto("/"); + await openSessionsFromHome(page); await expect(page.locator(".conversation-header h2")).toHaveText("Lifecycle Agent"); await createFixtureSession(request, "delete-first"); await createFixtureSession(request, "delete-second"); @@ -1617,7 +1672,7 @@ test("keeps one Session create attempt across response loss and an unchanged man test("shows composer activity only for a Core-reported in-progress Session", async ({ page, request }, testInfo) => { await resetFixture(request); - await page.goto("/"); + await openSessionsFromHome(page); await expect(connectedLiveEvents(page)).toBeVisible(); const activity = page.locator(".conversation-activity"); @@ -1638,7 +1693,7 @@ test("shows composer activity only for a Core-reported in-progress Session", asy test("shows immediate local feedback while a message submission is waiting for Core", async ({ page, request }) => { await resetFixture(request); - await page.goto("/"); + await openSessionsFromHome(page); await expect(connectedLiveEvents(page)).toBeVisible(); let releaseSend: (() => void) | undefined; @@ -1679,7 +1734,7 @@ test("shows immediate local feedback while a message submission is waiting for C test("updates Session title and metadata after a latest read while preserving failed and unknown drafts", async ({ page, request }) => { await resetFixture(request); - await page.goto("/"); + await openSessionsFromHome(page); await expect(connectedLiveEvents(page)).toBeVisible(); const manage = page.locator(".conversation-session-action"); const streamReadsBefore = (await fixtureRequests(request)).filter((entry) => ( @@ -1741,7 +1796,7 @@ test("updates Session title and metadata after a latest read while preserving fa test("preserves and safely rebases a Session metadata draft after a same-key conflict", async ({ page, request }) => { await resetFixture(request); - await page.goto("/"); + await openSessionsFromHome(page); await expect(connectedLiveEvents(page)).toBeVisible(); await page.locator(".conversation-session-action").click(); const dialog = page.getByRole("dialog"); @@ -1776,7 +1831,7 @@ test("preserves and safely rebases a Session metadata draft after a same-key con test("rejects wrong-id and deep-malformed Session reads before writes or delete retries", async ({ page, request }) => { await resetFixture(request); - await page.goto("/"); + await openSessionsFromHome(page); await expect(connectedLiveEvents(page)).toBeVisible(); await controlFixture(request, { sessionRetrieveVariant: "wrong_id" }); @@ -1816,7 +1871,7 @@ test("rejects wrong-id and deep-malformed Session reads before writes or delete test("requires confirmation and reconciles unknown Session deletes once without retrying the write", async ({ page, request }) => { await resetFixture(request); - await page.goto("/"); + await openSessionsFromHome(page); await expect(connectedLiveEvents(page)).toBeVisible(); const manage = page.locator(".conversation-session-action"); await manage.click(); @@ -1894,7 +1949,7 @@ test("requires confirmation and reconciles unknown Session deletes once without test("keeps a stale Session row and surfaces each explicit repeated 404 deletion", async ({ page, request }) => { await resetFixture(request); - await page.goto("/"); + await openSessionsFromHome(page); await expect(connectedLiveEvents(page)).toBeVisible(); const manage = page.locator(".conversation-session-action"); await manage.click(); @@ -1913,7 +1968,7 @@ test("keeps a stale Session row and surfaces each explicit repeated 404 deletion test("deletes an inactive Session without disturbing the active composer or live event stream", async ({ page, request }) => { await resetFixture(request); - await page.goto("/"); + await openSessionsFromHome(page); await expect(connectedLiveEvents(page)).toBeVisible(); await page.getByRole("button", { name: "Agents" }).click(); await startSessionWithSecondAgent(page); @@ -1958,7 +2013,7 @@ test("deletes an inactive Session without disturbing the active composer or live test("continues a Session with a new Turn after its latest attempt fails", async ({ page, request }) => { await resetFixture(request); - await page.goto("/"); + await openSessionsFromHome(page); await expect(connectedLiveEvents(page)).toBeVisible(); await emitSessionFixture(request, "failed"); @@ -2017,7 +2072,7 @@ for (const pendingRead of [ } }); await resetFixture(request); - await page.goto("/"); + await openSessionsFromHome(page); await expect(connectedLiveEvents(page)).toBeVisible(); await page.getByRole("button", { name: "Agents" }).click(); await startSessionWithSecondAgent(page); @@ -2060,7 +2115,7 @@ for (const pendingRead of [ test("aborts a pending manual recovery read after deleting the selected Session", async ({ page, request }) => { await resetFixture(request); - await page.goto("/"); + await openSessionsFromHome(page); await expect(connectedLiveEvents(page)).toBeVisible(); await controlFixture(request, { sessionRetrieveDelayMs: 5_000 }); @@ -2071,7 +2126,7 @@ test("aborts a pending manual recovery read after deleting the selected Session" test("aborts a pending detail retry read after deleting the selected Session", async ({ page, request }) => { await resetFixture(request); - await page.goto("/"); + await openSessionsFromHome(page); await expect(connectedLiveEvents(page)).toBeVisible(); await controlFixture(request, { sessionRetrieveStatus: 503 }); await page.getByRole("button", { name: "Recover durable state" }).click(); @@ -2086,7 +2141,7 @@ test("aborts a pending detail retry read after deleting the selected Session", a test("deletes the selected Session while its SSE is still connecting", async ({ page, request }) => { await resetFixture(request); - await page.goto("/"); + await openSessionsFromHome(page); await expect(connectedLiveEvents(page)).toBeVisible(); await page.getByRole("button", { name: "Agents" }).click(); await startSessionWithSecondAgent(page); @@ -2119,7 +2174,7 @@ test("deletes the selected Session while its SSE is still connecting", async ({ test("keeps Session actions accessible and contained at 390 px in dark mode", async ({ page, request }) => { await page.setViewportSize({ width: 390, height: 844 }); await resetFixture(request); - await page.goto("/"); + await openSessionsFromHome(page); await expect(connectedLiveEvents(page)).toBeVisible(); await page.getByRole("button", { name: "Dark theme" }).click(); const manage = page.locator(".conversation-session-action"); @@ -2157,7 +2212,7 @@ test("keeps Session actions accessible and contained at 390 px in dark mode", as test("presents Dashboard page-chain results and System boundaries without extra detail reads", async ({ page, request }, testInfo) => { await resetFixture(request); - await page.goto("/"); + await openSessionsFromHome(page); await expect(connectedLiveEvents(page)).toBeVisible(); await expect(page.getByText("Session is ready", { exact: true })).toBeVisible(); const initialDetailPaths = [ @@ -2344,7 +2399,7 @@ test("publishes Dashboard counts only after every top-level Agent and Session pa }); }); - await page.goto("/"); + await openSessionsFromHome(page); await page.getByRole("button", { name: "Dashboard", exact: true }).click(); const dashboard = page.locator(".dashboard-page"); await expect(dashboard.locator(".dashboard-summary > div").filter({ hasText: "Agents" })).toContainText("3"); @@ -2355,7 +2410,7 @@ test("publishes Dashboard counts only after every top-level Agent and Session pa test("keeps the previous Dashboard result when pagination exceeds the safety limit", async ({ page, request }) => { await resetFixture(request); - await page.goto("/"); + await openSessionsFromHome(page); await page.getByRole("button", { name: "Dashboard", exact: true }).click(); const dashboard = page.locator(".dashboard-page"); @@ -2412,7 +2467,7 @@ test("renders self-hosted Environment and Workspace state safely across reconnec streamCloseCount: 1, streamCloseDelayMs: 1_000, }); - await page.goto("/"); + await openSessionsFromHome(page); await expect(connectedLiveEvents(page)).toBeVisible(); const { dialog, panel, trigger } = await openEnvironmentDialog(page); @@ -2487,7 +2542,7 @@ test("renders self-hosted Environment and Workspace state safely across reconnec test("lists Workspace file metadata explicitly, paginates, fails closed, and fences Environment changes", async ({ page, request }, testInfo) => { await resetFixture(request); await controlFixture(request, { environmentScenario: 7 }); - await page.goto("/"); + await openSessionsFromHome(page); const { panel } = await openEnvironmentDialog(page); const files = panel.getByRole("region", { name: "Workspace files" }); @@ -2546,7 +2601,7 @@ test("hydrates durable expired and unavailable Environment states without a writ environmentResourceStatus: "expired", environmentEventStatus: 0, }); - await page.goto("/"); + await openSessionsFromHome(page); await expect(connectedLiveEvents(page)).toBeVisible(); let { panel, trigger } = await openEnvironmentDialog(page); @@ -2605,7 +2660,7 @@ test("hydrates durable Environment state even when the live stream is rejected", environmentResourceStatus: "expired", streamStatus: 401, }); - await page.goto("/"); + await openSessionsFromHome(page); const { panel, trigger } = await openEnvironmentDialog(page); await expect(trigger).toHaveAccessibleName("Environment expired"); @@ -2628,7 +2683,7 @@ test("keeps canonical Environment UUID identity across Session and resource proj environmentResourceStatus: "connected", environmentEventStatus: 0, }); - await page.goto("/"); + await openSessionsFromHome(page); const { panel, trigger } = await openEnvironmentDialog(page); await expect(trigger).toHaveAccessibleName("Environment connected"); @@ -2656,7 +2711,7 @@ test("applies a buffered live Environment event after an earlier durable snapsho environmentEventStatus: 3, environmentEventCount: 1, }); - await page.goto("/"); + await openSessionsFromHome(page); const { panel, trigger } = await openEnvironmentDialog(page); await expect(trigger).toHaveAccessibleName("Environment connected"); @@ -2671,7 +2726,7 @@ test("loads every Turn page, reconciles terminal events, and keeps diagnostics o turnsScenario: 1, turnsPageSize: 2, }); - await page.goto("/"); + await openSessionsFromHome(page); await expect(connectedLiveEvents(page)).toBeVisible(); const conversationTab = page.getByRole("tab", { name: "Conversation" }); @@ -2762,7 +2817,7 @@ test("presents an honest searchable Trace workbench without changing the convers turnsPageSize: 2, itemsScenario: 2, }); - await page.goto("/"); + await openSessionsFromHome(page); await expect(connectedLiveEvents(page)).toBeVisible(); const viewTabs = page.getByRole("tablist", { name: "Session view" }); @@ -2876,7 +2931,7 @@ test("drops a delayed Turn page after switching Sessions", async ({ page, reques turnsRetrieveDelayMs: 700, turnsPageSize: 2, }); - await page.goto("/"); + await openSessionsFromHome(page); await expect(page.getByText("Completed Turn output remains in the conversation.")).toBeVisible({ timeout: 1_500 }); await page.getByRole("tab", { name: "Trace" }).click(); const diagnostics = page.locator("details.trace-turn-diagnostics"); @@ -2906,7 +2961,7 @@ test("drops a delayed Turn page after switching Sessions", async ({ page, reques test("renders Parsar patches as accessible read-only diffs in desktop and narrow themes", async ({ page, request }, testInfo) => { await resetFixture(request); await controlFixture(request, { itemsScenario: 1 }); - await page.goto("/"); + await openSessionsFromHome(page); await expect(connectedLiveEvents(page)).toBeVisible(); const completedTrace = page.locator('[data-work-trace="completed"]'); @@ -2955,7 +3010,7 @@ test("renders Parsar patches as accessible read-only diffs in desktop and narrow test("manually retries uncertain sends with the original key only while the payload is unchanged", async ({ page, request }, testInfo) => { await resetFixture(request); - await page.goto("/"); + await openSessionsFromHome(page); await expect(connectedLiveEvents(page)).toBeVisible(); const composer = page.getByLabel("Message the Agent"); @@ -3004,7 +3059,7 @@ test("manually retries uncertain sends with the original key only while the payl test("reuses Function result identity only for an unchanged uncertain explicit retry", async ({ page, request }) => { await resetFixture(request); await controlFixture(request, { environmentScenario: 10 }); - await page.goto("/"); + await openSessionsFromHome(page); await expect(connectedLiveEvents(page)).toBeVisible(); const editor = page.getByLabel("Function result or error"); const submit = page.getByRole("button", { name: "Submit result" }); @@ -3047,7 +3102,7 @@ test("reuses Function result identity only for an unchanged uncertain explicit r test("keeps cancellation available for an Environment-only required action", async ({ page, request }) => { await resetFixture(request); await controlFixture(request, { environmentScenario: 6 }); - await page.goto("/"); + await openSessionsFromHome(page); await expect(connectedLiveEvents(page)).toBeVisible(); await expect(page.getByRole("region", { name: "Environment connection required" })).toBeVisible(); await expect(page.getByRole("region", { name: "Function result required" })).toHaveCount(0); diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 9b0d2b3..0091ef7 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -87,6 +87,7 @@ import { } from "./lib/connection"; import { settleCollection } from "./lib/collection-load"; import { listStableCollectionPages } from "./lib/collection-pagination"; +import { BACKEND_NOT_READY_NOTICE, backendFailureStatus } from "./lib/core-readiness"; import { beginPendingFunctionResult, failPendingFunctionResult, @@ -120,6 +121,14 @@ import { type View = ProductView | "system"; +function viewFromLocation(): View { + if (typeof window === "undefined") return "dashboard"; + const candidate = window.location.hash.slice(1); + return candidate === "agents" || candidate === "sessions" || candidate === "vaults" || candidate === "system" + ? candidate + : "dashboard"; +} + interface StreamConnection { sessionId: string | null; state: StreamState; @@ -214,9 +223,22 @@ function projectTextEvent(current: SessionItem[], event: SessionEvent): SessionI export function App() { const { show: showToast } = useToast(); - const [view, setView] = useState("sessions"); + const [view, setView] = useState(viewFromLocation); const [connection, setConnection] = useState(() => loadConnection()); const [connectionOpen, setConnectionOpen] = useState(false); + + useEffect(() => { + const hash = view === "dashboard" ? "" : `#${view}`; + const next = `${window.location.pathname}${window.location.search}${hash}`; + const current = `${window.location.pathname}${window.location.search}${window.location.hash}`; + if (current !== next) window.history.replaceState(window.history.state, "", next); + }, [view]); + + useEffect(() => { + const onHashChange = () => setView(viewFromLocation()); + window.addEventListener("hashchange", onHashChange); + return () => window.removeEventListener("hashchange", onHashChange); + }, []); const [agents, setAgents] = useState([]); const [vaultCatalog, setVaultCatalog] = useState(null); const [vaultCollectionState, setVaultCollectionState] = useState("connecting"); @@ -381,7 +403,7 @@ export function App() { const message = errorMessage(result.reason); setAgentCollectionState("failed"); setAgentCollectionError(message); - notify(message, "error"); + notify(backendFailureStatus(result.reason) ? BACKEND_NOT_READY_NOTICE : message, "error"); return false; } if (result.value === null) { @@ -425,7 +447,7 @@ export function App() { const message = errorMessage(result.reason); setSessionCollectionState("failed"); setSessionCollectionError(message); - notify(message, "error"); + notify(backendFailureStatus(result.reason) ? BACKEND_NOT_READY_NOTICE : message, "error"); return false; } if (result.value === null) { @@ -516,7 +538,7 @@ export function App() { const message = errorMessage(result.reason); setFilteredSessionCollectionState("failed"); setFilteredSessionCollectionError(message); - notify(message, "error"); + notify(backendFailureStatus(result.reason) ? BACKEND_NOT_READY_NOTICE : message, "error"); return false; } if (result.value === null) { diff --git a/apps/web/src/components/ConnectionModal.css b/apps/web/src/components/ConnectionModal.css index ed62025..c48b60c 100644 --- a/apps/web/src/components/ConnectionModal.css +++ b/apps/web/src/components/ConnectionModal.css @@ -113,6 +113,213 @@ margin: 8px 0 0; } +.connection-docker-guide { + margin-top: 10px; + padding: 12px; + color: var(--fg-muted); + font-size: 12px; + line-height: 17px; + background: var(--surface-subtle); + border: 1px solid var(--line); + border-radius: 6px; +} + +.connection-docker-guide .connection-guide-header > svg { + color: var(--accent); +} + +.connection-docker-path { + margin-top: 12px; + padding: 10px; + background: var(--surface); + border: 1px solid var(--line); + border-radius: 6px; +} + +.connection-docker-path-heading { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 10px; +} + +.connection-docker-path-heading strong { + color: var(--fg); + font-size: 12px; + font-weight: 550; +} + +.connection-docker-path-heading span { + color: var(--fg-muted); + font-size: 10px; +} + +.connection-docker-path-heading code { + color: var(--fg); + font-family: var(--font-mono); + font-size: 10px; +} + +.connection-docker-steps { + display: grid; + margin: 8px 0 0; + padding: 0; + gap: 10px; + counter-reset: docker-step; + list-style: none; +} + +.connection-docker-first-use > p { + margin: 8px 0 0; +} + +.connection-docker-first-steps { + display: grid; + margin: 9px 0 0; + padding-left: 20px; + gap: 8px; +} + +.connection-docker-first-steps > li { + padding-left: 2px; +} + +.connection-docker-first-steps > li > span { + display: grid; + gap: 1px; +} + +.connection-docker-first-steps strong { + color: var(--fg); + font-size: 11px; + font-weight: 550; +} + +.connection-docker-first-steps small { + color: var(--fg-muted); + font-size: 10px; + line-height: 14px; +} + +.connection-docker-first-steps .connection-docker-command { + margin-top: 5px; +} + +.connection-docker-first-links { + display: flex; + margin-top: 10px; + align-items: flex-start; + flex-direction: column; + gap: 5px; +} + +.connection-docker-first-links a { + display: inline-flex; + align-items: center; + gap: 5px; + color: var(--accent-strong); + text-decoration: none; +} + +.connection-docker-first-links a:hover { + text-decoration: underline; +} + +.connection-docker-steps > li { + display: grid; + min-width: 0; + padding-top: 10px; + gap: 6px; + border-top: 1px solid var(--line); + counter-increment: docker-step; +} + +.connection-docker-steps > li > span { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + gap: 2px 7px; +} + +.connection-docker-steps > li > span::before { + display: grid; + width: 18px; + height: 18px; + grid-row: 1 / span 2; + place-items: center; + color: var(--accent); + font-family: var(--font-mono); + font-size: 10px; + background: color-mix(in srgb, var(--accent) 10%, var(--surface)); + border-radius: 999px; + content: counter(docker-step); +} + +.connection-docker-steps strong, +.connection-docker-unconfigured strong { + color: var(--fg); + font-size: 12px; + font-weight: 550; +} + +.connection-docker-steps small { + color: var(--fg-muted); + font-size: 11px; + line-height: 15px; +} + +.connection-docker-command { + display: flex; + min-width: 0; + align-items: center; + padding: 7px 7px 7px 9px; + gap: 8px; + background: var(--surface); + border: 1px solid var(--line); + border-radius: 5px; +} + +.connection-docker-command code { + min-width: 0; + flex: 1; + overflow-x: auto; + color: var(--fg); + font-family: var(--font-mono); + font-size: 10px; + line-height: 15px; + white-space: nowrap; +} + +.connection-docker-command .button { + min-height: 26px; + flex: 0 0 auto; + padding: 3px 7px; + font-size: 10px; +} + +.connection-docker-unconfigured { + margin-top: 10px; + padding: 9px 10px; + background: var(--surface); + border: 1px dashed var(--line-strong); + border-radius: 5px; +} + +.connection-docker-unconfigured p, +.connection-docker-boundary { + margin: 3px 0 0; +} + +.connection-docker-unconfigured code { + color: var(--fg); + font-family: var(--font-mono); + font-size: 10px; +} + +.connection-docker-boundary { + padding-top: 9px; + border-top: 1px solid var(--line); +} + .connection-modal-advanced { margin-top: 12px; } @@ -207,4 +414,19 @@ grid-template-columns: 1fr; gap: 2px; } + + .connection-docker-command { + align-items: stretch; + flex-direction: column; + } + + .connection-docker-command .button { + width: 100%; + } + + .connection-docker-path-heading { + align-items: flex-start; + flex-direction: column; + gap: 2px; + } } diff --git a/apps/web/src/components/ConnectionModal.test.tsx b/apps/web/src/components/ConnectionModal.test.tsx index 573b912..f0eeaf5 100644 --- a/apps/web/src/components/ConnectionModal.test.tsx +++ b/apps/web/src/components/ConnectionModal.test.tsx @@ -6,18 +6,25 @@ import { ConnectionProbeStatus, type ConnectionProbeState, } from "./ConnectionModal"; +import type { LocalDockerBackendGuideProfile } from "../lib/docker-guide-config"; const callbacks = { onClose: () => undefined, onSave: () => undefined, }; -function renderModal(baseUrl: string, proxyAuthEnabled: boolean, token = "") { +function renderModal( + baseUrl: string, + proxyAuthEnabled: boolean, + token = "", + dockerBackendGuide: LocalDockerBackendGuideProfile | null = null, +) { return renderToStaticMarkup( , ); @@ -85,15 +92,15 @@ describe("Agent Core connection modes", () => { expect(markup.match(/disabled=""/g)).toHaveLength(2); }); - it("replaces inline credential tutorials with concise operator links", () => { + it("keeps credential tutorials in pinned operator links", () => { const markup = renderModal("/v1", true); expect(markup).toContain("Operator-owned setup"); - expect(markup).toContain("Legacy Web guide · 043 snapshot"); - expect(markup).toContain("Legacy troubleshooting · 043 snapshot"); + expect(markup).toContain("Web connection guide · current snapshot"); + expect(markup).toContain("Connection troubleshooting · current snapshot"); expect(markup).toContain("Parsar Core setup"); - expect(markup).toContain("98c5b3312ad33e1fae8b94283a011eb3e5f4ee2c"); - expect(markup).toContain("c31f81677a8b16c53b665de9075181df837a0032"); + expect(markup).toContain("agents-core-web/blob/main/docs/core-connection.md"); + expect(markup).toContain("dadf64a76bde58255281f3b6c3e939f8b556be09"); expect(markup).not.toContain("0438880ab21aa16d05cb91a4c7f91cc0abc12358"); expect(markup).not.toContain("f7cdf591396529880d80f8211fc7a0f4768fdf46"); expect(markup).not.toContain("8cc2898ca42b272cb3771234ee6a0ad0d2e932ba"); @@ -105,6 +112,40 @@ describe("Agent Core connection modes", () => { expect(markup).not.toContain("AGENTS_API_DAEMON_WS_URL"); }); + it("renders a copyable, non-executing Docker backend recovery guide", () => { + const markup = renderModal("/v1", true, "", { + databaseContainer: "parsar-agents-api-web-smoke-db", + apiContainer: "agents-core-web-api", + daemonContainer: "agents-core-web-daemon", + corePort: 8091, + }); + + expect(markup).toContain("Start a local Docker backend"); + expect(markup).toContain("docker start parsar-agents-api-web-smoke-db"); + expect(markup).toContain("docker start agents-core-web-api agents-core-web-daemon"); + expect(markup).toContain("http://127.0.0.1:8091/healthz"); + expect(markup).toContain("Already set up on this computer"); + expect(markup).toContain("First time on this computer"); + expect(markup).toContain("make docker-build-agents-api"); + expect(markup).toContain("does not publish a safe zero-input bootstrap"); + expect(markup).toContain("Parsar container setup · pinned revision"); + expect(markup).toContain("Daemon provisioning · pinned revision"); + expect(markup).toContain(`/parsar/blob/dadf64a76bde58255281f3b6c3e939f8b556be09/services/agents-api/CONTAINER.md`); + expect(markup).toContain("Web only displays the reviewed commands"); + expect(markup).toContain("never receives Docker socket access or executes them"); + expect(markup).toContain("A self-hosted executor is Session-specific"); + expect(markup).not.toContain("docker run"); + expect(markup).not.toContain("docker compose down"); + }); + + it("fails closed to configuration help when container names are unavailable", () => { + const markup = renderModal("/v1", true); + + expect(markup).toContain("Docker startup guide is not configured for this Web build"); + expect(markup).toContain("AGENTS_CORE_WEB_DOCKER_BACKEND_*"); + expect(markup).not.toContain("docker start"); + }); + it("states the GET-only probe boundary without disabling the current chat contract", () => { const markup = renderModal("/v1", true); diff --git a/apps/web/src/components/ConnectionModal.tsx b/apps/web/src/components/ConnectionModal.tsx index 4507dcd..e8be783 100644 --- a/apps/web/src/components/ConnectionModal.tsx +++ b/apps/web/src/components/ConnectionModal.tsx @@ -1,4 +1,4 @@ -import { ExternalLink, Info, KeyRound } from "lucide-react"; +import { Check, Container, Copy, ExternalLink, Info, KeyRound } from "lucide-react"; import { useEffect, useId, useRef, useState } from "react"; import { @@ -10,6 +10,7 @@ import { probeCore, type CoreProbeResult, } from "../lib/core-probe"; +import type { LocalDockerBackendGuideProfile } from "../lib/docker-guide-config"; import { Modal } from "./Modal"; import "./ConnectionModal.css"; @@ -17,6 +18,7 @@ interface ConnectionModalProps { connection: CoreConnection; open: boolean; proxyAuthEnabled?: boolean; + dockerBackendGuide?: LocalDockerBackendGuideProfile | null; onClose: () => void; onSave: (connection: CoreConnection) => void; } @@ -28,11 +30,132 @@ export type ConnectionProbeState = | { status: "loading" } | { status: "complete"; result: CoreProbeResult }; -const webBaseline = "98c5b3312ad33e1fae8b94283a011eb3e5f4ee2c"; -const parsarBaseline = "c31f81677a8b16c53b665de9075181df837a0032"; -const operatorGuideUrl = `https://github.com/MiniMax-AI-Dev/agents-core-web/blob/${webBaseline}/docs/core-connection.md`; +const parsarBaseline = "dadf64a76bde58255281f3b6c3e939f8b556be09"; +const operatorGuideUrl = "https://github.com/MiniMax-AI-Dev/agents-core-web/blob/main/docs/core-connection.md"; const troubleshootingUrl = `${operatorGuideUrl}#troubleshooting`; const parsarCoreSetupUrl = `https://github.com/MiniMax-AI-Dev/parsar/blob/${parsarBaseline}/services/agents-api/README.md#standalone-http-service`; +const parsarContainerSetupUrl = `https://github.com/MiniMax-AI-Dev/parsar/blob/${parsarBaseline}/services/agents-api/CONTAINER.md`; +const parsarDaemonSetupUrl = `https://github.com/MiniMax-AI-Dev/parsar/blob/${parsarBaseline}/services/agents-api/README.md#internal-execution-device-connection`; + +function DockerCommand({ label, command }: { label: string; command: string }) { + const [copied, setCopied] = useState(false); + + const copy = async () => { + if (!navigator.clipboard) return; + try { + await navigator.clipboard.writeText(command); + setCopied(true); + window.setTimeout(() => setCopied(false), 1_500); + } catch { + setCopied(false); + } + }; + + return ( +
+ {command} + +
+ ); +} + +function DockerBackendGuide({ profile }: { profile: LocalDockerBackendGuideProfile | null }) { + return ( +
+
+
+ {profile ? ( + <> +
+
+ Already set up on this computer + Start the existing containers +
+
    +
  1. + Start the dedicated databaseWait until its Docker health status is healthy. + +
  2. +
  3. + Start Core API and daemonThe daemon reconnects to Core independently. + +
  4. +
  5. + Verify Core process healthA successful health response is liveness only; use Test connection next. + +
  6. +
+
+
+
+ First time on this computer + Create Core before trying docker start +
+

+ Docker is assumed to be installed. Parsar still requires a dedicated PostgreSQL database, a private + caller principal, migrations, the Core API container, and a provisioned daemon profile. The pinned + Core revision does not publish a safe zero-input bootstrap, so Web will not invent credentials or + create containers with guessed settings. +

+
    +
  1. + Build the Core imageRun from the reviewed Parsar checkout. + +
  2. +
  3. Create the private Core stackPrepare the dedicated database, caller key files, migrations, and API container using the pinned container guide.
  4. +
  5. Provision and connect the daemonIssue a separate device profile; caller keys and daemon credentials are not interchangeable.
  6. +
  7. Return here and testConfigure Web's server-side caller-key file, restart Web, then use Test connection.
  8. +
+ +
+ + ) : ( +
+ Docker startup guide is not configured for this Web build. +

+ Set the non-secret AGENTS_CORE_WEB_DOCKER_BACKEND_* values from .env.example, + then restart Web. Container names are operator configuration and are never guessed in the browser. +

+
+ )} +

+ Existing-stack commands only start saved database/Core/daemon containers. First-time setup remains an + operator action because it creates durable state and credentials. A self-hosted executor is Session-specific + and must be connected from that Session's Environment instructions. +

+
+ ); +} function initialMode(connection: CoreConnection): ConnectionMode { return isLocalProxyBaseUrl(connection.baseUrl) ? "local" : "advanced"; @@ -111,6 +234,7 @@ export function ConnectionModal({ connection, open, proxyAuthEnabled = import.meta.env.DEV && __AGENTS_CORE_WEB_DEV_PROXY_AUTH__, + dockerBackendGuide = __AGENTS_CORE_WEB_DOCKER_BACKEND_GUIDE__, onClose, onSave, }: ConnectionModalProps) { @@ -220,30 +344,33 @@ export function ConnectionModal({ {mode === "local" ? ( -
-
-
-
-
-
API base
-
/v1
-
-
-
Authentication
-
{proxyAuthEnabled ? "Server-managed key detected" : "Server-managed key not detected"}
+ <> +
+
+
-
-

- {proxyAuthEnabled - ? "The Vite proxy supplies its key server-side. No bearer credential is exposed to browser JavaScript." - : "Configure the local proxy token file and restart Web. Local mode will not request a browser token."} -

-
+
+
+
API base
+
/v1
+
+
+
Authentication
+
{proxyAuthEnabled ? "Server-managed key detected" : "Server-managed key not detected"}
+
+
+

+ {proxyAuthEnabled + ? "The Vite proxy supplies its key server-side. No bearer credential is exposed to browser JavaScript." + : "Configure the local proxy token file and restart Web. Local mode will not request a browser token."} +

+ + + ) : (
- Legacy Web guide · 043 snapshot + Web connection guide · current snapshot - Legacy troubleshooting · 043 snapshot + Connection troubleshooting · current snapshot diff --git a/apps/web/src/features/dashboard/DashboardView.css b/apps/web/src/features/dashboard/DashboardView.css index bbf11f9..999874c 100644 --- a/apps/web/src/features/dashboard/DashboardView.css +++ b/apps/web/src/features/dashboard/DashboardView.css @@ -116,6 +116,58 @@ border-top: 1px solid color-mix(in srgb, var(--danger) 22%, var(--line)); } +.dashboard-backend-recovery { + display: grid; + width: 100%; + min-width: 0; + padding: 2px 0; + grid-template-columns: auto minmax(0, 1fr) auto; + align-items: center; + gap: 10px; + color: var(--danger); + text-align: left; + background: transparent; + border: 0; + border-radius: 5px; +} + +.dashboard-backend-recovery > span:nth-child(2) { + display: grid; + min-width: 0; + gap: 1px; +} + +.dashboard-backend-recovery strong { + font-size: 12px; + font-weight: 650; + line-height: 17px; +} + +.dashboard-backend-recovery small { + color: color-mix(in srgb, var(--danger) 82%, var(--fg-muted)); + font-size: 10px; + line-height: 15px; +} + +.dashboard-backend-recovery-action { + display: flex; + align-items: center; + gap: 5px; + font-size: 11px; + font-weight: 600; + white-space: nowrap; +} + +.dashboard-backend-recovery:hover, +.dashboard-backend-recovery:focus-visible { + background: color-mix(in srgb, var(--danger) 9%, transparent); +} + +.dashboard-backend-recovery:focus-visible { + outline: 2px solid color-mix(in srgb, var(--danger) 45%, transparent); + outline-offset: 2px; +} + .dashboard-snapshot-error-copy, .dashboard-connection-action { display: flex; @@ -595,6 +647,14 @@ flex-direction: column; } + .dashboard-backend-recovery { + grid-template-columns: auto minmax(0, 1fr); + } + + .dashboard-backend-recovery-action { + grid-column: 2; + } + .dashboard-connection-action { margin-left: 19px; } diff --git a/apps/web/src/features/dashboard/DashboardView.test.tsx b/apps/web/src/features/dashboard/DashboardView.test.tsx index 85d54e4..f7c617b 100644 --- a/apps/web/src/features/dashboard/DashboardView.test.tsx +++ b/apps/web/src/features/dashboard/DashboardView.test.tsx @@ -102,6 +102,25 @@ describe("Dashboard loaded-result presentation", () => { expect(unavailable).not.toContain("Using the last successful snapshot"); }); + it("turns local proxy gateway failures into an explicit, clickable backend recovery path", () => { + const html = render({ + agentCollectionState: "failed", + agentCollectionError: "Agent core request failed (502).", + agentCollectionHasSnapshot: false, + sessionCollectionState: "failed", + sessionCollectionError: "Agent core request failed (502).", + sessionCollectionHasSnapshot: false, + }); + + expect(html).toContain("Agent Core backend is not ready"); + expect(html).toContain("local `/v1` proxy cannot reach a ready Core (HTTP 502)"); + expect(html).toContain("Start the Docker backend, then test the connection"); + expect(html).toContain("Open startup guide"); + expect(html).toContain('aria-label="Agent Core backend is not ready. Open Docker startup guide"'); + expect(html).not.toContain("Agents: Agent core request failed (502)"); + expect(html).not.toContain("Sessions: Agent core request failed (502)"); + }); + it("renders a compact actionable overview while preserving Environment qualifications", () => { const selfHosted: AgentSession["environment"] = { type: "self_hosted", diff --git a/apps/web/src/features/dashboard/DashboardView.tsx b/apps/web/src/features/dashboard/DashboardView.tsx index 4034897..c2cd978 100644 --- a/apps/web/src/features/dashboard/DashboardView.tsx +++ b/apps/web/src/features/dashboard/DashboardView.tsx @@ -11,6 +11,7 @@ import { useMemo, type ReactNode } from "react"; import type { AgentSession, SavedAgent } from "@agents-core-web/agents-client"; import { StatusIcon, type StatusKind } from "../../components/StatusIcon"; +import { backendFailureStatus } from "../../lib/core-readiness"; import { buildDashboardSnapshot, dashboardEnvironmentLabel, @@ -253,6 +254,11 @@ export function DashboardView({ agentCollectionState === "failed" ? ["Agents", agentCollectionError] as const : null, sessionCollectionState === "failed" ? ["Sessions", sessionCollectionError] as const : null, ].filter((entry): entry is readonly ["Agents" | "Sessions", string | null] => entry !== null); + const backendFailureStatuses = sourceErrors.map(([, error]) => backendFailureStatus(error)); + const backendUnavailable = sourceErrors.length > 0 && backendFailureStatuses.every(Boolean); + const backendFailureDetail = Array.from(new Set(backendFailureStatuses.filter(Boolean))).map((status) => ( + status === "network" ? "network failure" : `HTTP ${status}` + )).join(" / "); const snapshotTitle = hasUnavailableSource ? "Snapshot incomplete" : hasStaleSnapshot @@ -303,16 +309,40 @@ export function DashboardView({ {sourceErrors.length ? (
- - - + {backendUnavailable ? ( + + ) : ( + <> + + + + + )}
) : null} diff --git a/apps/web/src/lib/core-readiness.test.ts b/apps/web/src/lib/core-readiness.test.ts new file mode 100644 index 0000000..cbe14f6 --- /dev/null +++ b/apps/web/src/lib/core-readiness.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from "vitest"; + +import { AgentCoreError } from "@agents-core-web/agents-client"; + +import { backendFailureStatus } from "./core-readiness"; + +describe("backendFailureStatus", () => { + it.each([502, 503, 504] as const)("classifies HTTP %s as backend unavailable", (status) => { + expect(backendFailureStatus(new AgentCoreError("Core request failed.", status))).toBe(String(status)); + }); + + it("classifies browser network failures without treating unrelated errors as readiness failures", () => { + expect(backendFailureStatus(new TypeError("Failed to fetch"))).toBe("network"); + expect(backendFailureStatus("NetworkError when attempting to fetch resource.")).toBe("network"); + expect(backendFailureStatus(new AgentCoreError("Request rejected.", 400))).toBeNull(); + expect(backendFailureStatus(new Error("Agent core request failed (500)."))).toBeNull(); + }); +}); diff --git a/apps/web/src/lib/core-readiness.ts b/apps/web/src/lib/core-readiness.ts new file mode 100644 index 0000000..7202511 --- /dev/null +++ b/apps/web/src/lib/core-readiness.ts @@ -0,0 +1,24 @@ +import { AgentCoreError } from "@agents-core-web/agents-client"; + +export type BackendFailureStatus = "502" | "503" | "504" | "network"; + +export const BACKEND_NOT_READY_NOTICE = + "Agent Core backend is not ready. Open the Dashboard startup guide."; + +export function backendFailureStatus(error: unknown): BackendFailureStatus | null { + if ( + error instanceof AgentCoreError && + (error.status === 502 || error.status === 503 || error.status === 504) + ) { + return String(error.status) as BackendFailureStatus; + } + + const message = typeof error === "string" + ? error + : error instanceof Error + ? error.message + : ""; + const gatewayStatus = message.match(/\((502|503|504)\)/)?.[1]; + if (gatewayStatus) return gatewayStatus as BackendFailureStatus; + return /failed to fetch|networkerror/i.test(message) ? "network" : null; +} diff --git a/apps/web/src/lib/docker-guide-config.test.ts b/apps/web/src/lib/docker-guide-config.test.ts index 4177ef7..82db6e2 100644 --- a/apps/web/src/lib/docker-guide-config.test.ts +++ b/apps/web/src/lib/docker-guide-config.test.ts @@ -1,6 +1,9 @@ import { describe, expect, it } from "vitest"; -import { loadLocalDockerGuideProfile } from "./docker-guide-config"; +import { + loadLocalDockerBackendGuideProfile, + loadLocalDockerGuideProfile, +} from "./docker-guide-config"; const valid = { AGENTS_CORE_WEB_DOCKER_GUIDE: "1", @@ -46,3 +49,47 @@ describe("local Docker guide configuration", () => { expect(() => loadLocalDockerGuideProfile(partial)).toThrow("AGENTS_CORE_WEB_DOCKER_RUNTIME_HOME_PATH is required"); }); }); + +const validBackend = { + AGENTS_CORE_WEB_DOCKER_BACKEND_GUIDE: "1", + AGENTS_CORE_WEB_DOCKER_DATABASE_CONTAINER: "parsar-agents-api-web-smoke-db", + AGENTS_CORE_WEB_DOCKER_API_CONTAINER: "agents-core-web-api", + AGENTS_CORE_WEB_DOCKER_DAEMON_CONTAINER: "agents-core-web-daemon", + AGENTS_CORE_WEB_DOCKER_CORE_PORT: "8091", +}; + +describe("local Docker backend guide configuration", () => { + it("is disabled unless the operator explicitly opts in", () => { + expect(loadLocalDockerBackendGuideProfile({})).toBeNull(); + expect(loadLocalDockerBackendGuideProfile({ + ...validBackend, + AGENTS_CORE_WEB_DOCKER_BACKEND_GUIDE: "true", + })).toBeNull(); + }); + + it("accepts only non-secret container names and a loopback Core port", () => { + expect(loadLocalDockerBackendGuideProfile(validBackend)).toEqual({ + databaseContainer: "parsar-agents-api-web-smoke-db", + apiContainer: "agents-core-web-api", + daemonContainer: "agents-core-web-daemon", + corePort: 8091, + }); + }); + + it("fails closed for incomplete or command-bearing configuration", () => { + expect(() => loadLocalDockerBackendGuideProfile({ + ...validBackend, + AGENTS_CORE_WEB_DOCKER_DAEMON_CONTAINER: "daemon; docker rm victim", + })).toThrow("safe Docker container name"); + expect(() => loadLocalDockerBackendGuideProfile({ + ...validBackend, + AGENTS_CORE_WEB_DOCKER_CORE_PORT: "70000", + })).toThrow("valid TCP port"); + + const partial: Record = { ...validBackend }; + delete partial.AGENTS_CORE_WEB_DOCKER_DATABASE_CONTAINER; + expect(() => loadLocalDockerBackendGuideProfile(partial)).toThrow( + "AGENTS_CORE_WEB_DOCKER_DATABASE_CONTAINER is required", + ); + }); +}); diff --git a/apps/web/src/lib/docker-guide-config.ts b/apps/web/src/lib/docker-guide-config.ts index 10e7861..d54baa1 100644 --- a/apps/web/src/lib/docker-guide-config.ts +++ b/apps/web/src/lib/docker-guide-config.ts @@ -6,14 +6,25 @@ export interface LocalDockerGuideProfile { runtimeHomePath: string; } +export interface LocalDockerBackendGuideProfile { + databaseContainer: string; + apiContainer: string; + daemonContainer: string; + corePort: number; +} + const dockerImagePattern = /^[A-Za-z0-9][A-Za-z0-9._/:@-]*$/; const dockerContainerPattern = /^[A-Za-z0-9][A-Za-z0-9_.-]*$/; const dockerUserPattern = /^[1-9][0-9]*:[1-9][0-9]*$/; const homePathSegmentPattern = /^[A-Za-z0-9._-]+$/; -function required(env: Record, name: string): string { +function required( + env: Record, + name: string, + feature = "AGENTS_CORE_WEB_DOCKER_GUIDE", +): string { const value = env[name]; - if (!value) throw new Error(`${name} is required when AGENTS_CORE_WEB_DOCKER_GUIDE=1.`); + if (!value) throw new Error(`${name} is required when ${feature}=1.`); return value; } @@ -56,3 +67,37 @@ export function loadLocalDockerGuideProfile( return profile; } + +function validPort(value: string): number | null { + if (!/^[1-9][0-9]{0,4}$/.test(value)) return null; + const port = Number(value); + return port <= 65_535 ? port : null; +} + +export function loadLocalDockerBackendGuideProfile( + env: Record, +): LocalDockerBackendGuideProfile | null { + if (env.AGENTS_CORE_WEB_DOCKER_BACKEND_GUIDE !== "1") return null; + + const feature = "AGENTS_CORE_WEB_DOCKER_BACKEND_GUIDE"; + const databaseContainer = required(env, "AGENTS_CORE_WEB_DOCKER_DATABASE_CONTAINER", feature); + const apiContainer = required(env, "AGENTS_CORE_WEB_DOCKER_API_CONTAINER", feature); + const daemonContainer = required(env, "AGENTS_CORE_WEB_DOCKER_DAEMON_CONTAINER", feature); + const corePortValue = required(env, "AGENTS_CORE_WEB_DOCKER_CORE_PORT", feature); + const corePort = validPort(corePortValue); + + for (const [name, value] of [ + ["AGENTS_CORE_WEB_DOCKER_DATABASE_CONTAINER", databaseContainer], + ["AGENTS_CORE_WEB_DOCKER_API_CONTAINER", apiContainer], + ["AGENTS_CORE_WEB_DOCKER_DAEMON_CONTAINER", daemonContainer], + ] as const) { + if (!dockerContainerPattern.test(value)) { + throw new Error(`${name} is not a safe Docker container name.`); + } + } + if (corePort === null) { + throw new Error("AGENTS_CORE_WEB_DOCKER_CORE_PORT must be a valid TCP port."); + } + + return { databaseContainer, apiContainer, daemonContainer, corePort }; +} diff --git a/apps/web/src/vite-env.d.ts b/apps/web/src/vite-env.d.ts index 5b4cfed..2425b1f 100644 --- a/apps/web/src/vite-env.d.ts +++ b/apps/web/src/vite-env.d.ts @@ -11,6 +11,12 @@ declare const __AGENTS_CORE_WEB_DOCKER_GUIDE__: null | { readonly credentialsHomePath: string; readonly runtimeHomePath: string; }; +declare const __AGENTS_CORE_WEB_DOCKER_BACKEND_GUIDE__: null | { + readonly databaseContainer: string; + readonly apiContainer: string; + readonly daemonContainer: string; + readonly corePort: number; +}; interface ImportMetaEnv { readonly VITE_AGENT_MODEL_PRESETS?: string; diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index f2e5b5f..a108f4b 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -4,7 +4,10 @@ import { defineConfig, loadEnv } from "vite"; import react from "@vitejs/plugin-react"; import { fileURLToPath } from "node:url"; -import { loadLocalDockerGuideProfile } from "./src/lib/docker-guide-config.ts"; +import { + loadLocalDockerBackendGuideProfile, + loadLocalDockerGuideProfile, +} from "./src/lib/docker-guide-config.ts"; import { loadProxyBearerAuth } from "./vite-auth.ts"; const repositoryRoot = fileURLToPath(new URL("../..", import.meta.url)); @@ -16,6 +19,7 @@ export default defineConfig(({ command, mode }) => { const openAIHostedSessionsEnabled = env.AGENTS_CORE_WEB_OPENAI_HOSTED_SESSIONS === "1"; const environmentFilesEnabled = env.AGENTS_CORE_WEB_ENVIRONMENT_FILES === "1"; const localDockerGuide = loadLocalDockerGuideProfile(env); + const localDockerBackendGuide = loadLocalDockerBackendGuideProfile(env); const proxyAuth = command === "serve" && mode !== "test" ? loadProxyBearerAuth({ token: env.AGENTS_API_PROXY_TOKEN, @@ -31,6 +35,7 @@ export default defineConfig(({ command, mode }) => { __AGENTS_CORE_WEB_OPENAI_HOSTED_SESSIONS__: JSON.stringify(openAIHostedSessionsEnabled), __AGENTS_CORE_WEB_ENVIRONMENT_FILES__: JSON.stringify(environmentFilesEnabled), __AGENTS_CORE_WEB_DOCKER_GUIDE__: JSON.stringify(localDockerGuide), + __AGENTS_CORE_WEB_DOCKER_BACKEND_GUIDE__: JSON.stringify(localDockerBackendGuide), }, envDir: repositoryRoot, plugins: [react()], diff --git a/docs/core-connection.md b/docs/core-connection.md index df40c21..bc2a02e 100644 --- a/docs/core-connection.md +++ b/docs/core-connection.md @@ -74,6 +74,43 @@ SDK loop or call the Responses API as its Core transport. Creating an Agent persists configuration only. It does not prove that a daemon, model, or provider credential can execute it. +### Web-guided recovery for a local Docker stack + +When the Dashboard receives HTTP `502`, `503`, or `504` while loading both top-level +collections, it presents **Agent Core backend is not ready** instead of repeating the +raw collection errors. The complete recovery notice opens the connection panel. + +For an operator-controlled stack whose containers already exist, the connection panel +renders copyable commands to start its dedicated database, Core API, and daemon, then +probe the loopback `/healthz` endpoint. The same panel now separates a prominent +**First time on this computer** path: it shows the source-grounded Core image build +command and immutable links to Parsar's container and daemon provisioning guides. + +The pinned Parsar revision has no safe zero-input bootstrap. First-time setup still +requires an operator to create a dedicated PostgreSQL database, generate a private +caller principal, run migrations, create the API container, and issue a distinct daemon +device profile. Web does not invent those durable identities or secrets. Enable the +local presentation profile in the Web server environment: + +```dotenv +AGENTS_CORE_WEB_DOCKER_BACKEND_GUIDE=1 +AGENTS_CORE_WEB_DOCKER_DATABASE_CONTAINER=parsar-agents-api-web-smoke-db +AGENTS_CORE_WEB_DOCKER_API_CONTAINER=agents-core-web-api +AGENTS_CORE_WEB_DOCKER_DAEMON_CONTAINER=agents-core-web-daemon +AGENTS_CORE_WEB_DOCKER_CORE_PORT=8091 +``` + +Use the actual non-secret container names for the reviewed local stack and restart Web +after changing them. The values are compiled into the local browser bundle. Strict +container-name and TCP-port validation prevents them from becoming arbitrary shell +fragments. Web does not access the Docker socket, execute a command, create a container, +run migrations, issue a credential, or infer container readiness. It assumes Docker is +already installed and gives the operator the pinned first-time path when containers are +missing. A successful +`/healthz` call is process liveness only; the operator must still use **Test connection** +for the authenticated Agents API read. A Session-specific `self_hosted` executor is +not part of this backend command and keeps its separate Environment connection flow. + ## Prerequisites For the source-based local path below, install: diff --git a/docs/protocol-coverage.md b/docs/protocol-coverage.md index c63cadf..9f8c623 100644 --- a/docs/protocol-coverage.md +++ b/docs/protocol-coverage.md @@ -377,6 +377,15 @@ upstream. reports that page 101 is required, the refresh fails closed and does not publish the partial result. Dashboard performs no additional Turn, Item, Environment, or execution-readiness requests and makes no writes. +- When both top-level collection reads fail through a gateway/network condition, + Dashboard labels the local Agent Core backend as not ready and links the whole + notice to connection recovery. An explicitly configured local Docker guide may + display validated, non-secret `docker start` and loopback `/healthz` commands for + pre-existing database/Core/daemon containers. The same panel distinguishes first-time + setup and links the pinned Parsar container/daemon guides plus its non-secret image + build command. Web never executes those commands, accesses the Docker socket, creates + containers, invents credentials, or treats process health as authenticated API or + execution readiness. - A ready Agent or Session result means pagination reached Core's end marker within that safety limit. Loaded counts are exact only for that published page-chain result. Pages may change while they are traversed, so neither count is an atomic diff --git a/playwright.config.ts b/playwright.config.ts index 85e32dc..e798b04 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -33,7 +33,7 @@ export default defineConfig({ stderr: "pipe", }, { - command: `AGENTS_CORE_WEB_SELF_HOSTED_SESSIONS=1 AGENTS_CORE_WEB_OPENAI_HOSTED_SESSIONS=1 AGENTS_CORE_WEB_ENVIRONMENT_FILES=1 AGENTS_API_PROXY_TARGET=http://127.0.0.1:${fixturePort} node node_modules/vite/bin/vite.js apps/web --host 127.0.0.1 --mode test --port ${webPort}`, + command: `AGENTS_CORE_WEB_SELF_HOSTED_SESSIONS=1 AGENTS_CORE_WEB_OPENAI_HOSTED_SESSIONS=1 AGENTS_CORE_WEB_ENVIRONMENT_FILES=1 AGENTS_CORE_WEB_DOCKER_BACKEND_GUIDE=1 AGENTS_CORE_WEB_DOCKER_DATABASE_CONTAINER=parsar-agents-api-web-smoke-db AGENTS_CORE_WEB_DOCKER_API_CONTAINER=agents-core-web-api AGENTS_CORE_WEB_DOCKER_DAEMON_CONTAINER=agents-core-web-daemon AGENTS_CORE_WEB_DOCKER_CORE_PORT=8091 AGENTS_API_PROXY_TARGET=http://127.0.0.1:${fixturePort} node node_modules/vite/bin/vite.js apps/web --host 127.0.0.1 --mode test --port ${webPort}`, url: `http://127.0.0.1:${webPort}`, reuseExistingServer, timeout: 30_000,