diff --git a/docs/LOCAL-ENGINE-API.md b/docs/LOCAL-ENGINE-API.md index d4908a59c9..ffe95fe67f 100644 --- a/docs/LOCAL-ENGINE-API.md +++ b/docs/LOCAL-ENGINE-API.md @@ -172,8 +172,12 @@ replaced (an upgrade), and the new record is awaited. Attached apps are counted. `POST /api/clients {app, pid}` attaches, `POST /api/clients/:id/heartbeat` keeps it (expected within `ttlMs`, 90 s; the SDK's `attach()` does this for you), `DELETE /api/clients/:id` detaches, `GET /api/clients` lists them with `idleForMs` and -the daemon's `idleExitAfterMs`. A registered daemon exits once nobody has been attached for -`--idle-exit` minutes (default 10; 0 keeps it up), and also when another daemon has taken over +the daemon's `idleExitAfterMs`. A registered daemon exits once nobody has needed it for +`--idle-exit` minutes (default 10; 0 keeps it up): no app attached, no request in flight, and no +authorized request since -- so an app that only calls the API (the Rust apps: Shape, Canvas, Query, +Recall, through `redrob-engine-client`) keeps it alive by using it, and it never leaves under a +request still being answered. Status polls (`/api/health`, `/api/clients`) do not count: the +launcher's engine card asks every few seconds. It also exits when another daemon has taken over the record. `redrob acp --attach` makes the ACP face a thin client of the daemon instead of an engine of its own, so ACP hosts (Design, Cut, CAD, Browser) share the one process too. diff --git a/packages/redrob/src/server/routes/instance/httpapi/middleware/authorization.ts b/packages/redrob/src/server/routes/instance/httpapi/middleware/authorization.ts index 66b44f762b..b663577e9d 100644 --- a/packages/redrob/src/server/routes/instance/httpapi/middleware/authorization.ts +++ b/packages/redrob/src/server/routes/instance/httpapi/middleware/authorization.ts @@ -1,4 +1,5 @@ import { ServerAuth } from "@/server/auth" +import * as Clients from "@redrob-code/server/clients" import { Effect, Encoding, Layer, Redacted } from "effect" import { HttpEffect, HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http" import { HttpApiError, HttpApiMiddleware } from "effect/unstable/httpapi" @@ -40,6 +41,7 @@ function validateCredential( effect: Effect.Effect, credential: ServerAuth.DecodedCredentials, config: ServerAuth.Info, + path: string, ) { return Effect.gen(function* () { if (!ServerAuth.required(config)) return yield* effect @@ -49,7 +51,8 @@ function validateCredential( ) return yield* new HttpApiError.Unauthorized({}) } - return yield* effect + // The shared daemon's idle clock: an authorized request is someone needing the engine. + return yield* Clients.withActivity(path, effect) }) } @@ -85,6 +88,7 @@ function validateRawCredential( effect: Effect.Effect, credential: ServerAuth.DecodedCredentials, config: ServerAuth.Info, + path: string, ) { if (!ServerAuth.required(config)) return effect if (!ServerAuth.authorized(credential, config)) @@ -94,7 +98,7 @@ function validateRawCredential( headers: { "www-authenticate": WWW_AUTHENTICATE }, }), ) - return effect + return Clients.withActivity(path, effect) } export const authorizationRouterMiddleware = HttpRouter.middleware()( @@ -107,7 +111,7 @@ export const authorizationRouterMiddleware = HttpRouter.middleware()( const request = yield* HttpServerRequest.HttpServerRequest const url = new URL(request.url, "http://localhost") return yield* credentialFromURL(url, request).pipe( - Effect.flatMap((credential) => validateRawCredential(effect, credential, config)), + Effect.flatMap((credential) => validateRawCredential(effect, credential, config, url.pathname)), ) }) }), @@ -121,8 +125,9 @@ export const authorizationLayer = Layer.effect( return Authorization.of((effect) => Effect.gen(function* () { const request = yield* HttpServerRequest.HttpServerRequest + const path = new URL(request.url, "http://localhost").pathname return yield* credentialFromRequest(request).pipe( - Effect.flatMap((credential) => validateCredential(effect, credential, config)), + Effect.flatMap((credential) => validateCredential(effect, credential, config, path)), ) }), ) @@ -140,7 +145,7 @@ export const ptyConnectAuthorizationLayer = Layer.effect( const url = new URL(request.url, "http://localhost") if (hasPtyConnectTicketURL(url)) return yield* effect return yield* credentialFromURL(url, request).pipe( - Effect.flatMap((credential) => validateCredential(effect, credential, config)), + Effect.flatMap((credential) => validateCredential(effect, credential, config, url.pathname)), ) }), ) diff --git a/packages/redrob/test/daemon.test.ts b/packages/redrob/test/daemon.test.ts index 782901e0a0..7325acea38 100644 --- a/packages/redrob/test/daemon.test.ts +++ b/packages/redrob/test/daemon.test.ts @@ -75,9 +75,13 @@ describe("daemon registry", () => { test("healthy is false for a daemon that does not answer, and true for one that does", async () => { const no = await Registry.healthy(record({ url: "http://127.0.0.1:9" }), undefined, 300) expect(no).toBe(false) - const yes = await Registry.healthy(record(), (async () => Response.json({ healthy: true })) as unknown as typeof fetch) + const yes = await Registry.healthy(record(), (async () => + Response.json({ healthy: true })) as unknown as typeof fetch) expect(yes).toBe(true) - const wrong = await Registry.healthy(record(), (async () => new Response("", { status: 401 })) as unknown as typeof fetch) + const wrong = await Registry.healthy( + record(), + (async () => new Response("", { status: 401 })) as unknown as typeof fetch, + ) expect(wrong).toBe(false) }) }) @@ -97,6 +101,36 @@ describe("attached apps", () => { expect(Clients.detach(b.id)).toBe(false) }) + test("requests count as activity: not idle while one is in flight, idle timer restarts after it", () => { + Clients.reset(1_000) + expect(Clients.idleFor(4_000)).toBe(3_000) + Clients.requestStarted(5_000) + expect(Clients.idleFor(600_000)).toBeUndefined() + Clients.requestEnded(20_000) + expect(Clients.idleFor(25_000)).toBe(5_000) + // Two overlapping requests: idle only once both are answered. + Clients.requestStarted(30_000) + Clients.requestStarted(31_000) + Clients.requestEnded(32_000) + expect(Clients.idleFor(40_000)).toBeUndefined() + Clients.requestEnded(33_000) + expect(Clients.idleFor(40_000)).toBe(7_000) + // An attachment still wins over the request clock. + const a = Clients.attach({ app: "office" }, 41_000) + expect(Clients.idleFor(90_000)).toBeUndefined() + Clients.detach(a.id, 50_000) + expect(Clients.idleFor(60_000)).toBe(10_000) + }) + + test("status polls are not activity; everything else is", () => { + expect(Clients.countsAsActivity("/api/health")).toBe(false) + expect(Clients.countsAsActivity("/api/clients")).toBe(false) + expect(Clients.countsAsActivity("/api/clients/abc")).toBe(false) + expect(Clients.countsAsActivity("/v1/chat/completions")).toBe(true) + expect(Clients.countsAsActivity("/provider")).toBe(true) + expect(Clients.countsAsActivity("/api/healthz")).toBe(true) + }) + test("an app whose heartbeats stop is swept; a heartbeat keeps one alive", () => { Clients.reset(0) const live = Clients.attach({ app: "office" }, 0) diff --git a/packages/server/src/clients.ts b/packages/server/src/clients.ts index 6c9695f457..6cecc7be35 100644 --- a/packages/server/src/clients.ts +++ b/packages/server/src/clients.ts @@ -7,6 +7,7 @@ * service: the serve command polls it from outside the request context. */ import { randomUUID } from "node:crypto" +import { Effect } from "effect" export type Client = { readonly id: string @@ -67,13 +68,60 @@ export function list(): Client[] { return [...clients.values()].sort((a, b) => a.attachedAt - b.attachedAt) } -/** How long nobody has been attached, in ms; undefined while someone is. */ +/** + * Requests count as activity too. The Rust apps (Shape, Canvas, Query, Recall) call the API + * without attaching, so with attachments alone the daemon would leave ten minutes after it was + * started -- possibly under a request still being answered. Status polls (health, the client + * list) are not activity: the launcher's engine card asks every few seconds. + */ +let lastRequestAt: number | undefined +let inFlight = 0 + +const STATUS_PATHS = ["/api/health", "/api/clients"] + +/** Whether a request to this path keeps the daemon alive. */ +export function countsAsActivity(path: string): boolean { + return !STATUS_PATHS.some( + (prefix) => path === prefix || path.startsWith(prefix + "/") || path.startsWith(prefix + "?"), + ) +} + +export function requestStarted(now = Date.now()): void { + inFlight += 1 + lastRequestAt = now +} + +export function requestEnded(now = Date.now()): void { + inFlight = Math.max(0, inFlight - 1) + lastRequestAt = now +} + +/** Run an authorized request's effect, counting it as activity unless it is a status poll. */ +export function withActivity(path: string, effect: Effect.Effect): Effect.Effect { + if (!countsAsActivity(path)) return effect + return Effect.suspend(() => { + requestStarted() + return Effect.ensuring( + effect, + Effect.sync(() => requestEnded()), + ) + }) +} + +/** + * How long nobody has needed this engine, in ms: no attachment, no request in flight, and no + * request since. Undefined while someone is attached or a request is being answered. + */ export function idleFor(now = Date.now()): number | undefined { - return idleSince === undefined ? undefined : now - idleSince + if (idleSince === undefined || inFlight > 0) return undefined + const since = lastRequestAt === undefined ? idleSince : Math.max(idleSince, lastRequestAt) + return now - since } /** Test seam. */ export function reset(now = Date.now()): void { clients.clear() idleSince = now + lastRequestAt = undefined + inFlight = 0 } diff --git a/packages/server/src/middleware/authorization.ts b/packages/server/src/middleware/authorization.ts index 484131f56d..5662cf41e1 100644 --- a/packages/server/src/middleware/authorization.ts +++ b/packages/server/src/middleware/authorization.ts @@ -1,4 +1,5 @@ import { ServerAuth } from "../auth" +import * as Clients from "../clients" import { UnauthorizedError } from "@redrob-code/protocol/errors" import { Authorization } from "@redrob-code/protocol/middleware/authorization" export { Authorization } from "@redrob-code/protocol/middleware/authorization" @@ -47,7 +48,8 @@ export const authorizationLayer = Layer.effect( // credential checks here; the connect handler consumes and validates the ticket. if (hasPtyConnectTicketURL(new URL(request.url, "http://localhost"))) return yield* effect const credential = yield* credentialFromRequest(request) - if (ServerAuth.authorized(credential, config)) return yield* effect + if (ServerAuth.authorized(credential, config)) + return yield* Clients.withActivity(new URL(request.url, "http://localhost").pathname, effect) yield* HttpEffect.appendPreResponseHandler((_request, response) => Effect.succeed(HttpServerResponse.setHeader(response, "www-authenticate", WWW_AUTHENTICATE)), )