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
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,16 @@ All notable changes to **omp-web** (`@kahme247/ompweb`) are documented in this f

## [Unreleased]

## [v0.7.1] - 2026-11-14

### Fixed

- **Upgrade v10 and v11 hosts from Settings again.** Update the web server first, then use **Settings → Hosts → Update**. An isolated shim welcomes old daemons as their own version and speaks only their existing update contract; chat, workspace operations and old session frames remain disabled. Native busy-host, preflight and restart safeguards are unchanged. Shared installations use the existing restart-only update after the server's files are replaced. An old daemon that already exited needs starting once.

### Added

- **Maintenance API v1, independent of application Protocol v12.** A separate host-token-authenticated connection provides status, session inventory and activity, capabilities and software updates even when application protocols differ. Session traffic uses Protocol v12; future application incompatibility no longer needs to disconnect maintenance. Reverse proxies must forward `/api/hosts/maintenance` WebSocket upgrades as well as `/api/hosts/link`.

## [v0.7.0] - 2026-11-14

### Breaking changes
Expand Down
24 changes: 20 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -698,10 +698,26 @@ protocol is blocked too. omp is part of each ompweb release and updates with it.
Before updating a daemon, close its session processes: the updater refuses to
interrupt sessions or subagents. **Settings → System** keeps the web server's
own application update and service settings.
Hosts need a daemon build that supports these update operations; older builds
show an unsupported message and need one manual update first. If the daemon
shares its installation with the web server, update the server first, then use
the host's restart-only update. Unsupported service wrappers and detected restart

**From 0.7.1 onward, maintenance API v1 is separate from application Protocol v12.**
The daemon connects to `/api/hosts/maintenance` with the same host token; status,
session inventory and activity, capabilities, update checks and installation do not
require a compatible application connection. An application mismatch still blocks
chat, workspace operations and session attachment, not maintenance.

**Upgrading v10 and v11 daemons:** update the web server to 0.7.1 first, then use
**Settings → Hosts → Update**. An upgrade-only shim welcomes each old daemon using
its own protocol and speaks only its existing updater's requests. It never adopts
or stops its sessions, and the old updater still refuses a busy host. Close those
session processes before the cutover. If server and daemon share an installation,
the server update puts the new files in place and the host update only restarts
the daemon. A daemon that already exited after an earlier version rejection needs
starting once; an exited process cannot be reached by the shim.

Reverse proxies must forward WebSocket upgrades for `/api/hosts/maintenance`
without an additional authentication prompt, just as for `/api/hosts/link`.
Daemons below v10 or without the existing update operations need one manual update.
Unsupported service wrappers and detected restart
scripts show a manual-update reason. Stop external restart loops before updating
a foreground daemon.

Expand Down
16 changes: 16 additions & 0 deletions app/api/hosts/[id]/maintenance/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { getHost } from "@/lib/hosts/hub";
import { MaintenanceError, requestMaintenance } from "@/lib/hosts/maintenance-hub";

/** Browser access stays behind the web gate; no application connection is needed. */
export async function GET(request: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const headers = { "cache-control": "no-store" };
if (!getHost(id)) return Response.json({ error: "Unknown host", code: "host_not_found" }, { status: 404, headers });
try {
const op = new URL(request.url).searchParams.get("sessions") === "1" ? "listSessions" : "status";
return Response.json(await requestMaintenance(id, op, {}), { headers });
} catch (error) {
if (error instanceof MaintenanceError) return Response.json({ error: error.message, code: error.code }, { status: error.status, headers });
return Response.json({ error: "The host maintenance request failed", code: "maintenance_failed" }, { status: 500, headers });
}
}
8 changes: 8 additions & 0 deletions app/api/hosts/maintenance/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { handleMaintenanceUpgrade, type MaintenanceSocketData } from "@/lib/hosts/maintenance-hub";
import { LINK_PROTOCOL_VERSION } from "@/lib/hosts/link-protocol";
import packageJson from "@/package.json";

