From 2312df335feb810d497113ccfd7c09be8c395358 Mon Sep 17 00:00:00 2001 From: Koller Date: Sat, 25 Jul 2026 02:08:00 -0300 Subject: [PATCH] feat(eve): add authenticated session hard delete Add a built-in-JWT-only maintenance route that permanently purges an eve session and its Workflow run tree. Gate deletion on the provider-advertised runTreePurge capability and retain retryable conflicts for active trees. Signed-off-by: Koller --- .changeset/calm-rabbits-delete-sessions.md | 7 + docs/channels/eve.mdx | 10 ++ packages/eve/src/channel/types.ts | 8 ++ .../src/execution/workflow-runtime.test.ts | 61 ++++++++ .../eve/src/execution/workflow-runtime.ts | 37 +++++ .../internal/nitro/routes/channel-dispatch.ts | 8 ++ .../eve/src/internal/testing/route-harness.ts | 2 + packages/eve/src/protocol/routes.ts | 11 ++ packages/eve/src/public/channels/auth.ts | 64 ++++++--- packages/eve/src/public/channels/eve.test.ts | 133 +++++++++++++++++- packages/eve/src/public/channels/eve.ts | 83 ++++++++++- .../eve/src/public/definitions/channel.ts | 3 + 12 files changed, 409 insertions(+), 18 deletions(-) create mode 100644 .changeset/calm-rabbits-delete-sessions.md diff --git a/.changeset/calm-rabbits-delete-sessions.md b/.changeset/calm-rabbits-delete-sessions.md new file mode 100644 index 000000000..4eb0c69bf --- /dev/null +++ b/.changeset/calm-rabbits-delete-sessions.md @@ -0,0 +1,7 @@ +--- +"eve": patch +--- + +Add a dedicated built-in-JWT-only framework route that hard-deletes a session +workflow tree. Deployment requires a released `@workflow/world` and provider +World that advertise the new `runTreePurge` capability. diff --git a/docs/channels/eve.mdx b/docs/channels/eve.mdx index 0c8f7e6e3..a8310432d 100644 --- a/docs/channels/eve.mdx +++ b/docs/channels/eve.mdx @@ -25,6 +25,7 @@ The application exposes a health route plus eve channel routes that inspect the - `POST /eve/v1/session` (start a session) - `POST /eve/v1/session/:sessionId` (send a follow-up) - `POST /eve/v1/session/:sessionId/cancel` (cancel the in-flight turn) +- `DELETE /eve/v1/session/:sessionId` (internal permanent deletion) - `GET /eve/v1/session/:sessionId/stream` (stream events, NDJSON) Start a session with a minimal body. The response returns `sessionId` and the `continuationToken` you reuse for follow-ups: @@ -54,6 +55,15 @@ curl -X POST https:///eve/v1/session/ses_01h.../cancel Cancellation is asynchronous: `"accepted"` means a cancellation hook accepted the request. Confirm the effective outcome on the stream as `turn.cancelled` followed by `session.waiting` — never as a failure. Active local and remote subagents are cancelled recursively before the parent settles; their own streams carry their cancellation boundaries, and cancelled work does not emit `subagent.completed` on the parent. Content emitted before the cancel stays on the event stream, while durable model history keeps only content that had already settled. The session accepts the next message normally. When there is no resumable cancellation target (including an unknown session, a settled turn, or a duplicate cancel), the route responds with `"no_active_turn"` — also a success, so a stop button can fire and forget. A `turnId` naming any other turn is accepted and consumed as a no-op, so a guarded cancel racing a turn boundary cannot stop a turn the caller never saw. +Permanent deletion is a framework maintenance operation, not a browser or +end-user session API. It is disabled unless `eveChannel()` receives a separate +`maintenanceAuth` created by the built-in `jwtHmac()` or `jwtEcdsa()` helper. +The normal `auth` callback is not invoked for this route. A deleted or already +absent tree returns `204`; an active tree that has not settled enough to purge +returns retryable `409`. Deployment also requires a Workflow World version +that advertises `runTreePurge`; unsupported providers leave the route +unavailable. + See [Sessions, runs & streaming](../concepts/sessions-runs-and-streaming) for the full request and stream flow, including the complete event set. ## CORS diff --git a/packages/eve/src/channel/types.ts b/packages/eve/src/channel/types.ts index d57472b7c..180ea2ed6 100644 --- a/packages/eve/src/channel/types.ts +++ b/packages/eve/src/channel/types.ts @@ -43,6 +43,11 @@ export type TerminateSessionResult = | { readonly status: "terminated" } | { readonly status: "already_terminal" }; +/** Result of permanently purging a session's complete workflow run tree. */ +export type DeleteSessionResult = + | { readonly status: "absent" } + | { readonly status: "deleted"; readonly purgedRunCount: number }; + // --------------------------------------------------------------------------- // Lineage // --------------------------------------------------------------------------- @@ -398,6 +403,9 @@ export interface Runtime { /** Terminally retires a session and releases its non-retained continuation hooks. */ terminateSession(input: TerminateSessionInput): Promise; + /** Permanently deletes a session and all descendant workflow-owned state. */ + deleteSession?(sessionId: string): Promise; + /** * Delivers a follow-up message to a parked session. */ diff --git a/packages/eve/src/execution/workflow-runtime.test.ts b/packages/eve/src/execution/workflow-runtime.test.ts index 2ab666291..ea09c9a98 100644 --- a/packages/eve/src/execution/workflow-runtime.test.ts +++ b/packages/eve/src/execution/workflow-runtime.test.ts @@ -245,6 +245,67 @@ describe("createWorkflowRuntime#terminateSession", () => { }); }); +describe("createWorkflowRuntime#deleteSession", () => { + function buildRuntime() { + return createWorkflowRuntime({ compiledArtifactsSource: {} as RuntimeCompiledArtifactsSource }); + } + + it("cancels then purges the root and every eve descendant", async () => { + const purgeRunTree = vi.fn().mockResolvedValue({ + purgedRunCount: 3, + status: "purged", + }); + const world = { capabilities: { runTreePurge: true }, purgeRunTree }; + getWorldMock.mockResolvedValue(world); + cancelRunMock.mockResolvedValue(undefined); + + await expect(buildRuntime().deleteSession?.("session-1")).resolves.toEqual({ + purgedRunCount: 3, + status: "deleted", + }); + expect(cancelRunMock).toHaveBeenCalledWith(world, "session-1", { + cancelReason: "Session permanently deleted by maintenance", + }); + expect(purgeRunTree).toHaveBeenCalledWith("session-1", { + descendantAttribute: { key: "$eve.root", value: "session-1" }, + }); + }); + + it("treats an already absent tree as idempotent success", async () => { + const { WorkflowRunNotFoundError } = await import("#compiled/@workflow/errors/index.js"); + cancelRunMock.mockRejectedValue(new WorkflowRunNotFoundError("session-1")); + getWorldMock.mockResolvedValue({ + capabilities: { runTreePurge: true }, + purgeRunTree: vi.fn().mockResolvedValue({ + purgedRunCount: 0, + status: "absent", + }), + }); + + await expect(buildRuntime().deleteSession?.("session-1")).resolves.toEqual({ + status: "absent", + }); + }); + + it("fails closed when the World has no purge capability", async () => { + getWorldMock.mockResolvedValue({}); + + await expect(buildRuntime().deleteSession?.("session-1")).rejects.toThrow( + "does not support run-tree purge", + ); + }); + + it("fails closed when a World method is present without its capability", async () => { + getWorldMock.mockResolvedValue({ + purgeRunTree: vi.fn(), + }); + + await expect(buildRuntime().deleteSession?.("session-1")).rejects.toMatchObject({ + name: "EntityConflictError", + }); + }); +}); + describe("createWorkflowRuntime#resolveSession", () => { function buildRuntime() { return createWorkflowRuntime({ compiledArtifactsSource: {} as RuntimeCompiledArtifactsSource }); diff --git a/packages/eve/src/execution/workflow-runtime.ts b/packages/eve/src/execution/workflow-runtime.ts index f4163c1bf..2e7fec17c 100644 --- a/packages/eve/src/execution/workflow-runtime.ts +++ b/packages/eve/src/execution/workflow-runtime.ts @@ -8,6 +8,7 @@ import { import type { CancelTurnInput, CancelTurnResult, + DeleteSessionResult, DeliverInput, GetEventStreamOptions, HookPayload, @@ -193,6 +194,42 @@ export function createWorkflowRuntime(config: { } }, + async deleteSession(sessionId: string): Promise { + const world = await getWorld(); + const purgeWorld = world as typeof world & { + capabilities?: { runTreePurge?: boolean }; + purgeRunTree?: ( + rootRunId: string, + options: { + descendantAttribute: { key: string; value: string }; + }, + ) => Promise<{ purgedRunCount: number; status: "absent" | "purged" }>; + }; + const purge = purgeWorld.purgeRunTree; + if (purge === undefined || purgeWorld.capabilities?.runTreePurge !== true) { + throw new EntityConflictError( + "The configured Workflow World does not support run-tree purge.", + ); + } + + try { + await cancelRun(world, sessionId, { + cancelReason: "Session permanently deleted by maintenance", + }); + } catch (error) { + if (!isAlreadyTerminalSessionError(error)) { + throw error; + } + } + + const result = await purge(sessionId, { + descendantAttribute: { key: "$eve.root", value: sessionId }, + }); + return result.status === "absent" + ? { status: "absent" } + : { purgedRunCount: result.purgedRunCount, status: "deleted" }; + }, + async deliver(input: DeliverInput): Promise<{ sessionId: string }> { const hookPayload: Extract = { auth: input.auth, diff --git a/packages/eve/src/internal/nitro/routes/channel-dispatch.ts b/packages/eve/src/internal/nitro/routes/channel-dispatch.ts index e3684ab4d..cebe1e2fe 100644 --- a/packages/eve/src/internal/nitro/routes/channel-dispatch.ts +++ b/packages/eve/src/internal/nitro/routes/channel-dispatch.ts @@ -228,10 +228,18 @@ function buildRouteArgs( } function createRouteAgent(runtime: Runtime, requestId: string | undefined): Agent { + const deleteSession = runtime.deleteSession; return { async cancelTurn(input) { return await runtime.cancelTurn(input); }, + ...(deleteSession === undefined + ? {} + : { + async deleteSession(sessionId: string) { + return await deleteSession(sessionId); + }, + }), async deliver(input) { const deliverInput: DeliverInput = { ...input, requestId }; // Avoid mutating a frozen caller input. return await runtime.deliver(deliverInput); diff --git a/packages/eve/src/internal/testing/route-harness.ts b/packages/eve/src/internal/testing/route-harness.ts index 08c080562..1ee21ef67 100644 --- a/packages/eve/src/internal/testing/route-harness.ts +++ b/packages/eve/src/internal/testing/route-harness.ts @@ -20,6 +20,7 @@ export interface MockAgent extends Agent { readonly cancelTurn: Mock; readonly run: Mock; readonly deliver: Mock; + readonly deleteSession: Mock; readonly getEventStream: Mock; } @@ -37,6 +38,7 @@ export function createMockAgent(): MockAgent { return { cancelTurn: vi.fn().mockResolvedValue({ status: "no_active_turn" }), deliver: vi.fn().mockResolvedValue(undefined), + deleteSession: vi.fn().mockResolvedValue({ purgedRunCount: 1, status: "deleted" }), getEventStream: vi.fn().mockResolvedValue(new ReadableStream()), run: vi.fn().mockResolvedValue({ continuationToken: "http:test", diff --git a/packages/eve/src/protocol/routes.ts b/packages/eve/src/protocol/routes.ts index d1f403765..11996ae40 100644 --- a/packages/eve/src/protocol/routes.ts +++ b/packages/eve/src/protocol/routes.ts @@ -45,6 +45,12 @@ export const EVE_MESSAGE_STREAM_ROUTE_PATTERN = `${EVE_ROUTE_PREFIX}/session/:se */ export const EVE_CANCEL_TURN_ROUTE_PATTERN = `${EVE_ROUTE_PREFIX}/session/:sessionId/cancel`; +/** + * Framework-owned maintenance route for permanently deleting one session and + * its descendant workflow runs. Unlike reset, this removes durable history. + */ +export const EVE_DELETE_SESSION_ROUTE_PATTERN = `${EVE_ROUTE_PREFIX}/session/:sessionId`; + /** * Framework-owned route pattern for dispatching one authored schedule * exactly once from the dev server. @@ -130,6 +136,11 @@ export function createEveCancelTurnRoutePath(sessionId: string): string { return `${EVE_ROUTE_PREFIX}/session/${encodeURIComponent(sessionId)}/cancel`; } +/** Creates the stable framework-owned hard-delete route path. */ +export function createEveDeleteSessionRoutePath(sessionId: string): string { + return `${EVE_ROUTE_PREFIX}/session/${encodeURIComponent(sessionId)}`; +} + /** * Creates the stable framework-owned connection callback route path for * one (`name`, `token`) pair. diff --git a/packages/eve/src/public/channels/auth.ts b/packages/eve/src/public/channels/auth.ts index 265f570d0..bdd8482e3 100644 --- a/packages/eve/src/public/channels/auth.ts +++ b/packages/eve/src/public/channels/auth.ts @@ -496,6 +496,34 @@ export type AuthFn = ( event: TEvent, ) => SessionAuthContext | null | undefined | Promise; +const JWT_AUTH_FN_SYMBOL = Symbol("eve.channels.auth.jwt"); + +/** + * A route authenticator created by Eve's built-in JWT verifiers. + * + * The runtime brand prevents maintenance-only routes from accepting a custom + * callback that merely returns JWT-looking principal metadata. + */ +export type JwtAuthFn = AuthFn & { + readonly __eveBuiltInJwtAuth: never; +}; + +/** @internal */ +export function isJwtAuthFn(fn: AuthFn): fn is JwtAuthFn { + return ( + ( + fn as AuthFn & { + readonly [JWT_AUTH_FN_SYMBOL]?: boolean; + } + )[JWT_AUTH_FN_SYMBOL] === true + ); +} + +function markJwtAuthFn(fn: AuthFn): JwtAuthFn { + Object.defineProperty(fn, JWT_AUTH_FN_SYMBOL, { value: true }); + return fn as JwtAuthFn; +} + /** * Symbol-keyed property carrying the `www-authenticate` challenges an * {@link AuthFn} would satisfy, attached via {@link withAuthChallenges}. @@ -1067,14 +1095,16 @@ export function httpBasic( * {@link verifyJwtHmac}. Declares a `Bearer` {@link withAuthChallenges} * challenge for {@link routeAuth}'s 401. */ -export function jwtHmac(config: VerifyJwtHmacConfig): AuthFn { - return withAuthChallenges( - async (request) => { - const token = extractBearerToken(request.headers.get("authorization")); - const result = await verifyJwtHmac(token, config); - return result.ok ? result.sessionAuth : null; - }, - [{ scheme: "Bearer" }], +export function jwtHmac(config: VerifyJwtHmacConfig): JwtAuthFn { + return markJwtAuthFn( + withAuthChallenges( + async (request) => { + const token = extractBearerToken(request.headers.get("authorization")); + const result = await verifyJwtHmac(token, config); + return result.ok ? result.sessionAuth : null; + }, + [{ scheme: "Bearer" }], + ), ); } @@ -1083,14 +1113,16 @@ export function jwtHmac(config: VerifyJwtHmacConfig): AuthFn { * {@link verifyJwtEcdsa}. Declares a `Bearer` {@link withAuthChallenges} * challenge for {@link routeAuth}'s 401. */ -export function jwtEcdsa(config: VerifyJwtEcdsaConfig): AuthFn { - return withAuthChallenges( - async (request) => { - const token = extractBearerToken(request.headers.get("authorization")); - const result = await verifyJwtEcdsa(token, config); - return result.ok ? result.sessionAuth : null; - }, - [{ scheme: "Bearer" }], +export function jwtEcdsa(config: VerifyJwtEcdsaConfig): JwtAuthFn { + return markJwtAuthFn( + withAuthChallenges( + async (request) => { + const token = extractBearerToken(request.headers.get("authorization")); + const result = await verifyJwtEcdsa(token, config); + return result.ok ? result.sessionAuth : null; + }, + [{ scheme: "Bearer" }], + ), ); } diff --git a/packages/eve/src/public/channels/eve.test.ts b/packages/eve/src/public/channels/eve.test.ts index ef77801ef..8996810a3 100644 --- a/packages/eve/src/public/channels/eve.test.ts +++ b/packages/eve/src/public/channels/eve.test.ts @@ -1,12 +1,14 @@ import type { FilePart, UserContent } from "ai"; +import { SignJWT } from "jose"; import { describe, expect, it, vi } from "vitest"; +import { EntityConflictError } from "#compiled/@workflow/errors/index.js"; import { buildAdapterContext } from "#channel/adapter-context.js"; import { callAdapterEventHandler, type ChannelAdapter } from "#channel/adapter.js"; import { isCompiledChannel } from "#channel/compiled-channel.js"; import { createJsonMessageRequest, createMockAgent } from "#internal/testing/route-harness.js"; import { attachRouteAgent } from "#internal/nitro/routes/channel-route-context.js"; -import { type AuthFn, none } from "#public/channels/auth.js"; +import { type AuthFn, jwtHmac, type JwtAuthFn, none } from "#public/channels/auth.js"; import { eveChannel, defaultEveAuth, type EveChannelInput } from "#public/channels/eve.js"; import type { SessionAuthContext } from "#channel/types.js"; import type { RouteHandlerArgs, SendFn, SendOptions, SendPayload } from "#channel/routes.js"; @@ -180,6 +182,42 @@ function cancelRequest(body?: unknown): Request { }); } +function createEveDeleteHandler(input: EveChannelInput) { + const channel = eveChannel(input); + const deleteRoute = channel.routes.find( + (r) => r.method === "DELETE" && r.path === "/eve/v1/session/:sessionId", + ); + if (!deleteRoute) throw new Error("No session DELETE route found"); + + const agent = createMockAgent(); + return { + deleteSession: agent.deleteSession, + async fetch(req?: Request) { + const args = attachRouteAgent( + { + send: vi.fn(), + resolveActiveSession: async () => undefined, + cancel: vi.fn(), + reset: vi.fn(), + getSession: vi.fn(), + receive: vi.fn() as any, + params: { sessionId: "test-session-id" }, + waitUntil: () => undefined, + requestIp: "127.0.0.1", + } satisfies RouteHandlerArgs, + agent, + ); + return (deleteRoute as any).handler( + req ?? + new Request("https://example.com/eve/v1/session/test-session-id", { + method: "DELETE", + }), + args, + ); + }, + }; +} + /** Creates a POST handler test harness for the continuation-addressed reset route. */ function createEveResetHandler(input: EveChannelInput) { const channel = eveChannel(input); @@ -1442,6 +1480,99 @@ describe("eveChannel — cancel turn", () => { }); }); +describe("eveChannel — hard delete session", () => { + const secret = "test-maintenance-secret-at-least-32-bytes"; + const internalJwtAuth = jwtHmac({ + algorithm: "HS256", + audiences: ["eve-maintenance"], + issuer: "rabbit-studio", + secret, + }); + + async function deleteRequest(): Promise { + const token = await new SignJWT({}) + .setProtectedHeader({ alg: "HS256" }) + .setIssuer("rabbit-studio") + .setAudience("eve-maintenance") + .setSubject("rabbit-studio-runtime") + .setExpirationTime("5m") + .sign(new TextEncoder().encode(secret)); + return new Request("https://example.com/eve/v1/session/test-session-id", { + headers: { authorization: `Bearer ${token}` }, + method: "DELETE", + }); + } + + it("returns 204 for an authenticated deleted or absent session", async () => { + const handler = createEveDeleteHandler({ + auth: () => { + throw new Error("Turn-context auth must not run for maintenance."); + }, + maintenanceAuth: internalJwtAuth, + }); + + const response = await handler.fetch(await deleteRequest()); + + expect(response.status).toBe(204); + expect(response.headers.get("cache-control")).toBe("no-store"); + expect(handler.deleteSession).toHaveBeenCalledWith("test-session-id"); + }); + + it("does not expose deletion without a dedicated built-in JWT policy", async () => { + const forgedJwtAuth = Object.assign(none(), { + __eveBuiltInJwtAuth: undefined as never, + }) satisfies JwtAuthFn; + const handler = createEveDeleteHandler({ + auth: none(), + maintenanceAuth: forgedJwtAuth, + }); + + const response = await handler.fetch(); + + expect(response.status).toBe(501); + expect(handler.deleteSession).not.toHaveBeenCalled(); + }); + + it("returns 401 when the maintenance JWT is missing", async () => { + const handler = createEveDeleteHandler({ + auth: none(), + maintenanceAuth: internalJwtAuth, + }); + + const response = await handler.fetch(); + + expect(response.status).toBe(401); + expect(handler.deleteSession).not.toHaveBeenCalled(); + }); + + it("returns retryable 409 while the run tree is still active", async () => { + const handler = createEveDeleteHandler({ + auth: none(), + maintenanceAuth: internalJwtAuth, + }); + handler.deleteSession.mockRejectedValue( + new EntityConflictError("Workflow run tree is still active"), + ); + + const response = await handler.fetch(await deleteRequest()); + + expect(response.status).toBe(409); + expect(response.headers.get("retry-after")).toBe("1"); + }); + + it("does not infer a retryable conflict from an error message", async () => { + const handler = createEveDeleteHandler({ + auth: none(), + maintenanceAuth: internalJwtAuth, + }); + handler.deleteSession.mockRejectedValue(new Error("Workflow run tree is still active")); + + const response = await handler.fetch(await deleteRequest()); + + expect(response.status).toBe(500); + }); +}); + describe("eveChannel — reset session", () => { it("retires the owner of the supplied channel-local continuation token", async () => { const handler = createEveResetHandler({ auth: none() }); diff --git a/packages/eve/src/public/channels/eve.ts b/packages/eve/src/public/channels/eve.ts index 01efce6c2..eb829d5c9 100644 --- a/packages/eve/src/public/channels/eve.ts +++ b/packages/eve/src/public/channels/eve.ts @@ -1,4 +1,5 @@ import { type FilePart, type TextPart, type UserContent } from "ai"; +import { EntityConflictError } from "#compiled/@workflow/errors/index.js"; import type { CancelTurnResult, SessionAuthContext, SessionCallback } from "#channel/types.js"; import type { CancelTurnResponse } from "#protocol/cancel-turn.js"; @@ -22,11 +23,12 @@ import { } from "#protocol/message.js"; import { EVE_CANCEL_TURN_ROUTE_PATTERN, + EVE_DELETE_SESSION_ROUTE_PATTERN, EVE_INFO_ROUTE_PATH, EVE_RESET_SESSION_ROUTE_PATH, } from "#protocol/routes.js"; import { type InputResponse, isInputResponse } from "#runtime/input/types.js"; -import { type AuthFn, routeAuth } from "#public/channels/auth.js"; +import { type AuthFn, isJwtAuthFn, type JwtAuthFn, routeAuth } from "#public/channels/auth.js"; import { collectUploadPolicyViolations, formatUploadPolicyViolation, @@ -36,6 +38,7 @@ import { } from "#public/channels/upload-policy.js"; import { defineChannel, + DELETE, POST, GET, type Channel, @@ -129,6 +132,13 @@ export interface EveChannelInput { * the next; exhaustion (including the empty array) rejects with 401. Include `none()` last for anonymous traffic. */ readonly auth: AuthFn | readonly AuthFn[]; + /** + * Dedicated built-in JWT policy for framework maintenance routes. This is + * intentionally separate from {@link auth}: application auth may require + * Turn context or accept users, while hard deletion must accept only an + * internal service JWT verified by {@link jwtHmac} or {@link jwtEcdsa}. + */ + readonly maintenanceAuth?: JwtAuthFn | readonly JwtAuthFn[]; /** * The trusted-forwarders policy: which transport-authenticated callers may * assert a forwarded principal on the create-session route (the @@ -425,6 +435,62 @@ export function eveChannel(input: EveChannelInput): EveChannel { ); }), + DELETE(EVE_DELETE_SESSION_ROUTE_PATTERN, async (req, args) => { + const maintenanceAuth = normalizeMaintenanceAuth(input.maintenanceAuth); + if (maintenanceAuth === null) { + return Response.json( + { error: "Session deletion is unavailable.", ok: false }, + { status: 501 }, + ); + } + const authResult = await routeAuth(req, maintenanceAuth); + if (authResult instanceof Response) return authResult; + if (authResult.principalType !== "service") { + return Response.json( + { error: "Session deletion requires an internal service JWT.", ok: false }, + { status: 403 }, + ); + } + + const sessionId = args.params.sessionId; + if (!sessionId) { + return Response.json({ error: "Missing session id.", ok: false }, { status: 400 }); + } + + try { + const agent = readRouteAgent(args); + if (agent === undefined) { + throw new Error("Missing route agent."); + } + if (agent.deleteSession === undefined) { + return Response.json( + { error: "Session deletion is unavailable.", ok: false }, + { status: 501 }, + ); + } + await agent.deleteSession(sessionId); + return new Response(null, { + headers: { "cache-control": "no-store" }, + status: 204, + }); + } catch (error) { + if (isPurgeConflict(error)) { + return Response.json( + { error: "Session deletion is still in progress.", ok: false }, + { + headers: { "cache-control": "no-store", "retry-after": "1" }, + status: 409, + }, + ); + } + const errorId = logError(log, "session-delete request failed", error); + return Response.json( + { error: "Failed to delete the session.", errorId, ok: false }, + { status: 500 }, + ); + } + }), + GET("/eve/v1/session/:sessionId/stream", async (req, { getSession, params }) => { const authResult = await routeAuth(req, input.auth); if (authResult instanceof Response) return authResult; @@ -464,6 +530,21 @@ export function eveChannel(input: EveChannelInput): EveChannel { }); } +function isPurgeConflict(error: unknown): boolean { + return EntityConflictError.is(error); +} + +function normalizeMaintenanceAuth( + auth: EveChannelInput["maintenanceAuth"], +): JwtAuthFn | readonly JwtAuthFn[] | null { + if (auth === undefined) return null; + const entries = Array.isArray(auth) ? auth : [auth]; + if (entries.length === 0 || !entries.every((entry) => isJwtAuthFn(entry))) { + return null; + } + return auth; +} + function normalizeEveCors(cors: EveChannelCors | undefined): ChannelCors { if (cors === undefined || cors === false) { return false; diff --git a/packages/eve/src/public/definitions/channel.ts b/packages/eve/src/public/definitions/channel.ts index a96d8d01d..e8daebf52 100644 --- a/packages/eve/src/public/definitions/channel.ts +++ b/packages/eve/src/public/definitions/channel.ts @@ -9,6 +9,7 @@ import type { Session, SessionHandle } from "#channel/session.js"; import type { CancelTurnInput, CancelTurnResult, + DeleteSessionResult, DeliverInput, DeliverPayload, GetEventStreamOptions, @@ -140,6 +141,8 @@ export interface Agent { * Both outcomes are successful. */ cancelTurn(input: CancelTurnInput): Promise; + /** Framework maintenance operation for permanent session-tree deletion. */ + deleteSession?(sessionId: string): Promise; /** * Sends a follow-up message to a session that is currently parked waiting * for input. Throws if no parked session exists for the supplied