Skip to content
Open
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
8 changes: 5 additions & 3 deletions src/features/agents/functions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,9 +36,11 @@ const result = <A, E extends { message: string }>(
),
);

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])
Expand Down
11 changes: 8 additions & 3 deletions src/features/chat/functions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = <A, E>(effect: Effect.Effect<A, E>) =>
Expand Down Expand Up @@ -33,7 +35,6 @@ export type InitialConversation = Awaited<
>;

export const getConversation = createServerFn({ method: "GET" })
.middleware([available])
.validator(
Schema.decodeUnknownSync(
Schema.Struct({
Expand All @@ -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(
Expand Down Expand Up @@ -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 }),
Expand Down
1 change: 0 additions & 1 deletion src/features/coding/functions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)));

Expand Down
39 changes: 28 additions & 11 deletions src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
},
});
47 changes: 47 additions & 0 deletions src/server/auth/readiness.server.ts
Original file line number Diff line number Diff line change
@@ -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();
}
3 changes: 3 additions & 0 deletions src/server/codex/login.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -171,3 +171,6 @@ export async function closeLogin() {
await state.starting;
await state.cancel?.();
}

export const loginActive = () =>
Boolean(state.starting || state.value.status === "pending");
1 change: 1 addition & 0 deletions src/server/coding/worker.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, {
Expand Down
4 changes: 4 additions & 0 deletions src/server/computer/session.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
8 changes: 8 additions & 0 deletions src/server/computer/socket.server.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -13,6 +14,8 @@ const authTimers = new Map<string, ReturnType<typeof setInterval>>();

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))
Expand Down Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion src/server/runs/store.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
16 changes: 15 additions & 1 deletion src/server/runs/worker.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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));
Expand Down Expand Up @@ -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,
};
}
Loading