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
8 changes: 6 additions & 2 deletions docs/LOCAL-ENGINE-API.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -40,6 +41,7 @@ function validateCredential<A, E, R>(
effect: Effect.Effect<A, E, R>,
credential: ServerAuth.DecodedCredentials,
config: ServerAuth.Info,
path: string,
) {
return Effect.gen(function* () {
if (!ServerAuth.required(config)) return yield* effect
Expand All @@ -49,7 +51,8 @@ function validateCredential<A, E, R>(
)
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)
})
}

Expand Down Expand Up @@ -85,6 +88,7 @@ function validateRawCredential<A, E, R>(
effect: Effect.Effect<A, E, R>,
credential: ServerAuth.DecodedCredentials,
config: ServerAuth.Info,
path: string,
) {
if (!ServerAuth.required(config)) return effect
if (!ServerAuth.authorized(credential, config))
Expand All @@ -94,7 +98,7 @@ function validateRawCredential<A, E, R>(
headers: { "www-authenticate": WWW_AUTHENTICATE },
}),
)
return effect
return Clients.withActivity(path, effect)
}

export const authorizationRouterMiddleware = HttpRouter.middleware()(
Expand All @@ -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)),
)
})
}),
Expand All @@ -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)),
)
}),
)
Expand All @@ -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)),
)
}),
)
Expand Down
38 changes: 36 additions & 2 deletions packages/redrob/test/daemon.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})
})
Expand All @@ -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)
Expand Down
52 changes: 50 additions & 2 deletions packages/server/src/clients.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<A, E, R>(path: string, effect: Effect.Effect<A, E, R>): Effect.Effect<A, E, R> {
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
}
4 changes: 3 additions & 1 deletion packages/server/src/middleware/authorization.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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)),
)
Expand Down
Loading