diff --git a/src/features/agents/functions.ts b/src/features/agents/functions.ts
index ff2daa6..0214729 100644
--- a/src/features/agents/functions.ts
+++ b/src/features/agents/functions.ts
@@ -36,9 +36,11 @@ const result = (
),
);
-export const getAgents = createServerFn({ method: "GET" })
- .middleware([available])
- .handler(() => result(listAgents()));
+// Read-only navigation remains available during update drain. Full startup gates
+// still reject ordinary HTTP before any loader runs.
+export const getAgents = createServerFn({ method: "GET" }).handler(() =>
+ result(listAgents()),
+);
export const getConnection = createServerFn({ method: "GET" })
.middleware([available])
diff --git a/src/features/chat/functions.ts b/src/features/chat/functions.ts
index 2f6a5c4..e889a22 100644
--- a/src/features/chat/functions.ts
+++ b/src/features/chat/functions.ts
@@ -3,9 +3,11 @@ import { setResponseHeader } from "@tanstack/react-start/server";
import { Effect, Schema } from "effect";
import { withAgentStore } from "../../server/agents/store.server";
import { available } from "../../server/available";
+import { isMaintenance } from "../../server/maintenance.server";
import { readConversationSnapshot } from "../../server/runs/conversation-snapshot.server";
import { cancelRun, enqueueChat } from "../../server/runs/store.server";
import { ensureTimeline, startWorker } from "../../server/runs/worker.server";
+import { appGate } from "../../updater/gate";
import { SendMessage } from "./schema";
const result = (effect: Effect.Effect) =>
@@ -33,7 +35,6 @@ export type InitialConversation = Awaited<
>;
export const getConversation = createServerFn({ method: "GET" })
- .middleware([available])
.validator(
Schema.decodeUnknownSync(
Schema.Struct({
@@ -43,7 +44,12 @@ export const getConversation = createServerFn({ method: "GET" })
}),
),
)
- .handler(({ data }) => {
+ .handler(async ({ data }) => {
+ if (
+ appGate().mode === "drain" ||
+ (await Effect.runPromise(withAgentStore(isMaintenance)))
+ )
+ return result(readConversationSnapshot(data.agentId, data));
startWorker();
return result(
@@ -79,7 +85,6 @@ export const stopMessage = createServerFn({ method: "POST" })
.handler(({ data }) => result(cancelRun(data.agentId, data.id)));
export const getActivityOutput = createServerFn({ method: "GET" })
- .middleware([available])
.validator(
Schema.decodeUnknownSync(
Schema.Struct({ agentId: Schema.UUID, id: Schema.String }),
diff --git a/src/features/coding/functions.ts b/src/features/coding/functions.ts
index 079fd37..d75d697 100644
--- a/src/features/coding/functions.ts
+++ b/src/features/coding/functions.ts
@@ -64,7 +64,6 @@ export const removeExecutionProfile = createServerFn({ method: "POST" })
);
export const getCodingJobs = createServerFn({ method: "GET" })
- .middleware([available])
.validator(Schema.decodeUnknownSync(AgentInput))
.handler(({ data }) => result(listCodingJobs(data.agentId)));
diff --git a/src/server.ts b/src/server.ts
index 652f711..774e70a 100644
--- a/src/server.ts
+++ b/src/server.ts
@@ -8,22 +8,39 @@ import { authPage } from "./server/auth/page.server";
import { nativeAuthEnabled } from "./server/auth/store.server";
import { compressHtml } from "./server/html-compression.server";
import { followStartupRedirect } from "./server/startup-response.server";
+import {
+ trackUpdateRequest,
+ updateGateRequest,
+} from "./server/update-gate.server";
const handler = createStartHandler(defaultStreamHandler);
export default createServerEntry({
async fetch(request, options) {
- const auth = await authGate(request, authPage);
- if (auth) return auth;
- const response = await followStartupRedirect(
- request,
- await handler(request, options),
- (nextRequest) => handler(nextRequest, options),
+ const gate = await updateGateRequest(request, () =>
+ // A fixed, capability-authorized read-only shell probe. Settings' external
+ // connection loaders remain blocked by maintenance; no client JS executes.
+ handler(
+ new Request(new URL("/settings?group=updates", request.url), {
+ headers: request.headers,
+ }),
+ options,
+ ),
);
- if (nativeAuthEnabled()) {
- response.headers.set("Cache-Control", "private, no-store");
- response.headers.set("Referrer-Policy", "no-referrer");
- }
- return compressHtml(request, response);
+ if (gate) return gate;
+ return trackUpdateRequest(async () => {
+ const auth = await authGate(request, authPage);
+ if (auth) return auth;
+ const response = await followStartupRedirect(
+ request,
+ await handler(request, options),
+ (nextRequest) => handler(nextRequest, options),
+ );
+ if (nativeAuthEnabled()) {
+ response.headers.set("Cache-Control", "private, no-store");
+ response.headers.set("Referrer-Policy", "no-referrer");
+ }
+ return compressHtml(request, response);
+ });
},
});
diff --git a/src/server/auth/readiness.server.ts b/src/server/auth/readiness.server.ts
new file mode 100644
index 0000000..bf48edb
--- /dev/null
+++ b/src/server/auth/readiness.server.ts
@@ -0,0 +1,47 @@
+import type { DatabaseSync } from "node:sqlite";
+import { validateOrigin } from "./store.server";
+
+/** Read-only startup verification. SQLite integrity alone cannot detect malformed
+ * auth JSON or missing tables that would leave the owner unable to sign in. */
+export function verifyAuthReadiness(db: DatabaseSync) {
+ const config = JSON.parse(
+ String(db.prepare("SELECT value FROM config WHERE id=1").get()?.value),
+ );
+ validateOrigin(config.origin);
+ if (
+ typeof config.owner !== "string" ||
+ !/^[A-Za-z0-9_-]{43}$/.test(config.owner) ||
+ !Number.isSafeInteger(config.generation) ||
+ config.generation < 1 ||
+ !Number.isFinite(config.expires) ||
+ config.expires < 0 ||
+ (config.bootstrap !== null &&
+ (typeof config.bootstrap !== "string" ||
+ !/^[a-f0-9]{64}$/.test(config.bootstrap)))
+ )
+ throw new Error("Auth configuration failed readiness checks.");
+ const credentials = db.prepare("SELECT id,value FROM credentials").all();
+ if (
+ !credentials.length &&
+ (!config.bootstrap || config.expires <= Date.now())
+ )
+ throw new Error("Auth has no owner credential or enrollment path.");
+ for (const row of credentials) {
+ const credential = JSON.parse(String(row.value));
+ if (
+ credential.id !== row.id ||
+ typeof credential.id !== "string" ||
+ !credential.id ||
+ typeof credential.publicKey !== "string" ||
+ !/^[A-Za-z0-9_-]+$/.test(credential.publicKey) ||
+ !Number.isSafeInteger(credential.counter) ||
+ credential.counter < 0
+ )
+ throw new Error("Auth credential failed readiness checks.");
+ }
+ db.prepare(
+ "SELECT id,credential,created,expires FROM sessions LIMIT 0",
+ ).all();
+ db.prepare("SELECT id,value,expires FROM ceremonies LIMIT 0").all();
+ db.prepare("SELECT id,count,expires FROM limits LIMIT 0").all();
+}
diff --git a/src/server/codex/login.server.ts b/src/server/codex/login.server.ts
index 1673bee..4cd2717 100644
--- a/src/server/codex/login.server.ts
+++ b/src/server/codex/login.server.ts
@@ -171,3 +171,6 @@ export async function closeLogin() {
await state.starting;
await state.cancel?.();
}
+
+export const loginActive = () =>
+ Boolean(state.starting || state.value.status === "pending");
diff --git a/src/server/coding/worker.server.ts b/src/server/coding/worker.server.ts
index be62150..9f6d598 100644
--- a/src/server/coding/worker.server.ts
+++ b/src/server/coding/worker.server.ts
@@ -186,6 +186,7 @@ export async function tickCodingJobs(
status: "blocked",
error:
"This terminal now belongs to a different worker. No input was sent; inspect it in Herdr.",
+ lastWorkerState: "unknown",
});
if (worker.state === "missing")
return persist(job, {
diff --git a/src/server/computer/session.server.ts b/src/server/computer/session.server.ts
index 1e65c1c..189d047 100644
--- a/src/server/computer/session.server.ts
+++ b/src/server/computer/session.server.ts
@@ -147,3 +147,7 @@ export const endComputerAction = () => {
export const releaseComputer = (agentId: string) => {
if (state.agent === agentId) state.agent = undefined;
};
+
+export const computerActivity = () =>
+ Number(state.acting) +
+ [...state.viewers.values()].filter((viewer) => viewer.connected).length;
diff --git a/src/server/computer/socket.server.ts b/src/server/computer/socket.server.ts
index 3140824..e20256c 100644
--- a/src/server/computer/socket.server.ts
+++ b/src/server/computer/socket.server.ts
@@ -1,5 +1,6 @@
import { connect, type Socket } from "node:net";
import { defineWebSocketHandler } from "nitro";
+import { appGate } from "../../updater/gate";
import { authenticatedSocket, sessionActive } from "../auth/session.server";
import {
attachViewer,
@@ -13,6 +14,8 @@ const authTimers = new Map>();
export default defineWebSocketHandler({
upgrade(request) {
+ if (appGate().mode !== "open")
+ throw new Response("Update admission is closed", { status: 503 });
const session = authenticatedSocket(request);
const id = new URL(request.url).searchParams.get("ticket") ?? "";
if (!connectViewer(id, request.headers.get("origin"), session))
@@ -57,6 +60,11 @@ export default defineWebSocketHandler({
},
message(peer, message) {
+ if (["hold", "verify", "manual"].includes(appGate().mode)) {
+ sockets.get(peer.id)?.destroy();
+ peer.close(1013, "Roost is updating");
+ return;
+ }
if (
!sessionActive(
typeof peer.context.session === "string" ? peer.context.session : null,
diff --git a/src/server/runs/store.server.ts b/src/server/runs/store.server.ts
index a455841..abde7d4 100644
--- a/src/server/runs/store.server.ts
+++ b/src/server/runs/store.server.ts
@@ -330,7 +330,7 @@ export const claimSteeringRun = (run: Run) =>
(db) =>
db
.prepare(
- "UPDATE runs SET status='steering',owner=?,startedAt=?,threadId=(SELECT threadId FROM runs WHERE id=?) WHERE id=(SELECT q.id FROM runs q WHERE q.agentId=? AND q.kind='chat' AND q.status='queued' AND q.cancelRequested=0 AND EXISTS (SELECT 1 FROM runs r WHERE r.id=? AND r.owner=? AND r.status='running' AND r.kind IN ('chat','handoff') AND r.cancelRequested=0) AND EXISTS (SELECT 1 FROM worker_lease WHERE owner=? AND heartbeat>?) ORDER BY q.createdAt,q.rowid LIMIT 1) RETURNING *",
+ "UPDATE runs SET status='steering',owner=?,startedAt=?,threadId=(SELECT threadId FROM runs WHERE id=?) WHERE id=(SELECT q.id FROM runs q WHERE q.agentId=? AND q.kind='chat' AND q.status='queued' AND (SELECT maintenance FROM runtime_control WHERE id=1)=0 AND q.cancelRequested=0 AND EXISTS (SELECT 1 FROM runs r WHERE r.id=? AND r.owner=? AND r.status='running' AND r.kind IN ('chat','handoff') AND r.cancelRequested=0) AND EXISTS (SELECT 1 FROM worker_lease WHERE owner=? AND heartbeat>?) ORDER BY q.createdAt,q.rowid LIMIT 1) RETURNING *",
)
.get(
run.owner,
diff --git a/src/server/runs/worker.server.ts b/src/server/runs/worker.server.ts
index 0be6220..d16aa3c 100644
--- a/src/server/runs/worker.server.ts
+++ b/src/server/runs/worker.server.ts
@@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto";
import { resolve } from "node:path";
import { Effect } from "effect";
import type { ChatEvent, Message } from "../../features/chat/schema";
+import { appGate } from "../../updater/gate";
import { AgentStoreError, withAgentStore } from "../agents/store.server";
import { CodexError } from "../codex/app-server.server";
import {
@@ -201,7 +202,12 @@ export function startWorker() {
}
const current = worker;
current.tick = async () => {
- if (current.ticking || current.stopped) return;
+ if (
+ current.ticking ||
+ current.stopped ||
+ ["hold", "verify", "manual"].includes(appGate().mode)
+ )
+ return;
current.ticking = true;
try {
const owns = await Effect.runPromise(schedulerTick(current.owner));
@@ -290,3 +296,11 @@ export function startWorker() {
workers.delete(root);
};
}
+
+export function workerActivity() {
+ const current = workers.get(resolve(process.env.ROOST_DATA_DIR ?? ".roost"));
+ return {
+ initialized: !!current,
+ tasks: current ? current.tasks.size + Number(current.ticking) : 0,
+ };
+}
diff --git a/src/server/update-gate.server.ts b/src/server/update-gate.server.ts
new file mode 100644
index 0000000..a910a1b
--- /dev/null
+++ b/src/server/update-gate.server.ts
@@ -0,0 +1,145 @@
+import { readdir, readFile, stat } from "node:fs/promises";
+import { join } from "node:path";
+import { DatabaseSync } from "node:sqlite";
+import { Effect } from "effect";
+import { appGate } from "../updater/gate";
+import { withAgentStore } from "./agents/store.server";
+import { verifyAuthReadiness } from "./auth/readiness.server";
+import { loginActive } from "./codex/login.server";
+import { computerActivity } from "./computer/session.server";
+import { workerActivity } from "./runs/worker.server";
+
+let requests = 0;
+export async function trackUpdateRequest(action: () => Promise) {
+ requests++;
+ try {
+ return await action();
+ } finally {
+ requests--;
+ }
+}
+export async function updateGateRequest(
+ request: Request,
+ renderShell?: () => Response | Promise,
+): Promise {
+ const gate = appGate();
+ const url = new URL(request.url);
+ const authorized =
+ request.headers.get("X-Roost-Updater") === (gate.token ?? gate.operation) &&
+ gate.mode !== "open";
+ if (url.pathname === "/api/updates/quiescence" && authorized) {
+ const activity = workerActivity();
+ return Response.json({
+ requests,
+ tasks: activity.tasks + computerActivity(),
+ login: loginActive(),
+ frozen: ["hold", "verify", "manual"].includes(gate.mode),
+ });
+ }
+ if (
+ url.pathname === "/api/updates/probe" &&
+ authorized &&
+ gate.mode === "verify"
+ ) {
+ const appSchema = await Effect.runPromise(
+ withAgentStore((db) => {
+ if (
+ db
+ .prepare("PRAGMA integrity_check")
+ .all()
+ .some((r) => r.integrity_check !== "ok")
+ )
+ throw new Error("App integrity failed.");
+ return Number(db.prepare("PRAGMA user_version").get()?.user_version);
+ }),
+ );
+ const auth = new DatabaseSync(
+ join(process.env.ROOST_DATA_DIR!, "auth.sqlite"),
+ { readOnly: true },
+ );
+ let authSchema = 0;
+ try {
+ authSchema = Number(
+ auth.prepare("PRAGMA user_version").get()?.user_version,
+ );
+ if (
+ auth
+ .prepare("PRAGMA integrity_check")
+ .all()
+ .some((r) => r.integrity_check !== "ok")
+ )
+ throw new Error("Auth integrity failed.");
+ verifyAuthReadiness(auth);
+ } finally {
+ auth.close();
+ }
+ const assets = process.env.ROOST_PUBLIC_DIR;
+ const names = assets ? await readdir(join(assets, "assets")) : [];
+ const script = names.find((name) => name.endsWith(".js"));
+ const style = names.find((name) => name.endsWith(".css"));
+ const healthyAssets = !!(
+ script &&
+ style &&
+ assets &&
+ (await readFile(join(assets, "assets", script))).length &&
+ (await readFile(join(assets, "assets", style))).length
+ );
+ let shell = false;
+ let referencedAssets = false;
+ if (renderShell) {
+ const response = await renderShell();
+ const { boundedBytes } = await import("../updater/releases");
+ const html = (await boundedBytes(response, 2 * 1024 * 1024)).toString();
+ const references = [
+ ...new Set(html.match(/\/assets\/[^"'<>\s)]+\.(?:js|css)/g) ?? []),
+ ];
+ referencedAssets =
+ !!assets &&
+ references.some((p) => p.endsWith(".js")) &&
+ references.some((p) => p.endsWith(".css"));
+ for (const path of references) {
+ const name = path.slice("/assets/".length);
+ if (!/^[A-Za-z0-9_.-]+\.(?:js|css)$/.test(name)) {
+ referencedAssets = false;
+ break;
+ }
+ const info = await stat(join(assets!, "assets", name)).catch(
+ () => null,
+ );
+ if (!info?.isFile() || info.size === 0) referencedAssets = false;
+ }
+ shell =
+ response.ok &&
+ response.headers.get("content-type")?.includes("text/html") === true &&
+ html.includes("Software updates") &&
+ html.includes("