/** API v1 discovery and its independent host-token-authenticated WebSocket. */
export function GET(request: Request, { upgrade }: { upgrade: (data: MaintenanceSocketData) => boolean }) {
return handleMaintenanceUpgrade(request, upgrade, { protocolVersion: LINK_PROTOCOL_VERSION, ompwebVersion: packageJson.version });
}
83 changes: 83 additions & 0 deletions components/HostUpdates.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import "../tests/setup-dom.mjs";
import assert from "node:assert/strict";
import test from "node:test";
import { act, createElement } from "react";
import { createRoot } from "react-dom/client";
import { HostUpdates } from "./HostUpdates";

const endpoint = "/api/hosts/box/updates";
const info = {
ompweb: { currentVersion: "0.7.0", availableVersion: "0.7.1", updateAvailable: true, canUpdate: true, blockedReason: null, updateCommand: "npm install -g @kahme247/ompweb", installMethod: "npm", protocolVersion: 12 },
omp: { currentVersion: "18.20.0" },
operation: null,
};

/** Render the real host row and record every request it sends. */
async function renderHost(t, host) {
const requests = [];
const originalFetch = globalThis.fetch;
globalThis.fetch = async (url, init) => {
requests.push({ url: String(url), method: init?.method ?? "GET", body: init?.body });
if (init?.method === "POST") return Response.json({ accepted: true, operation: { state: "running", target: "ompweb", version: "0.7.1" } });
return Response.json(info);
};
const container = document.createElement("div");
document.body.append(container);
const root = createRoot(container);
t.after(() => {
act(() => root.unmount());
container.remove();
globalThis.fetch = originalFetch;
});
await act(async () => {
root.render(createElement(HostUpdates, { host }));
});
await act(() => new Promise((resolve) => setTimeout(resolve, 20)));
return { container, requests };
}

const button = (root, label) => [...root.querySelectorAll("button")].find((candidate) => candidate.textContent.trim() === label);

test("an upgrade-only legacy host can check for and submit an ompweb update", async (t) => {
const { container, requests } = await renderHost(t, { id: "box", label: "Box", status: "needs-upgrade", upgradeOnly: true, ompVersion: "omp/18.20.0", ompwebVersion: "0.7.0" });
assert.equal(requests[0]?.url, endpoint, "the status is read on mount");
assert.equal(button(container, "Check updates").disabled, false);

await act(async () => button(container, "Check updates").click());
await act(() => new Promise((resolve) => setTimeout(resolve, 20)));
assert.ok(requests.some((request) => request.url === `${endpoint}?force=1` && request.method === "GET"), requests.map((request) => request.url).join(", "));

act(() => button(container, "Update").click());
const confirm = [...document.querySelectorAll(".dialog-actions button")].find((candidate) => candidate.textContent.trim() === "Update");
assert.ok(confirm, "the update confirmation did not open");
await act(async () => confirm.click());
await act(() => new Promise((resolve) => setTimeout(resolve, 20)));
const post = requests.find((request) => request.method === "POST");
assert.ok(post, "the update was not submitted");
assert.deepEqual(JSON.parse(post.body), { target: "ompweb", version: "0.7.1" });
});

test("a connected maintenance API can update a compatible host while its application connection is offline", async (t) => {
const { container, requests } = await renderHost(t, { id: "box", label: "Box", status: "offline", apiVersion: 1, hostVersion: 12, ompwebVersion: "0.7.0" });
assert.equal(requests[0]?.url, endpoint);
assert.equal(button(container, "Check updates").disabled, false);
assert.ok(button(container, "Update"));
});

test("an ordinary incompatible host cannot be checked or updated", async (t) => {
const { container, requests } = await renderHost(t, { id: "box", label: "Box", status: "needs-upgrade", ompVersion: "omp/18.20.0", ompwebVersion: "0.6.0" });
assert.deepEqual(requests, [], "an unavailable host must not be read");
const check = button(container, "Check updates");
assert.equal(check.disabled, true);
act(() => check.click());
assert.equal(button(container, "Update"), undefined);
assert.ok(container.textContent.includes("This host daemon does not support updates"), container.textContent);
assert.deepEqual(requests, []);
});

