Skip to content
Draft
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
7 changes: 7 additions & 0 deletions .changeset/calm-rabbits-delete-sessions.md
Original file line number Diff line number Diff line change
@@ -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.
10 changes: 10 additions & 0 deletions docs/channels/eve.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -54,6 +55,15 @@ curl -X POST https://<deployment>/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
Expand Down
8 changes: 8 additions & 0 deletions packages/eve/src/channel/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -398,6 +403,9 @@ export interface Runtime {
/** Terminally retires a session and releases its non-retained continuation hooks. */
terminateSession(input: TerminateSessionInput): Promise<TerminateSessionResult>;

/** Permanently deletes a session and all descendant workflow-owned state. */
deleteSession?(sessionId: string): Promise<DeleteSessionResult>;

/**
* Delivers a follow-up message to a parked session.
*/
Expand Down
61 changes: 61 additions & 0 deletions packages/eve/src/execution/workflow-runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand Down
37 changes: 37 additions & 0 deletions packages/eve/src/execution/workflow-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
import type {
CancelTurnInput,
CancelTurnResult,
DeleteSessionResult,
DeliverInput,
GetEventStreamOptions,
HookPayload,
Expand Down Expand Up @@ -193,6 +194,42 @@ export function createWorkflowRuntime(config: {
}
},

async deleteSession(sessionId: string): Promise<DeleteSessionResult> {
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" }>;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unsupported Workflow World (no runTreePurge capability) is reported to clients as a retryable 409 Conflict instead of 501 Unavailable, causing maintenance clients to retry a request that can never succeed.

Fix on Vercel

};
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<HookPayload, { kind: "deliver" }> = {
auth: input.auth,
Expand Down
8 changes: 8 additions & 0 deletions packages/eve/src/internal/nitro/routes/channel-dispatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
2 changes: 2 additions & 0 deletions packages/eve/src/internal/testing/route-harness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export interface MockAgent extends Agent {
readonly cancelTurn: Mock;
readonly run: Mock;
readonly deliver: Mock;
readonly deleteSession: Mock;
readonly getEventStream: Mock;
}

Expand All @@ -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",
Expand Down
11 changes: 11 additions & 0 deletions packages/eve/src/protocol/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down
64 changes: 48 additions & 16 deletions packages/eve/src/public/channels/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -496,6 +496,34 @@ export type AuthFn<TEvent = Request> = (
event: TEvent,
) => SessionAuthContext | null | undefined | Promise<SessionAuthContext | null | undefined>;

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<Request> & {
readonly __eveBuiltInJwtAuth: never;
};

/** @internal */
export function isJwtAuthFn(fn: AuthFn<Request>): fn is JwtAuthFn {
return (
(
fn as AuthFn<Request> & {
readonly [JWT_AUTH_FN_SYMBOL]?: boolean;
}
)[JWT_AUTH_FN_SYMBOL] === true
);
}

function markJwtAuthFn(fn: AuthFn<Request>): 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}.
Expand Down Expand Up @@ -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<Request> {
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" }],
),
);
}

Expand All @@ -1083,14 +1113,16 @@ export function jwtHmac(config: VerifyJwtHmacConfig): AuthFn<Request> {
* {@link verifyJwtEcdsa}. Declares a `Bearer` {@link withAuthChallenges}
* challenge for {@link routeAuth}'s 401.
*/
export function jwtEcdsa(config: VerifyJwtEcdsaConfig): AuthFn<Request> {
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" }],
),
);
}

Expand Down
Loading