test("an offline host stays unavailable", async (t) => {
const { container, requests } = await renderHost(t, { id: "box", label: "Box", status: "offline", ompVersion: "omp/18.20.0", ompwebVersion: "0.7.0" });
assert.deepEqual(requests, []);
assert.equal(button(container, "Check updates").disabled, true);
assert.ok(container.textContent.includes("Updates unavailable while this host is offline."), container.textContent);
});
5 changes: 3 additions & 2 deletions components/HostUpdates.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,8 @@ export function HostUpdates({ host }: { host: HostRow }) {
const mounted = useRef(true);
const postController = useRef<AbortController | null>(null);
const endpoint = `/api/hosts/${encodeURIComponent(host.id)}/updates`;
const unavailable = host.status === "offline" || host.status === "needs-upgrade";
const upgradeOnly = host.status === "needs-upgrade" && host.upgradeOnly === true;
const unavailable = (host.status === "offline" || host.status === "needs-upgrade") && !upgradeOnly && host.apiVersion !== 1;

useEffect(() => {
mounted.current = true;
Expand Down Expand Up @@ -164,7 +165,7 @@ export function HostUpdates({ host }: { host: HostRow }) {

const busy = loading || posting || monitoring;
const disabled = busy || unavailable || unsupported;
const unavailableReason = host.status === "needs-upgrade" || unsupported ? t("hostUpdates.unsupported") : host.status === "offline" ? t("hostUpdates.offline") : null;
const unavailableReason = (host.status === "needs-upgrade" && unavailable) || unsupported ? t("hostUpdates.unsupported") : host.status === "offline" && unavailable ? t("hostUpdates.offline") : null;
const operation = info?.operation;
return (
<div style={{ flexBasis: "100%", minWidth: 0, borderTop: "1px solid var(--border)", paddingTop: 12, display: "flex", flexDirection: "column", gap: 10 }}>
Expand Down
72 changes: 38 additions & 34 deletions docs/specs/2026-10-28-remote-hosts-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -315,15 +315,15 @@ that upload's file.
ops; 9: `open.launch.shareCredentials`; 10: `open.launch.configVersion`,
`config_state`, the `clusterConfig` op; 11: channel lines are the omp
runner's `{t}` frames, not omp RPC frames). Main links daemons from
`MIN_LINK_PROTOCOL_VERSION` (11) up to its own `LINK_PROTOCOL_VERSION` (11);
its `welcome` carries the version the link speaks (see "Protocol versions and
compatibility"). A hello whose `v` is outside that range (checked on the raw
JSON before decoding) gets close 4426 with reason `{"serverVersion":11}`; the
daemon exits 1, telling which side to
update ("update ompweb on this host", or "this host's ompweb is newer than
the server's: update ompweb on the server" when `serverVersion` is below its
own); Settings → Hosts shows "needs upgrade (host vN, server vM)" unless the
host has a healthy socket.
`MIN_LINK_PROTOCOL_VERSION` (12) up to its own `LINK_PROTOCOL_VERSION` (12).
A v10 or v11 daemon is welcomed with its own version by an upgrade-only shim,
outside that application range. The shim only sends `checkUpdates` and
`updateSoftware`; it acknowledges and discards application activity, never
adopts or kills sessions, and never starts cluster configuration synchronization.
All other incompatible versions get close 4426 with `{"serverVersion":12}`.
New daemons retain their separate maintenance API connection while an
application mismatch is retried. Old daemons may exit on rejection; an
already-exited daemon needs starting once.
- **Identity.** The host is the record whose `tokenHash` matches. Nothing the
daemon sends selects or changes its identity.
- **One link per host.** A hello on a new socket with the same token closes the
Expand Down Expand Up @@ -404,21 +404,15 @@ the `spawn` op are gone).
message being understood unless the version (or, later, a capability) says
so. Tolerance starts with v3: a daemon older than that is strict (an
unknown type or op makes it refuse the line).
- **Older versions stay supported** from the next version on, until supporting
one becomes too much of a maintenance burden; dropping one is an explicit
decision (raising `MIN_LINK_PROTOCOL_VERSION`, which is 11 today). In
practice main keeps at least version N-1. For an older daemon main decodes
its hello into today's shape and sends only messages that version
understands (to a v4 daemon: no `part`, every line whole, and no unacked
window, since it acks only inside its next uplink frame).
A daemon on a dropped version gets 426
(a POST) or close 4426 (a hello frame). Dropping versions ends with the
decision below.
- **Upgrade order: web app first, then hosts.** Main supports older daemons; a
daemon is not required to support a newer main, and one newer than main gets
close 4426.
`hostVersion` (Settings → Hosts) is the version a linked daemon speaks,
so Settings can later show "update available" for one behind the server.
- **Application compatibility is separate from maintenance compatibility.**
Dropping an application version raises `MIN_LINK_PROTOCOL_VERSION` without
dropping maintenance API v1. The current application range is 12–12.
Legacy v10/v11 are admitted only by the update shim, not the session runtime.
- **Upgrade order: web app first, then hosts.** The shim passes the new server's
application range to the old daemon's unchanged updater, while its `welcome`
echoes the old version. The updater can install Protocol v12 without needing
to understand it. Existing safety checks still refuse busy hosts and unsafe
supervisors; a shared installation uses restart-only after updating the server.
- **v4 (the WebSocket link) dropped v3** before any release; from the next
version on, main keeps N-1. A v3 daemon POSTs its hello to
`/api/hosts/link`; main answers that POST 426 with both versions (and never
Expand All @@ -443,16 +437,26 @@ the `spawn` op are gone).
a channel; both sides refuse one with 426 / close 4426 instead of hanging.
Prerelease builds already shipped v10 with omp RPC frames, so the runner could
not reuse 10.
- **Decided: backward compatibility becomes enforced once the WebSocket
experiments end.** Until then (the browser transport experiment and the
host-link measurements), versions may still be dropped as above. Call N the
first link protocol version released after them. From N on the guarantees
are enforced, so the web app can always update an old host from Settings →
Hosts: the handshake (`hello`/`welcome` and the version close codes), the
version report, and an update op form a fixed core that every later web app
keeps working with every daemon on N or newer. `MIN_LINK_PROTOCOL_VERSION`
never goes above N. This replaces the update-only mode that was planned
for dropped versions.
- **Maintenance API v1 is a separate, stable contract.** New daemons discover
`/api/hosts/maintenance` with the same bearer token, then open a separate
WebSocket. Its `hello`/`welcome` negotiate `apiVersion: 1` independently of the
advisory `protocolVersion`. Its codec and transport do not import the
application protocol. A protocol mismatch cannot terminate this connection.
Requests have bounded IDs and explicit replies; disconnects and timeouts never
cause automatic replay of an update. The daemon remains authoritative for
whether it is safe to update.
- `status`: software/API/protocol versions, capabilities, workspace-operation
availability, session-process inventory and activity, drained/busy state and
the durable update operation.
- `listSessions`: session metadata, including `processRunning` and `active`;
no transcript or application frame decoding.
- `checkUpdates` and `updateSoftware`: the native updater and its safeguards.
- The web-authenticated `GET /api/hosts/[id]/maintenance` exposes status;
`?sessions=1` exposes inventory. Existing host update routes prefer API v1
when available, otherwise use the legacy shim.
- Revoke and shutdown close both connections. API v1 remains supported across
future application-protocol changes; incompatible maintenance changes need
a separately negotiated API version.

## Host ops (this sub-project)

Expand Down
6 changes: 6 additions & 0 deletions lib/hosts/daemon-channels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,12 @@ export class ChannelTable {
for (const channel of this.channels.values()) if (channel.lifecycle?.activeTurn ?? channel.pending.activeTurn) active++;
return active;
}
maintenanceSessions(): { id: string | null; cwd: string; active: boolean }[] {
return [...this.channels.values()].map(channel => ({
id: channel.pending.sessionId, cwd: channel.cwd,
active: (channel.lifecycle?.isRunning ?? channel.pending.activeTurn) || channel.resuming === true,
}));
}
cwdForSession(id: string): string | undefined {
for (const channel of this.channels.values()) if (channel.pending.sessionId === id) return channel.cwd;
return undefined;
Expand Down
Loading
Loading