From 72b79ab53cc0247697412c5599b13123da457dfe Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Wed, 24 Jun 2026 10:04:14 -0500 Subject: [PATCH 1/8] feat(chat): re-point /api/chat/generate onto runAgentWorkflow (chat#1813) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Async chat generation now runs on the SAME durable runAgentWorkflow as interactive /api/chat instead of the synchronous legacy ToolLoopAgent. POST /api/chat/generate provisions a headless session + active sandbox, mints a short-lived account-scoped recoup_sk_ key for in-sandbox recoup-api calls, builds the shared workflow input via buildRunAgentInput, and start()s the run — returning { runId } with 202 immediately. Generation, message persistence, the credit charge, and key revocation happen server-side inside the workflow. - lib/keys/mintEphemeralAccountKey + insertApiKey expires_at writer (re-added from the deferred half of #700; minting now has its only consumer). - lib/chat/generate/validateGenerateRequest — x-api-key auth + prompt/messages normalization to UIMessage[]. - lib/chat/generate/provisionGenerateSession — ensurePersonalRepo → insertSession → insertChat → connectSandbox → updateSession(active) → discoverSkills. - lib/chat/handleChatGenerate — orchestrates provision → mint → start; revokes the key if the run never starts. - Ephemeral key injected as recoupAccessToken + threaded as agentContext.ephemeralKeyId; runAgentWorkflow's finally deletes it on run end (deleteEphemeralKeyStep). The ~15m expires_at TTL (enforced by #700) is the backstop. - Matches docs#249 (202 { runId } contract). Co-Authored-By: Claude Opus 4.8 (1M context) --- app/api/chat/generate/route.ts | 33 +- .../__tests__/runAgentWorkflow.test.ts | 35 + app/lib/workflows/deleteEphemeralKeyStep.ts | 25 + app/lib/workflows/runAgentWorkflow.ts | 13 +- lib/agent/tools/AgentContext.ts | 9 + lib/chat/__tests__/handleChatGenerate.test.ts | 654 +++--------------- lib/chat/buildRunAgentInput.ts | 7 + .../__tests__/validateGenerateRequest.test.ts | 80 +++ lib/chat/generate/provisionGenerateSession.ts | 109 +++ lib/chat/generate/validateGenerateRequest.ts | 90 +++ lib/chat/handleChatGenerate.ts | 128 ++-- .../__tests__/mintEphemeralAccountKey.test.ts | 44 ++ lib/keys/mintEphemeralAccountKey.ts | 45 ++ lib/supabase/account_api_keys/insertApiKey.ts | 3 + 14 files changed, 637 insertions(+), 638 deletions(-) create mode 100644 app/lib/workflows/deleteEphemeralKeyStep.ts create mode 100644 lib/chat/generate/__tests__/validateGenerateRequest.test.ts create mode 100644 lib/chat/generate/provisionGenerateSession.ts create mode 100644 lib/chat/generate/validateGenerateRequest.ts create mode 100644 lib/keys/__tests__/mintEphemeralAccountKey.test.ts create mode 100644 lib/keys/mintEphemeralAccountKey.ts diff --git a/app/api/chat/generate/route.ts b/app/api/chat/generate/route.ts index 0176d0eee..2f8477e05 100644 --- a/app/api/chat/generate/route.ts +++ b/app/api/chat/generate/route.ts @@ -18,32 +18,33 @@ export async function OPTIONS() { /** * POST /api/chat/generate * - * Non-streaming chat endpoint that processes messages and returns a JSON response. + * Asynchronous, headless chat generation on the durable `runAgentWorkflow` + * (recoupable/chat#1813). Provisions a session + sandbox, starts a workflow run, + * and returns `{ runId }` with **202** immediately — generation, assistant- + * message persistence, and side effects happen server-side after the response. * - * Authentication: x-api-key header required. - * The account ID is inferred from the API key. + * Authentication: x-api-key header required (account inferred from the key; + * org keys may override via body `accountId`). * * Request body: - * - messages: Array of chat messages (mutually exclusive with prompt) * - prompt: String prompt (mutually exclusive with messages) - * - roomId: Optional UUID of the chat room - * - topic: Optional topic for new chat room (ignored if room already exists) + * - messages: Array of UIMessages (mutually exclusive with prompt) * - artistId: Optional UUID of the artist account - * - model: Optional model ID override - * - excludeTools: Optional array of tool names to exclude + * - model: Optional model ID override (default anthropic/claude-haiku-4.5) + * - topic: Optional session title * - accountId: Optional accountId override (requires org API key) * - * Response body: - * - text: The generated text response - * - reasoningText: Optional reasoning text (for models that support it) - * - sources: Array of sources used in generation - * - finishReason: The reason generation finished - * - usage: Token usage information - * - response: Additional response metadata + * Response body (202): `{ runId }` — the durable workflow run id. * * @param request - The request object - * @returns A JSON response with the generated text or error + * @returns 202 `{ runId }`, or a 4xx/5xx error */ export async function POST(request: NextRequest): Promise { return handleChatGenerate(request); } + +// Provisioning (repo + session + sandbox) runs before the 202 returns, so give +// the function headroom beyond the default. The workflow itself runs durably +// outside this request. +export const maxDuration = 120; +export const dynamic = "force-dynamic"; diff --git a/app/lib/workflows/__tests__/runAgentWorkflow.test.ts b/app/lib/workflows/__tests__/runAgentWorkflow.test.ts index c49adfe6f..be1adf5d0 100644 --- a/app/lib/workflows/__tests__/runAgentWorkflow.test.ts +++ b/app/lib/workflows/__tests__/runAgentWorkflow.test.ts @@ -6,7 +6,11 @@ import { closeChatStream } from "@/app/lib/workflows/closeChatStream"; import { generateAssistantMessageId } from "@/app/lib/workflows/generateAssistantMessageId"; import { handleChatCredits } from "@/lib/credits/handleChatCredits"; import { autoCommitChatTurn } from "@/lib/chat/auto-commit/autoCommitChatTurn"; +import { deleteEphemeralKeyStep } from "@/app/lib/workflows/deleteEphemeralKeyStep"; +vi.mock("@/app/lib/workflows/deleteEphemeralKeyStep", () => ({ + deleteEphemeralKeyStep: vi.fn(), +})); vi.mock("@/app/lib/workflows/runAgentStep", () => ({ runAgentStep: vi.fn(), })); @@ -90,6 +94,37 @@ describe("runAgentWorkflow", () => { expect(clearChatActiveStream).toHaveBeenCalledWith("chat-1", "wrun_from_metadata"); }); + it("deletes the ephemeral key on run end when agentContext.ephemeralKeyId is set (headless)", async () => { + vi.mocked(runAgentStep).mockResolvedValue({ + finishReason: "stop", + aborted: false, + responseMessage: undefined, + }); + + await runAgentWorkflow({ + ...baseInput, + agentContext: { + sandbox: { state: { type: "vercel" }, workingDirectory: "/sandbox/mono" }, + ephemeralKeyId: "ephem-key-1", + } as never, + }); + + expect(deleteEphemeralKeyStep).toHaveBeenCalledTimes(1); + expect(deleteEphemeralKeyStep).toHaveBeenCalledWith("ephem-key-1"); + }); + + it("does NOT delete a key for the interactive path (no ephemeralKeyId)", async () => { + vi.mocked(runAgentStep).mockResolvedValue({ + finishReason: "stop", + aborted: false, + responseMessage: undefined, + }); + + await runAgentWorkflow(baseInput); + + expect(deleteEphemeralKeyStep).not.toHaveBeenCalled(); + }); + it("explicitly closes the chat writable after a successful run so SSE ends promptly", async () => { vi.mocked(runAgentStep).mockResolvedValue({ finishReason: "stop", diff --git a/app/lib/workflows/deleteEphemeralKeyStep.ts b/app/lib/workflows/deleteEphemeralKeyStep.ts new file mode 100644 index 000000000..1b6fac545 --- /dev/null +++ b/app/lib/workflows/deleteEphemeralKeyStep.ts @@ -0,0 +1,25 @@ +import { deleteApiKey } from "@/lib/supabase/account_api_keys/deleteApiKey"; + +/** + * Vercel Workflow `"use step"` that deletes the ephemeral, account-scoped + * `recoup_sk_…` key minted for a headless `/api/chat/generate` run + * (recoupable/chat#1813). Called from `runAgentWorkflow`'s `finally` so the + * credential is revoked the moment the run ends — the key's ~15m `expires_at` + * TTL (enforced in `getApiKeyAccountId`) is only the backstop if this is missed. + * + * Defensively swallows its own errors: a cleanup hiccup must not fail the run, + * and the TTL still guarantees the key can't outlive its window. + * + * @param keyId - `account_api_keys.id` of the ephemeral key to delete. + */ +export async function deleteEphemeralKeyStep(keyId: string): Promise { + "use step"; + try { + const { error } = await deleteApiKey(keyId); + if (error) { + console.error(`[deleteEphemeralKeyStep] failed to delete key ${keyId}:`, error); + } + } catch (error) { + console.error(`[deleteEphemeralKeyStep] unhandled error deleting key ${keyId}:`, error); + } +} diff --git a/app/lib/workflows/runAgentWorkflow.ts b/app/lib/workflows/runAgentWorkflow.ts index c6642161a..862e6ef12 100644 --- a/app/lib/workflows/runAgentWorkflow.ts +++ b/app/lib/workflows/runAgentWorkflow.ts @@ -4,6 +4,7 @@ import { closeChatStream } from "@/app/lib/workflows/closeChatStream"; import { generateAssistantMessageId } from "@/app/lib/workflows/generateAssistantMessageId"; import { runAgentStep } from "@/app/lib/workflows/runAgentStep"; import { clearChatActiveStream } from "@/lib/chat/clearChatActiveStream"; +import { deleteEphemeralKeyStep } from "@/app/lib/workflows/deleteEphemeralKeyStep"; import { handleChatCredits } from "@/lib/credits/handleChatCredits"; import { autoCommitChatTurn } from "@/lib/chat/auto-commit/autoCommitChatTurn"; import type { AgentMessageMetadata } from "@/lib/agent/messageMetadata/AgentMessageMetadata"; @@ -151,11 +152,19 @@ export async function runAgentWorkflow(input: RunAgentWorkflowInput): Promise ({ - ensureCreditsOrShortCircuit: vi.fn().mockResolvedValue(null), +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest, NextResponse } from "next/server"; + +import { handleChatGenerate } from "@/lib/chat/handleChatGenerate"; +import { validateGenerateRequest } from "@/lib/chat/generate/validateGenerateRequest"; +import { provisionGenerateSession } from "@/lib/chat/generate/provisionGenerateSession"; +import { mintEphemeralAccountKey } from "@/lib/keys/mintEphemeralAccountKey"; +import { deleteApiKey } from "@/lib/supabase/account_api_keys/deleteApiKey"; +import { buildRunAgentInput } from "@/lib/chat/buildRunAgentInput"; +import { start } from "workflow/api"; + +vi.mock("@/lib/networking/getCorsHeaders", () => ({ + getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })), })); - -// Mock all dependencies before importing the module under test -vi.mock("@/lib/auth/validateAuthContext", () => ({ - validateAuthContext: vi.fn(), +vi.mock("@/lib/chat/generate/validateGenerateRequest", () => ({ + validateGenerateRequest: vi.fn(), })); - -vi.mock("@/lib/chat/setupChatRequest", () => ({ - setupChatRequest: vi.fn(), +vi.mock("@/lib/chat/generate/provisionGenerateSession", () => ({ + provisionGenerateSession: vi.fn(), })); - -vi.mock("@/lib/chat/saveChatCompletion", () => ({ - saveChatCompletion: vi.fn(), +vi.mock("@/lib/keys/mintEphemeralAccountKey", () => ({ + mintEphemeralAccountKey: vi.fn(), })); - -vi.mock("@/lib/uuid/generateUUID", () => { - const mockFn = vi.fn(() => "auto-generated-room-id"); - return { - generateUUID: mockFn, - default: mockFn, - }; -}); - -vi.mock("@/lib/chat/createNewRoom", () => ({ - createNewRoom: vi.fn(), +vi.mock("@/lib/supabase/account_api_keys/deleteApiKey", () => ({ + deleteApiKey: vi.fn(async () => ({ error: null })), })); - -vi.mock("@/lib/supabase/memories/insertMemories", () => ({ - default: vi.fn(), -})); - -vi.mock("@/lib/messages/filterMessageContentForMemories", () => ({ - default: vi.fn((msg: unknown) => msg), +vi.mock("@/lib/chat/buildRunAgentInput", () => ({ + buildRunAgentInput: vi.fn(x => ({ built: true, ...x })), })); - -vi.mock("@/lib/chat/setupConversation", () => ({ - setupConversation: vi.fn(), +vi.mock("workflow/api", () => ({ + start: vi.fn(), })); +vi.mock("@/app/lib/workflows/runAgentWorkflow", () => ({ runAgentWorkflow: vi.fn() })); -const mockValidateAuthContext = vi.mocked(validateAuthContext); -const mockSetupChatRequest = vi.mocked(setupChatRequest); -const mockSaveChatCompletion = vi.mocked(saveChatCompletion); -const mockSetupConversation = vi.mocked(setupConversation); - -// Helper to create a mock agent with .generate() -/** - * - * @param generateResult - */ -function createMockAgent(generateResult: Record) { - return { - generate: vi.fn().mockResolvedValue(generateResult), - stream: vi.fn(), - tools: {}, - }; -} +const req = () => + new NextRequest("https://x.test/api/chat/generate", { + method: "POST", + headers: { "content-type": "application/json", "x-api-key": "recoup_sk_test" }, + body: JSON.stringify({ prompt: "go" }), + }); -// Helper to create mock NextRequest -/** - * - * @param body - * @param headers - */ -function createMockRequest(body: unknown, headers: Record = {}): Request { - return { - json: () => Promise.resolve(body), - headers: { - get: (key: string) => headers[key.toLowerCase()] || null, - has: (key: string) => key.toLowerCase() in headers, - }, - } as unknown as Request; -} +const validated = { + accountId: "acc-1", + orgId: null, + messages: [{ id: "m1", role: "user", parts: [{ type: "text", text: "go" }] }], + artistId: undefined, + modelId: "anthropic/claude-haiku-4.5", + sessionTitle: undefined, +}; + +const provisioned = { + session: { + id: "sess-1", + clone_url: "https://github.com/recoupable/acc-1", + title: "Scheduled generation", + }, + chat: { id: "chat-1" }, + sandboxState: { type: "vercel", sandboxName: "session-sess-1" }, + workingDirectory: "/vercel/sandbox", + skills: [], +}; describe("handleChatGenerate", () => { beforeEach(() => { vi.clearAllMocks(); - // Default mock for setupConversation - mockSetupConversation.mockResolvedValue({ - roomId: "auto-generated-room-id", - memoryId: "auto-generated-memory-id", + vi.mocked(validateGenerateRequest).mockResolvedValue(validated as never); + vi.mocked(provisionGenerateSession).mockResolvedValue(provisioned as never); + vi.mocked(mintEphemeralAccountKey).mockResolvedValue({ + rawKey: "recoup_sk_raw", + keyId: "key-1", }); + vi.mocked(start).mockResolvedValue({ runId: "wrun_abc" } as never); }); - afterEach(() => { - vi.restoreAllMocks(); + it("provisions, mints, starts the workflow, and returns 202 { runId }", async () => { + const res = await handleChatGenerate(req()); + expect(res.status).toBe(202); + expect(await res.json()).toEqual({ runId: "wrun_abc" }); + + expect(provisionGenerateSession).toHaveBeenCalledWith( + expect.objectContaining({ accountId: "acc-1", title: "Scheduled generation" }), + ); + // the minted key is injected as recoupAccessToken AND threaded as ephemeralKeyId + expect(buildRunAgentInput).toHaveBeenCalledWith( + expect.objectContaining({ + chatId: "chat-1", + sessionId: "sess-1", + recoupAccessToken: "recoup_sk_raw", + ephemeralKeyId: "key-1", + }), + ); + expect(start).toHaveBeenCalledOnce(); + // key is NOT deleted here — the workflow's finally owns that on run end + expect(deleteApiKey).not.toHaveBeenCalled(); }); - describe("validation", () => { - it("returns 400 error when neither messages nor prompt is provided", async () => { - mockValidateAuthContext.mockResolvedValue({ - accountId: "account-123", - orgId: null, - authToken: "token", - }); - - const request = createMockRequest({ roomId: "room-123" }, { "x-api-key": "test-key" }); - - const result = await handleChatGenerate(request as any); - - expect(result).toBeInstanceOf(NextResponse); - expect(result.status).toBe(400); - const json = await result.json(); - expect(json.status).toBe("error"); - }); - - it("returns 401 error when no auth header is provided", async () => { - mockValidateAuthContext.mockResolvedValue( - NextResponse.json({ status: "error", error: "Unauthorized" }, { status: 401 }), - ); - const request = createMockRequest({ prompt: "Hello" }, {}); - - const result = await handleChatGenerate(request as any); - - expect(result).toBeInstanceOf(NextResponse); - expect(result.status).toBe(401); - }); + it("returns the validation error short-circuit", async () => { + vi.mocked(validateGenerateRequest).mockResolvedValue( + NextResponse.json({ status: "error" }, { status: 401 }), + ); + const res = await handleChatGenerate(req()); + expect(res.status).toBe(401); + expect(provisionGenerateSession).not.toHaveBeenCalled(); }); - describe("text generation", () => { - it("returns generated text using agent.generate() for valid requests", async () => { - mockValidateAuthContext.mockResolvedValue({ - accountId: "account-123", - orgId: null, - authToken: "token", - }); - - const mockAgent = createMockAgent({ - text: "Hello! How can I help you?", - reasoningText: undefined, - sources: [], - finishReason: "stop", - usage: { promptTokens: 10, completionTokens: 20 }, - response: { - messages: [], - headers: {}, - body: null, - }, - }); - - mockSetupChatRequest.mockResolvedValue({ - agent: mockAgent, - messages: [], - } as any); - - const request = createMockRequest({ prompt: "Hello, world!" }, { "x-api-key": "valid-key" }); - - const result = await handleChatGenerate(request as any); - - expect(mockAgent.generate).toHaveBeenCalled(); - expect(result.status).toBe(200); - const json = await result.json(); - expect(json.text).toBe("Hello! How can I help you?"); - expect(json.finishReason).toBe("stop"); - expect(json.usage).toEqual({ promptTokens: 10, completionTokens: 20 }); - }); - - it("uses messages array when provided", async () => { - mockValidateAuthContext.mockResolvedValue({ - accountId: "account-123", - orgId: null, - authToken: "token", - }); - - const mockAgent = createMockAgent({ - text: "Response", - finishReason: "stop", - usage: { promptTokens: 10, completionTokens: 20 }, - response: { messages: [], headers: {}, body: null }, - }); - - mockSetupChatRequest.mockResolvedValue({ - agent: mockAgent, - messages: [], - } as any); - - const messages = [{ role: "user", content: "Hello" }]; - const request = createMockRequest({ messages }, { "x-api-key": "valid-key" }); - - await handleChatGenerate(request as any); - - expect(mockSetupChatRequest).toHaveBeenCalledWith( - expect.objectContaining({ - messages, - accountId: "account-123", - }), - ); - }); - - it("passes through optional parameters", async () => { - mockValidateAuthContext.mockResolvedValue({ - accountId: "account-123", - orgId: null, - authToken: "token", - }); - mockSetupConversation.mockResolvedValue({ - roomId: "room-xyz", - memoryId: "memory-id", - }); - - const mockAgent = createMockAgent({ - text: "Response", - finishReason: "stop", - usage: { promptTokens: 10, completionTokens: 20 }, - response: { messages: [], headers: {}, body: null }, - }); - - mockSetupChatRequest.mockResolvedValue({ - agent: mockAgent, - messages: [], - } as any); - - const request = createMockRequest( - { - prompt: "Hello", - roomId: "room-xyz", - artistId: "artist-abc", - model: "claude-3-opus", - excludeTools: ["tool1"], - }, - { "x-api-key": "valid-key" }, - ); - - await handleChatGenerate(request as any); - - expect(mockSetupChatRequest).toHaveBeenCalledWith( - expect.objectContaining({ - roomId: "room-xyz", - artistId: "artist-abc", - model: "claude-3-opus", - excludeTools: ["tool1"], - }), - ); - }); - - it("includes reasoningText when present", async () => { - mockValidateAuthContext.mockResolvedValue({ - accountId: "account-123", - orgId: null, - authToken: "token", - }); - - const mockAgent = createMockAgent({ - text: "Response", - reasoningText: "Let me think about this...", - sources: [{ url: "https://example.com" }], - finishReason: "stop", - usage: { promptTokens: 10, completionTokens: 20 }, - response: { messages: [], headers: {}, body: null }, - }); - - mockSetupChatRequest.mockResolvedValue({ - agent: mockAgent, - messages: [], - } as any); - - const request = createMockRequest({ prompt: "Hello" }, { "x-api-key": "valid-key" }); - - const result = await handleChatGenerate(request as any); - - expect(result.status).toBe(200); - const json = await result.json(); - expect(json.reasoningText).toBe("Let me think about this..."); - expect(json.sources).toEqual([{ url: "https://example.com" }]); - }); + it("revokes the minted key and 500s when start() fails", async () => { + vi.mocked(start).mockRejectedValue(new Error("workflow start boom")); + const res = await handleChatGenerate(req()); + expect(res.status).toBe(500); + expect(deleteApiKey).toHaveBeenCalledWith("key-1"); }); - describe("error handling", () => { - it("returns 500 error when setupChatRequest fails", async () => { - mockValidateAuthContext.mockResolvedValue({ - accountId: "account-123", - orgId: null, - authToken: "token", - }); - mockSetupChatRequest.mockRejectedValue(new Error("Setup failed")); - - const request = createMockRequest({ prompt: "Hello" }, { "x-api-key": "valid-key" }); - - const result = await handleChatGenerate(request as any); - - expect(result).toBeInstanceOf(NextResponse); - expect(result.status).toBe(500); - const json = await result.json(); - expect(json.status).toBe("error"); - }); - - it("returns 500 error when agent.generate() fails", async () => { - mockValidateAuthContext.mockResolvedValue({ - accountId: "account-123", - orgId: null, - authToken: "token", - }); - - const mockAgent = { - generate: vi.fn().mockRejectedValue(new Error("Generation failed")), - stream: vi.fn(), - tools: {}, - }; - - mockSetupChatRequest.mockResolvedValue({ - agent: mockAgent, - messages: [], - } as any); - - const request = createMockRequest({ prompt: "Hello" }, { "x-api-key": "valid-key" }); - - const result = await handleChatGenerate(request as any); - - expect(result).toBeInstanceOf(NextResponse); - expect(result.status).toBe(500); - const json = await result.json(); - expect(json.status).toBe("error"); - }); - }); - - describe("accountId override", () => { - it("allows accountId override", async () => { - mockValidateAuthContext.mockResolvedValue({ - accountId: "target-account-456", - orgId: null, - authToken: "token", - }); - - const mockAgent = createMockAgent({ - text: "Response", - finishReason: "stop", - usage: { promptTokens: 10, completionTokens: 20 }, - response: { messages: [], headers: {}, body: null }, - }); - - mockSetupChatRequest.mockResolvedValue({ - agent: mockAgent, - messages: [], - } as any); - - const request = createMockRequest( - { prompt: "Hello", accountId: "target-account-456" }, - { "x-api-key": "org-api-key" }, - ); - - await handleChatGenerate(request as any); - - expect(mockSetupChatRequest).toHaveBeenCalledWith( - expect.objectContaining({ - accountId: "target-account-456", - }), - ); - }); - }); - - describe("message persistence", () => { - it("saves assistant message to database when roomId is provided", async () => { - mockValidateAuthContext.mockResolvedValue({ - accountId: "account-123", - orgId: null, - authToken: "token", - }); - mockSetupConversation.mockResolvedValue({ - roomId: "room-abc-123", - memoryId: "memory-id", - }); - - const mockAgent = createMockAgent({ - text: "Hello! How can I help you?", - finishReason: "stop", - usage: { promptTokens: 10, completionTokens: 20 }, - response: { messages: [], headers: {}, body: null }, - }); - - mockSetupChatRequest.mockResolvedValue({ - agent: mockAgent, - messages: [], - } as any); - - mockSaveChatCompletion.mockResolvedValue(null); - - const request = createMockRequest( - { prompt: "Hello", roomId: "room-abc-123" }, - { "x-api-key": "valid-key" }, - ); - - await handleChatGenerate(request as any); - - expect(mockSaveChatCompletion).toHaveBeenCalledWith({ - text: "Hello! How can I help you?", - roomId: "room-abc-123", - }); - }); - - it("saves message with auto-generated roomId when roomId is not provided", async () => { - mockValidateAuthContext.mockResolvedValue({ - accountId: "account-123", - orgId: null, - authToken: "token", - }); - mockSetupConversation.mockResolvedValue({ - roomId: "auto-generated-room-id", - memoryId: "memory-id", - }); - - const mockAgent = createMockAgent({ - text: "Response", - finishReason: "stop", - usage: { promptTokens: 10, completionTokens: 20 }, - response: { messages: [], headers: {}, body: null }, - }); - - mockSetupChatRequest.mockResolvedValue({ - agent: mockAgent, - messages: [], - } as any); - - mockSaveChatCompletion.mockResolvedValue(null); - - const request = createMockRequest({ prompt: "Hello" }, { "x-api-key": "valid-key" }); - - await handleChatGenerate(request as any); - - // Since roomId is auto-created, saveChatCompletion should be called - expect(mockSaveChatCompletion).toHaveBeenCalledWith({ - text: "Response", - roomId: "auto-generated-room-id", - }); - }); - - it("includes roomId in HTTP response when provided by client", async () => { - mockValidateAuthContext.mockResolvedValue({ - accountId: "account-123", - orgId: null, - authToken: "token", - }); - mockSetupConversation.mockResolvedValue({ - roomId: "client-provided-room-id", - memoryId: "memory-id", - }); - - const mockAgent = createMockAgent({ - text: "Response", - finishReason: "stop", - usage: { promptTokens: 10, completionTokens: 20 }, - response: { messages: [], headers: {}, body: null }, - }); - - mockSetupChatRequest.mockResolvedValue({ - agent: mockAgent, - messages: [], - } as any); - - mockSaveChatCompletion.mockResolvedValue(null); - - const request = createMockRequest( - { prompt: "Hello", roomId: "client-provided-room-id" }, - { "x-api-key": "valid-key" }, - ); - - const result = await handleChatGenerate(request as any); - - expect(result.status).toBe(200); - const json = await result.json(); - expect(json.roomId).toBe("client-provided-room-id"); - }); - - it("includes auto-generated roomId in HTTP response when not provided", async () => { - mockValidateAuthContext.mockResolvedValue({ - accountId: "account-123", - orgId: null, - authToken: "token", - }); - mockSetupConversation.mockResolvedValue({ - roomId: "auto-generated-room-456", - memoryId: "memory-id", - }); - - const mockAgent = createMockAgent({ - text: "Response", - finishReason: "stop", - usage: { promptTokens: 10, completionTokens: 20 }, - response: { messages: [], headers: {}, body: null }, - }); - - mockSetupChatRequest.mockResolvedValue({ - agent: mockAgent, - messages: [], - } as any); - - mockSaveChatCompletion.mockResolvedValue(null); - - const request = createMockRequest({ prompt: "Hello" }, { "x-api-key": "valid-key" }); - - const result = await handleChatGenerate(request as any); - - expect(result.status).toBe(200); - const json = await result.json(); - expect(json.roomId).toBe("auto-generated-room-456"); - }); - - it("passes correct text to saveChatCompletion", async () => { - mockValidateAuthContext.mockResolvedValue({ - accountId: "account-123", - orgId: null, - authToken: "token", - }); - mockSetupConversation.mockResolvedValue({ - roomId: "room-xyz", - memoryId: "memory-id", - }); - - const mockAgent = createMockAgent({ - text: "This is the assistant response text", - finishReason: "stop", - usage: { promptTokens: 10, completionTokens: 20 }, - response: { messages: [], headers: {}, body: null }, - }); - - mockSetupChatRequest.mockResolvedValue({ - agent: mockAgent, - messages: [], - } as any); - - mockSaveChatCompletion.mockResolvedValue(null); - - const request = createMockRequest( - { prompt: "Hello", roomId: "room-xyz" }, - { "x-api-key": "valid-key" }, - ); - - await handleChatGenerate(request as any); - - expect(mockSaveChatCompletion).toHaveBeenCalledWith({ - text: "This is the assistant response text", - roomId: "room-xyz", - }); - }); - - it("still returns success response even if saveChatCompletion fails", async () => { - mockValidateAuthContext.mockResolvedValue({ - accountId: "account-123", - orgId: null, - authToken: "token", - }); - mockSetupConversation.mockResolvedValue({ - roomId: "room-abc", - memoryId: "memory-id", - }); - - const mockAgent = createMockAgent({ - text: "Response", - finishReason: "stop", - usage: { promptTokens: 10, completionTokens: 20 }, - response: { messages: [], headers: {}, body: null }, - }); - - mockSetupChatRequest.mockResolvedValue({ - agent: mockAgent, - messages: [], - } as any); - - mockSaveChatCompletion.mockRejectedValue(new Error("Database error")); - - const request = createMockRequest( - { prompt: "Hello", roomId: "room-abc" }, - { "x-api-key": "valid-key" }, - ); - - const result = await handleChatGenerate(request as any); - - expect(result.status).toBe(200); - const json = await result.json(); - expect(json.text).toBe("Response"); - }); + it("does not mint or delete a key when provisioning fails", async () => { + vi.mocked(provisionGenerateSession).mockRejectedValue(new Error("repo boom")); + const res = await handleChatGenerate(req()); + expect(res.status).toBe(500); + expect(mintEphemeralAccountKey).not.toHaveBeenCalled(); + expect(deleteApiKey).not.toHaveBeenCalled(); }); }); diff --git a/lib/chat/buildRunAgentInput.ts b/lib/chat/buildRunAgentInput.ts index 95b0df423..ad5189c03 100644 --- a/lib/chat/buildRunAgentInput.ts +++ b/lib/chat/buildRunAgentInput.ts @@ -23,6 +23,11 @@ export type BuildRunAgentInputParams = { * `/api/chat/generate`). Omitted when absent so the service key never leaks. */ recoupAccessToken?: string; + /** + * Row id of an ephemeral key minted for a headless run, so the workflow can + * delete it on run end (recoupable/chat#1813). Interactive callers omit it. + */ + ephemeralKeyId?: string; }; /** @@ -43,6 +48,7 @@ export function buildRunAgentInput({ workingDirectory, skills, recoupAccessToken, + ephemeralKeyId, }: BuildRunAgentInputParams): RunAgentWorkflowInput { const repoIds = parseGitHubRepoIdentifiers(cloneUrl); const recoupOrgId = cloneUrl ? (extractOrgId(cloneUrl) ?? undefined) : undefined; @@ -61,6 +67,7 @@ export function buildRunAgentInput({ recoupOrgId, skills, ...(recoupAccessToken ? { recoupAccessToken } : {}), + ...(ephemeralKeyId ? { ephemeralKeyId } : {}), }, }; } diff --git a/lib/chat/generate/__tests__/validateGenerateRequest.test.ts b/lib/chat/generate/__tests__/validateGenerateRequest.test.ts new file mode 100644 index 000000000..4883e1a0f --- /dev/null +++ b/lib/chat/generate/__tests__/validateGenerateRequest.test.ts @@ -0,0 +1,80 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest, NextResponse } from "next/server"; + +import { validateGenerateRequest } from "@/lib/chat/generate/validateGenerateRequest"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; + +vi.mock("@/lib/networking/getCorsHeaders", () => ({ + getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })), +})); + +vi.mock("@/lib/auth/validateAuthContext", () => ({ + validateAuthContext: vi.fn(), +})); + +function req(body: unknown): NextRequest { + return new NextRequest("https://x.test/api/chat/generate", { + method: "POST", + headers: { "content-type": "application/json", "x-api-key": "recoup_sk_test" }, + body: JSON.stringify(body), + }); +} + +const okAuth = { accountId: "acc-1", orgId: null, authToken: "recoup_sk_test" }; + +describe("validateGenerateRequest", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(validateAuthContext).mockResolvedValue(okAuth); + }); + + it("converts a prompt into a single user UIMessage", async () => { + const result = await validateGenerateRequest(req({ prompt: "weekly report please" })); + expect(result).not.toBeInstanceOf(NextResponse); + if (result instanceof NextResponse) return; + expect(result.accountId).toBe("acc-1"); + expect(result.messages).toHaveLength(1); + expect(result.messages[0].role).toBe("user"); + expect(JSON.stringify(result.messages[0].parts)).toContain("weekly report please"); + }); + + it("passes messages through and applies the model override + default", async () => { + const withModel = await validateGenerateRequest( + req({ + messages: [{ id: "m1", role: "user", parts: [{ type: "text", text: "hi" }] }], + model: "anthropic/claude-opus-4-8", + }), + ); + if (withModel instanceof NextResponse) throw new Error("unexpected error"); + expect(withModel.modelId).toBe("anthropic/claude-opus-4-8"); + + const noModel = await validateGenerateRequest(req({ prompt: "hi" })); + if (noModel instanceof NextResponse) throw new Error("unexpected error"); + expect(noModel.modelId).toBe("anthropic/claude-haiku-4.5"); + }); + + it("rejects when neither prompt nor messages is provided (400)", async () => { + const result = await validateGenerateRequest(req({ artistId: "a1" })); + expect(result).toBeInstanceOf(NextResponse); + if (!(result instanceof NextResponse)) return; + expect(result.status).toBe(400); + }); + + it("returns the auth error response when auth fails", async () => { + vi.mocked(validateAuthContext).mockResolvedValue( + NextResponse.json({ status: "error" }, { status: 401 }), + ); + const result = await validateGenerateRequest(req({ prompt: "hi" })); + expect(result).toBeInstanceOf(NextResponse); + if (!(result instanceof NextResponse)) return; + expect(result.status).toBe(401); + }); + + it("forwards body accountId override to validateAuthContext", async () => { + await validateGenerateRequest(req({ prompt: "hi", accountId: "member-acc" })); + expect(validateAuthContext).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ accountId: "member-acc" }), + ); + }); +}); diff --git a/lib/chat/generate/provisionGenerateSession.ts b/lib/chat/generate/provisionGenerateSession.ts new file mode 100644 index 000000000..37e46bc0f --- /dev/null +++ b/lib/chat/generate/provisionGenerateSession.ts @@ -0,0 +1,109 @@ +import ms from "ms"; +import { generateUUID } from "@/lib/uuid/generateUUID"; +import { ensurePersonalRepo } from "@/lib/recoupable/ensurePersonalRepo"; +import { buildSessionInsertRow } from "@/lib/sessions/buildSessionInsertRow"; +import { insertSession } from "@/lib/supabase/sessions/insertSession"; +import { insertChat } from "@/lib/supabase/chats/insertChat"; +import { connectSandbox } from "@/lib/sandbox/factory"; +import { getSessionSandboxName } from "@/lib/sandbox/getSessionSandboxName"; +import { resolveGitUser } from "@/lib/sandbox/resolveGitUser"; +import { getServiceGithubToken } from "@/lib/github/getServiceGithubToken"; +import { buildActiveLifecycleUpdate } from "@/lib/sandbox/buildActiveLifecycleUpdate"; +import { updateSession } from "@/lib/supabase/sessions/updateSession"; +import { discoverSkills } from "@/lib/skills/discoverSkills"; +import { getSandboxSkillDirectories } from "@/lib/skills/getSandboxSkillDirectories"; +import { DEFAULT_WORKING_DIRECTORY } from "@/lib/sandbox/vercel/sandbox/constants"; +import type { Json, Tables } from "@/types/database.types"; +import type { VercelState } from "@/lib/sandbox/vercel/state"; +import type { SkillMetadata } from "@/lib/skills/skillTypes"; + +const SANDBOX_TIMEOUT_MS = ms("30m"); + +export type ProvisionedGenerateSession = { + session: Tables<"sessions">; + chat: Tables<"chats">; + sandboxState: VercelState; + workingDirectory: string; + skills: SkillMetadata[]; +}; + +/** + * Headlessly provision a session + chat with an ACTIVE sandbox for a scheduled + * `/api/chat/generate` run (recoupable/chat#1813) — the same building blocks the + * interactive path uses (`POST /api/sessions` + `POST /api/sandbox`), composed + * server-side since there is no client session. Mirrors `createSandboxHandler`'s + * connect → `buildActiveLifecycleUpdate` → `updateSession` binding so the + * provisioned session passes `isSandboxActive`. + * + * @throws if repo/session/chat/sandbox provisioning fails — the caller maps it + * to a 5xx and revokes any minted ephemeral key. + */ +export async function provisionGenerateSession({ + accountId, + title, + artistId, +}: { + accountId: string; + title: string; + artistId?: string; +}): Promise { + const cloneUrl = await ensurePersonalRepo({ accountId }); + if (!cloneUrl) throw new Error("Failed to provision workspace repository"); + + const session = await insertSession( + buildSessionInsertRow({ accountId, title, cloneUrl, artistId }), + ); + if (!session) throw new Error("Failed to create session"); + + const chat = await insertChat({ + id: generateUUID(), + session_id: session.id, + title: "Scheduled generation", + }); + if (!chat) throw new Error("Failed to create chat"); + + const sandboxName = getSessionSandboxName(session.id); + const gitUser = await resolveGitUser(accountId); + const sandbox = await connectSandbox({ + state: { type: "vercel", sandboxName, source: { repo: cloneUrl, prebuilt: false } }, + options: { + timeout: SANDBOX_TIMEOUT_MS, + ports: [3000], + githubToken: getServiceGithubToken(), + gitUser, + persistent: true, + resume: true, + createIfMissing: true, + }, + }); + + const sandboxState = sandbox.getState() as Json; + const updated = await updateSession(session.id, { + sandbox_state: sandboxState, + lifecycle_version: session.lifecycle_version + 1, + ...buildActiveLifecycleUpdate(sandboxState), + snapshot_url: null, + snapshot_created_at: null, + }); + if (!updated) throw new Error("Failed to activate session sandbox"); + + // Best-effort skill + working-directory discovery from the live handle — + // a failure falls back to defaults so the run can still start (tools surface + // the underlying issue when they reconnect). Mirrors handleChatWorkflowStream. + let workingDirectory = DEFAULT_WORKING_DIRECTORY; + let skills: SkillMetadata[] = []; + try { + workingDirectory = sandbox.workingDirectory; + skills = await discoverSkills(sandbox, await getSandboxSkillDirectories(sandbox)); + } catch (error) { + console.error("[provisionGenerateSession] skill discovery failed; using defaults:", error); + } + + return { + session: updated, + chat, + sandboxState: updated.sandbox_state as VercelState, + workingDirectory, + skills, + }; +} diff --git a/lib/chat/generate/validateGenerateRequest.ts b/lib/chat/generate/validateGenerateRequest.ts new file mode 100644 index 000000000..014e09e1b --- /dev/null +++ b/lib/chat/generate/validateGenerateRequest.ts @@ -0,0 +1,90 @@ +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; +import type { UIMessage } from "ai"; +import { z } from "zod"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; +import { errorResponse } from "@/lib/networking/errorResponse"; +import { validationErrorResponse } from "@/lib/zod/validationErrorResponse"; +import { generateUUID } from "@/lib/uuid/generateUUID"; + +/** Default model for headless generation when the caller omits `model`. */ +export const DEFAULT_GENERATE_MODEL_ID = "anthropic/claude-haiku-4.5"; + +/** + * Body schema for `POST /api/chat/generate` (the durable-workflow re-point, + * recoupable/chat#1813). Exactly one of `prompt` / `messages` must be present. + * `roomId` / `topic` are accepted-but-ignored for back-compat with the + * scheduled caller — the new path mints its own session + chat per run. + */ +export const generateBodySchema = z.object({ + prompt: z.string().optional(), + messages: z.array(z.any()).optional(), + artistId: z.string().uuid("artistId must be a valid UUID").optional(), + accountId: z.string().optional(), + organizationId: z.string().optional(), + model: z.string().optional(), + topic: z.string().optional(), + roomId: z.string().optional(), +}); + +export type GenerateRequest = { + accountId: string; + orgId: string | null; + messages: UIMessage[]; + artistId?: string; + modelId: string; + sessionTitle?: string; +}; + +/** + * Validates a `POST /api/chat/generate` request end-to-end: parses + validates + * the body, runs auth via `validateAuthContext` (x-api-key, with org-key + * account override), and normalizes `prompt`/`messages` into a `UIMessage[]`. + * + * @param request - The incoming NextRequest. + * @returns A NextResponse error short-circuit (400/401/403) or the validated, + * auth-augmented request ready to provision + start a workflow run. + */ +export async function validateGenerateRequest( + request: NextRequest, +): Promise { + let rawBody: unknown; + try { + rawBody = await request.json(); + } catch { + return errorResponse("Invalid JSON body", 400); + } + + const parsed = generateBodySchema.safeParse(rawBody); + if (!parsed.success) { + const firstError = parsed.error.issues[0]; + return validationErrorResponse(firstError.message, firstError.path); + } + + const { prompt, messages, artistId, accountId, organizationId, model, topic } = parsed.data; + + const hasPrompt = typeof prompt === "string" && prompt.length > 0; + const hasMessages = Array.isArray(messages) && messages.length > 0; + if (hasPrompt === hasMessages) { + return errorResponse("Exactly one of prompt or messages must be provided", 400); + } + + const auth = await validateAuthContext(request, { + accountId, + organizationId: organizationId ?? null, + }); + if (auth instanceof NextResponse) return auth; + + const uiMessages: UIMessage[] = hasPrompt + ? [{ id: generateUUID(), role: "user", parts: [{ type: "text", text: prompt! }] }] + : (messages as UIMessage[]); + + return { + accountId: auth.accountId, + orgId: auth.orgId, + messages: uiMessages, + artistId, + modelId: model ?? DEFAULT_GENERATE_MODEL_ID, + sessionTitle: topic, + }; +} diff --git a/lib/chat/handleChatGenerate.ts b/lib/chat/handleChatGenerate.ts index 1f0a4a970..b659e1f06 100644 --- a/lib/chat/handleChatGenerate.ts +++ b/lib/chat/handleChatGenerate.ts @@ -1,77 +1,81 @@ import { NextRequest, NextResponse } from "next/server"; -import { validateChatRequest } from "./validateChatRequest"; -import { setupChatRequest } from "./setupChatRequest"; +import { start } from "workflow/api"; import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; -import { saveChatCompletion } from "./saveChatCompletion"; +import { errorResponse } from "@/lib/networking/errorResponse"; +import { validateGenerateRequest } from "@/lib/chat/generate/validateGenerateRequest"; +import { provisionGenerateSession } from "@/lib/chat/generate/provisionGenerateSession"; +import { mintEphemeralAccountKey } from "@/lib/keys/mintEphemeralAccountKey"; +import { deleteApiKey } from "@/lib/supabase/account_api_keys/deleteApiKey"; +import { buildRunAgentInput } from "@/lib/chat/buildRunAgentInput"; +import { runAgentWorkflow } from "@/app/lib/workflows/runAgentWorkflow"; /** - * Handles a non-streaming chat generate request. + * Handles `POST /api/chat/generate` — the headless, asynchronous counterpart of + * interactive `/api/chat`. Re-pointed onto the durable `runAgentWorkflow` + * (recoupable/chat#1813): it provisions a session + active sandbox, mints a + * short-lived account-scoped `recoup_sk_…` key for in-sandbox `recoup-api` + * calls, builds the shared workflow input via `buildRunAgentInput`, and + * `start()`s the run — returning `{ runId }` with **202** immediately. * - * This function: - * 1. Validates the request (auth, body schema) - * 2. Sets up the chat configuration (agent, model, tools) - * 3. Generates text using the AI SDK's generateText - * 4. Persists the assistant message to the database (if roomId is provided) - * 5. Returns a JSON response with text, reasoning, sources, etc. + * Generation, assistant-message persistence, the credit charge, and the + * ephemeral-key revocation all happen server-side inside the workflow after + * this response. The legacy synchronous `ToolLoopAgent` path is gone. * - * @param request - The incoming NextRequest - * @returns A JSON response or error NextResponse + * The minted key is injected as the agent's `recoupAccessToken` (so the service + * key never enters model-issued bash) and threaded as `ephemeralKeyId` so the + * workflow deletes it on run end; its ~15m TTL is the backstop. If the run + * fails to start, we revoke the key here since the workflow never ran. + * + * @param request - The incoming request (x-api-key auth). + * @returns 202 `{ runId }`, or a 4xx/5xx error. */ export async function handleChatGenerate(request: NextRequest): Promise { - const validatedBodyOrError = await validateChatRequest(request); - if (validatedBodyOrError instanceof NextResponse) { - return validatedBodyOrError; - } - const body = validatedBodyOrError; + const validated = await validateGenerateRequest(request); + if (validated instanceof NextResponse) return validated; + + const { accountId, messages, artistId, modelId, sessionTitle } = validated; + let ephemeralKeyId: string | undefined; try { - const chatConfig = await setupChatRequest(body); - const { agent } = chatConfig; + const provisioned = await provisionGenerateSession({ + accountId, + title: sessionTitle ?? "Scheduled generation", + artistId, + }); - const result = await agent.generate(chatConfig); + const { rawKey, keyId } = await mintEphemeralAccountKey(accountId); + ephemeralKeyId = keyId; - // Save assistant message to database - // Note: roomId is always defined after validateChatRequest (auto-created if not provided) - try { - await saveChatCompletion({ - text: result.text, - roomId: body.roomId, - }); - } catch (error) { - // Log error but don't fail the request - message persistence is non-critical - console.error("Failed to persist assistant message:", error); - } + const run = await start(runAgentWorkflow, [ + buildRunAgentInput({ + messages, + chatId: provisioned.chat.id, + sessionId: provisioned.session.id, + accountId, + modelId, + sessionTitle: provisioned.session.title ?? sessionTitle, + cloneUrl: provisioned.session.clone_url, + sandboxState: provisioned.sandboxState, + workingDirectory: provisioned.workingDirectory, + skills: provisioned.skills, + recoupAccessToken: rawKey, + ephemeralKeyId: keyId, + }), + ]); - return NextResponse.json( - { - text: result.text, - roomId: body.roomId, - reasoningText: result.reasoningText, - sources: result.sources, - finishReason: result.finishReason, - usage: result.usage, - response: { - messages: result.response.messages, - headers: result.response.headers, - body: result.response.body, - }, - }, - { - status: 200, - headers: getCorsHeaders(), - }, - ); - } catch (e) { - console.error("/api/chat/generate Global error:", e); - return NextResponse.json( - { - status: "error", - message: e instanceof Error ? e.message : "Unknown error", - }, - { - status: 500, - headers: getCorsHeaders(), - }, - ); + return NextResponse.json({ runId: run.runId }, { status: 202, headers: getCorsHeaders() }); + } catch (error) { + // The workflow's `finally` revokes the key on run end — but if we never got + // there (provisioning ok, then mint ok, then start threw), the key would + // linger until its TTL. Revoke it now. If mint itself threw, there's no key. + if (ephemeralKeyId) { + try { + await deleteApiKey(ephemeralKeyId); + } catch (cleanupError) { + console.error("[handleChatGenerate] failed to revoke ephemeral key:", cleanupError); + } + } + console.error("[handleChatGenerate] failed to start generation run:", error); + return errorResponse("Failed to start chat generation", 500); } } diff --git a/lib/keys/__tests__/mintEphemeralAccountKey.test.ts b/lib/keys/__tests__/mintEphemeralAccountKey.test.ts new file mode 100644 index 000000000..1c7ba3614 --- /dev/null +++ b/lib/keys/__tests__/mintEphemeralAccountKey.test.ts @@ -0,0 +1,44 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +import { + mintEphemeralAccountKey, + DEFAULT_EPHEMERAL_KEY_TTL_MS, +} from "@/lib/keys/mintEphemeralAccountKey"; +import { insertApiKey } from "@/lib/supabase/account_api_keys/insertApiKey"; + +vi.mock("@/lib/keys/generateApiKey", () => ({ + generateApiKey: vi.fn(() => "recoup_sk_RAW"), +})); +vi.mock("@/lib/keys/hashApiKey", () => ({ + hashApiKey: vi.fn(() => "hashed"), +})); +vi.mock("@/lib/supabase/account_api_keys/insertApiKey", () => ({ + insertApiKey: vi.fn(), +})); +vi.mock("@/lib/const", () => ({ PRIVY_PROJECT_SECRET: "secret" })); + +describe("mintEphemeralAccountKey", () => { + beforeEach(() => vi.clearAllMocks()); + + it("mints an account-scoped recoup_sk_ key with a ~15m expiry and returns rawKey + keyId", async () => { + vi.mocked(insertApiKey).mockResolvedValue({ data: { id: "key-1" }, error: null } as never); + + const before = Date.now(); + const result = await mintEphemeralAccountKey("acc-1"); + const after = Date.now(); + + expect(result).toEqual({ rawKey: "recoup_sk_RAW", keyId: "key-1" }); + + const arg = vi.mocked(insertApiKey).mock.calls[0][0]; + expect(arg.account).toBe("acc-1"); + expect(arg.key_hash).toBe("hashed"); + const expMs = new Date(arg.expires_at as string).getTime(); + expect(expMs).toBeGreaterThanOrEqual(before + DEFAULT_EPHEMERAL_KEY_TTL_MS); + expect(expMs).toBeLessThanOrEqual(after + DEFAULT_EPHEMERAL_KEY_TTL_MS); + }); + + it("throws when the insert fails", async () => { + vi.mocked(insertApiKey).mockResolvedValue({ data: null, error: { message: "boom" } } as never); + await expect(mintEphemeralAccountKey("acc-1")).rejects.toThrow(/mint/i); + }); +}); diff --git a/lib/keys/mintEphemeralAccountKey.ts b/lib/keys/mintEphemeralAccountKey.ts new file mode 100644 index 000000000..8b138f279 --- /dev/null +++ b/lib/keys/mintEphemeralAccountKey.ts @@ -0,0 +1,45 @@ +import { generateApiKey } from "@/lib/keys/generateApiKey"; +import { hashApiKey } from "@/lib/keys/hashApiKey"; +import { insertApiKey } from "@/lib/supabase/account_api_keys/insertApiKey"; +import { PRIVY_PROJECT_SECRET } from "@/lib/const"; + +/** Default lifetime for an ephemeral key: 15 minutes. */ +export const DEFAULT_EPHEMERAL_KEY_TTL_MS = 15 * 60 * 1000; + +export type EphemeralAccountKey = { rawKey: string; keyId: string }; + +/** + * Mint a short-lived, account-scoped `recoup_sk_` api key for a headless run + * (recoupable/chat#1813). Returns the raw key — to inject as `$RECOUP_API_KEY` + * into the sandbox — and the row id, so the caller can delete it on run end. + * The key also auto-expires via `account_api_keys.expires_at` (defense in depth + * if the delete is missed; enforced in `getApiKeyAccountId`). The long-lived + * service key never enters the sandbox. + */ +export async function mintEphemeralAccountKey( + accountId: string, + { + ttlMs = DEFAULT_EPHEMERAL_KEY_TTL_MS, + name = "ephemeral:chat-generate", + }: { + ttlMs?: number; + name?: string; + } = {}, +): Promise { + const rawKey = generateApiKey("recoup_sk"); + const keyHash = hashApiKey(rawKey, PRIVY_PROJECT_SECRET); + const expiresAt = new Date(Date.now() + ttlMs).toISOString(); + + const { data, error } = await insertApiKey({ + name, + account: accountId, + key_hash: keyHash, + expires_at: expiresAt, + }); + + if (error || !data) { + throw new Error(`Failed to mint ephemeral api key: ${error?.message ?? "no row returned"}`); + } + + return { rawKey, keyId: data.id }; +} diff --git a/lib/supabase/account_api_keys/insertApiKey.ts b/lib/supabase/account_api_keys/insertApiKey.ts index 3904b2f46..3a058dede 100644 --- a/lib/supabase/account_api_keys/insertApiKey.ts +++ b/lib/supabase/account_api_keys/insertApiKey.ts @@ -8,12 +8,14 @@ import type { Database } from "@/types/database.types"; * @param input.name - The input object containing the name, account, and key_hash * @param input.account - The account ID * @param input.key_hash - The hash of the API key + * @param input.expires_at - Optional ISO expiry for ephemeral keys (NULL = never) * @returns The inserted API key */ export async function insertApiKey({ name, account, key_hash, + expires_at, }: Database["public"]["Tables"]["account_api_keys"]["Insert"]) { const { data, error } = await supabase .from("account_api_keys") @@ -21,6 +23,7 @@ export async function insertApiKey({ name, account, key_hash, + expires_at, }) .select() .single(); From 28edafc5be2edb1e5ed95957dbb5a9020120588d Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Wed, 24 Jun 2026 12:03:14 -0500 Subject: [PATCH 2/8] feat(chat): return { runId, chatId, sessionId } from /api/chat/generate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The workflow runId alone can't be resolved back to the chat output. Return the persisted-output identifiers too so a caller can read the result later (GET /api/chat/{chatId}/stream, or the chat's persisted messages) — turning the endpoint from fire-and-forget-only into a proper async-job contract. The scheduled task still ignores the body. (chat#1813, review follow-up.) Co-Authored-By: Claude Opus 4.8 (1M context) --- app/api/chat/generate/route.ts | 6 ++++-- lib/chat/__tests__/handleChatGenerate.test.ts | 6 +++++- lib/chat/handleChatGenerate.ts | 10 +++++++++- 3 files changed, 18 insertions(+), 4 deletions(-) diff --git a/app/api/chat/generate/route.ts b/app/api/chat/generate/route.ts index 2f8477e05..539fa12af 100644 --- a/app/api/chat/generate/route.ts +++ b/app/api/chat/generate/route.ts @@ -34,10 +34,12 @@ export async function OPTIONS() { * - topic: Optional session title * - accountId: Optional accountId override (requires org API key) * - * Response body (202): `{ runId }` — the durable workflow run id. + * Response body (202): `{ runId, chatId, sessionId }` — the durable workflow run + * id plus the persisted-output identifiers. Read the result later via + * `GET /api/chat/{chatId}/stream` (resume) or the chat's persisted messages. * * @param request - The request object - * @returns 202 `{ runId }`, or a 4xx/5xx error + * @returns 202 `{ runId, chatId, sessionId }`, or a 4xx/5xx error */ export async function POST(request: NextRequest): Promise { return handleChatGenerate(request); diff --git a/lib/chat/__tests__/handleChatGenerate.test.ts b/lib/chat/__tests__/handleChatGenerate.test.ts index 796f0d84c..ef520bd8b 100644 --- a/lib/chat/__tests__/handleChatGenerate.test.ts +++ b/lib/chat/__tests__/handleChatGenerate.test.ts @@ -75,7 +75,11 @@ describe("handleChatGenerate", () => { it("provisions, mints, starts the workflow, and returns 202 { runId }", async () => { const res = await handleChatGenerate(req()); expect(res.status).toBe(202); - expect(await res.json()).toEqual({ runId: "wrun_abc" }); + expect(await res.json()).toEqual({ + runId: "wrun_abc", + chatId: "chat-1", + sessionId: "sess-1", + }); expect(provisionGenerateSession).toHaveBeenCalledWith( expect.objectContaining({ accountId: "acc-1", title: "Scheduled generation" }), diff --git a/lib/chat/handleChatGenerate.ts b/lib/chat/handleChatGenerate.ts index b659e1f06..6f2d3ddac 100644 --- a/lib/chat/handleChatGenerate.ts +++ b/lib/chat/handleChatGenerate.ts @@ -63,7 +63,15 @@ export async function handleChatGenerate(request: NextRequest): Promise Date: Wed, 24 Jun 2026 12:59:23 -0500 Subject: [PATCH 3/8] =?UTF-8?q?refactor(chat):=20rename=20POST=20/api/chat?= =?UTF-8?q?/generate=20=E2=86=92=20POST=20/api/chat/runs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit REST cleanup (chat#1813): the endpoint starts a *run*, so it's modeled as a run resource, not a `generate` verb. Removes /generate entirely (no alias). - Route app/api/chat/generate → app/api/chat/runs; handler handleChatGenerate → handleStartChatRun. Add a Location header at /api/chat/runs/{runId}. - Update path strings in comments/JSDoc to /api/chat/runs. Also addresses cubic review on this PR: - validateGenerateRequest: trim prompt before the presence check (reject blank). - handleStartChatRun: standardized 500 body "Internal server error". - validateGenerateRequest test: use a schema-valid field so the "exactly one of prompt/messages" case is exercised for the right reason; add a whitespace-prompt test. (Internal helper names — validateGenerateRequest/provisionGenerateSession — keep "generate" as it describes the operation; renaming is out of scope.) Co-Authored-By: Claude Opus 4.8 (1M context) --- app/api/chat/{generate => runs}/route.ts | 23 +++++++++------- app/lib/workflows/deleteEphemeralKeyStep.ts | 2 +- app/lib/workflows/runAgentWorkflow.ts | 2 +- lib/agent/tools/AgentContext.ts | 2 +- ...ate.test.ts => handleStartChatRun.test.ts} | 13 +++++----- lib/chat/buildRunAgentInput.ts | 2 +- .../__tests__/validateGenerateRequest.test.ts | 11 ++++++-- lib/chat/generate/provisionGenerateSession.ts | 2 +- lib/chat/generate/validateGenerateRequest.ts | 9 ++++--- ...eChatGenerate.ts => handleStartChatRun.ts} | 26 +++++++++++-------- 10 files changed, 54 insertions(+), 38 deletions(-) rename app/api/chat/{generate => runs}/route.ts (62%) rename lib/chat/__tests__/{handleChatGenerate.test.ts => handleStartChatRun.test.ts} (91%) rename lib/chat/{handleChatGenerate.ts => handleStartChatRun.ts} (78%) diff --git a/app/api/chat/generate/route.ts b/app/api/chat/runs/route.ts similarity index 62% rename from app/api/chat/generate/route.ts rename to app/api/chat/runs/route.ts index 539fa12af..d847b5c2f 100644 --- a/app/api/chat/generate/route.ts +++ b/app/api/chat/runs/route.ts @@ -1,7 +1,7 @@ import type { NextRequest } from "next/server"; import { NextResponse } from "next/server"; import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; -import { handleChatGenerate } from "@/lib/chat/handleChatGenerate"; +import { handleStartChatRun } from "@/lib/chat/handleStartChatRun"; /** * OPTIONS handler for CORS preflight requests. @@ -16,12 +16,14 @@ export async function OPTIONS() { } /** - * POST /api/chat/generate + * POST /api/chat/runs * - * Asynchronous, headless chat generation on the durable `runAgentWorkflow` - * (recoupable/chat#1813). Provisions a session + sandbox, starts a workflow run, - * and returns `{ runId }` with **202** immediately — generation, assistant- - * message persistence, and side effects happen server-side after the response. + * Start an asynchronous, headless chat-generation run on the durable + * `runAgentWorkflow` (recoupable/chat#1813). Provisions a session + sandbox, + * starts a workflow run, and returns `{ runId, chatId, sessionId }` with **202** + * immediately (plus a `Location` header at the run-status resource) — generation, + * assistant-message persistence, and side effects happen server-side after the + * response. * * Authentication: x-api-key header required (account inferred from the key; * org keys may override via body `accountId`). @@ -34,15 +36,16 @@ export async function OPTIONS() { * - topic: Optional session title * - accountId: Optional accountId override (requires org API key) * - * Response body (202): `{ runId, chatId, sessionId }` — the durable workflow run - * id plus the persisted-output identifiers. Read the result later via - * `GET /api/chat/{chatId}/stream` (resume) or the chat's persisted messages. + * Response body (202): `{ runId, chatId, sessionId }`. Read the result later via + * `GET /api/chat/{chatId}/stream` (watch the stream) or the chat's persisted + * messages; poll `GET /api/chat/runs/{runId}` for status (status route lands in + * a follow-up). * * @param request - The request object * @returns 202 `{ runId, chatId, sessionId }`, or a 4xx/5xx error */ export async function POST(request: NextRequest): Promise { - return handleChatGenerate(request); + return handleStartChatRun(request); } // Provisioning (repo + session + sandbox) runs before the 202 returns, so give diff --git a/app/lib/workflows/deleteEphemeralKeyStep.ts b/app/lib/workflows/deleteEphemeralKeyStep.ts index 1b6fac545..614323a82 100644 --- a/app/lib/workflows/deleteEphemeralKeyStep.ts +++ b/app/lib/workflows/deleteEphemeralKeyStep.ts @@ -2,7 +2,7 @@ import { deleteApiKey } from "@/lib/supabase/account_api_keys/deleteApiKey"; /** * Vercel Workflow `"use step"` that deletes the ephemeral, account-scoped - * `recoup_sk_…` key minted for a headless `/api/chat/generate` run + * `recoup_sk_…` key minted for a headless `/api/chat/runs` run * (recoupable/chat#1813). Called from `runAgentWorkflow`'s `finally` so the * credential is revoked the moment the run ends — the key's ~15m `expires_at` * TTL (enforced in `getApiKeyAccountId`) is only the backstop if this is missed. diff --git a/app/lib/workflows/runAgentWorkflow.ts b/app/lib/workflows/runAgentWorkflow.ts index 862e6ef12..c0cdec7ff 100644 --- a/app/lib/workflows/runAgentWorkflow.ts +++ b/app/lib/workflows/runAgentWorkflow.ts @@ -152,7 +152,7 @@ export async function runAgentWorkflow(input: RunAgentWorkflowInput): Promise { +describe("handleStartChatRun", () => { beforeEach(() => { vi.clearAllMocks(); vi.mocked(validateGenerateRequest).mockResolvedValue(validated as never); @@ -73,8 +73,9 @@ describe("handleChatGenerate", () => { }); it("provisions, mints, starts the workflow, and returns 202 { runId }", async () => { - const res = await handleChatGenerate(req()); + const res = await handleStartChatRun(req()); expect(res.status).toBe(202); + expect(res.headers.get("Location")).toBe("/api/chat/runs/wrun_abc"); expect(await res.json()).toEqual({ runId: "wrun_abc", chatId: "chat-1", @@ -102,21 +103,21 @@ describe("handleChatGenerate", () => { vi.mocked(validateGenerateRequest).mockResolvedValue( NextResponse.json({ status: "error" }, { status: 401 }), ); - const res = await handleChatGenerate(req()); + const res = await handleStartChatRun(req()); expect(res.status).toBe(401); expect(provisionGenerateSession).not.toHaveBeenCalled(); }); it("revokes the minted key and 500s when start() fails", async () => { vi.mocked(start).mockRejectedValue(new Error("workflow start boom")); - const res = await handleChatGenerate(req()); + const res = await handleStartChatRun(req()); expect(res.status).toBe(500); expect(deleteApiKey).toHaveBeenCalledWith("key-1"); }); it("does not mint or delete a key when provisioning fails", async () => { vi.mocked(provisionGenerateSession).mockRejectedValue(new Error("repo boom")); - const res = await handleChatGenerate(req()); + const res = await handleStartChatRun(req()); expect(res.status).toBe(500); expect(mintEphemeralAccountKey).not.toHaveBeenCalled(); expect(deleteApiKey).not.toHaveBeenCalled(); diff --git a/lib/chat/buildRunAgentInput.ts b/lib/chat/buildRunAgentInput.ts index ad5189c03..0630bf562 100644 --- a/lib/chat/buildRunAgentInput.ts +++ b/lib/chat/buildRunAgentInput.ts @@ -20,7 +20,7 @@ export type BuildRunAgentInputParams = { /** * Short-lived bearer for in-sandbox recoup-api calls: the user's Privy JWT * (interactive `/api/chat/workflow`) or an ephemeral account key (headless - * `/api/chat/generate`). Omitted when absent so the service key never leaks. + * `/api/chat/runs`). Omitted when absent so the service key never leaks. */ recoupAccessToken?: string; /** diff --git a/lib/chat/generate/__tests__/validateGenerateRequest.test.ts b/lib/chat/generate/__tests__/validateGenerateRequest.test.ts index 4883e1a0f..62340d323 100644 --- a/lib/chat/generate/__tests__/validateGenerateRequest.test.ts +++ b/lib/chat/generate/__tests__/validateGenerateRequest.test.ts @@ -13,7 +13,7 @@ vi.mock("@/lib/auth/validateAuthContext", () => ({ })); function req(body: unknown): NextRequest { - return new NextRequest("https://x.test/api/chat/generate", { + return new NextRequest("https://x.test/api/chat/runs", { method: "POST", headers: { "content-type": "application/json", "x-api-key": "recoup_sk_test" }, body: JSON.stringify(body), @@ -54,7 +54,14 @@ describe("validateGenerateRequest", () => { }); it("rejects when neither prompt nor messages is provided (400)", async () => { - const result = await validateGenerateRequest(req({ artistId: "a1" })); + const result = await validateGenerateRequest(req({ topic: "test" })); + expect(result).toBeInstanceOf(NextResponse); + if (!(result instanceof NextResponse)) return; + expect(result.status).toBe(400); + }); + + it("rejects a whitespace-only prompt (400)", async () => { + const result = await validateGenerateRequest(req({ prompt: " \n\t " })); expect(result).toBeInstanceOf(NextResponse); if (!(result instanceof NextResponse)) return; expect(result.status).toBe(400); diff --git a/lib/chat/generate/provisionGenerateSession.ts b/lib/chat/generate/provisionGenerateSession.ts index 37e46bc0f..e6cda2adf 100644 --- a/lib/chat/generate/provisionGenerateSession.ts +++ b/lib/chat/generate/provisionGenerateSession.ts @@ -29,7 +29,7 @@ export type ProvisionedGenerateSession = { /** * Headlessly provision a session + chat with an ACTIVE sandbox for a scheduled - * `/api/chat/generate` run (recoupable/chat#1813) — the same building blocks the + * `/api/chat/runs` run (recoupable/chat#1813) — the same building blocks the * interactive path uses (`POST /api/sessions` + `POST /api/sandbox`), composed * server-side since there is no client session. Mirrors `createSandboxHandler`'s * connect → `buildActiveLifecycleUpdate` → `updateSession` binding so the diff --git a/lib/chat/generate/validateGenerateRequest.ts b/lib/chat/generate/validateGenerateRequest.ts index 014e09e1b..da5b6f9aa 100644 --- a/lib/chat/generate/validateGenerateRequest.ts +++ b/lib/chat/generate/validateGenerateRequest.ts @@ -11,7 +11,7 @@ import { generateUUID } from "@/lib/uuid/generateUUID"; export const DEFAULT_GENERATE_MODEL_ID = "anthropic/claude-haiku-4.5"; /** - * Body schema for `POST /api/chat/generate` (the durable-workflow re-point, + * Body schema for `POST /api/chat/runs` (the durable-workflow re-point, * recoupable/chat#1813). Exactly one of `prompt` / `messages` must be present. * `roomId` / `topic` are accepted-but-ignored for back-compat with the * scheduled caller — the new path mints its own session + chat per run. @@ -37,7 +37,7 @@ export type GenerateRequest = { }; /** - * Validates a `POST /api/chat/generate` request end-to-end: parses + validates + * Validates a `POST /api/chat/runs` request end-to-end: parses + validates * the body, runs auth via `validateAuthContext` (x-api-key, with org-key * account override), and normalizes `prompt`/`messages` into a `UIMessage[]`. * @@ -63,7 +63,8 @@ export async function validateGenerateRequest( const { prompt, messages, artistId, accountId, organizationId, model, topic } = parsed.data; - const hasPrompt = typeof prompt === "string" && prompt.length > 0; + const trimmedPrompt = typeof prompt === "string" ? prompt.trim() : ""; + const hasPrompt = trimmedPrompt.length > 0; const hasMessages = Array.isArray(messages) && messages.length > 0; if (hasPrompt === hasMessages) { return errorResponse("Exactly one of prompt or messages must be provided", 400); @@ -76,7 +77,7 @@ export async function validateGenerateRequest( if (auth instanceof NextResponse) return auth; const uiMessages: UIMessage[] = hasPrompt - ? [{ id: generateUUID(), role: "user", parts: [{ type: "text", text: prompt! }] }] + ? [{ id: generateUUID(), role: "user", parts: [{ type: "text", text: trimmedPrompt }] }] : (messages as UIMessage[]); return { diff --git a/lib/chat/handleChatGenerate.ts b/lib/chat/handleStartChatRun.ts similarity index 78% rename from lib/chat/handleChatGenerate.ts rename to lib/chat/handleStartChatRun.ts index 6f2d3ddac..d3026ddf0 100644 --- a/lib/chat/handleChatGenerate.ts +++ b/lib/chat/handleStartChatRun.ts @@ -10,12 +10,13 @@ import { buildRunAgentInput } from "@/lib/chat/buildRunAgentInput"; import { runAgentWorkflow } from "@/app/lib/workflows/runAgentWorkflow"; /** - * Handles `POST /api/chat/generate` — the headless, asynchronous counterpart of - * interactive `/api/chat`. Re-pointed onto the durable `runAgentWorkflow` + * Handles `POST /api/chat/runs` — the headless, asynchronous counterpart of + * interactive `/api/chat`. Runs on the durable `runAgentWorkflow` * (recoupable/chat#1813): it provisions a session + active sandbox, mints a * short-lived account-scoped `recoup_sk_…` key for in-sandbox `recoup-api` * calls, builds the shared workflow input via `buildRunAgentInput`, and - * `start()`s the run — returning `{ runId }` with **202** immediately. + * `start()`s the run — returning `{ runId, chatId, sessionId }` with **202** + * (plus a `Location` header pointing at the run-status resource) immediately. * * Generation, assistant-message persistence, the credit charge, and the * ephemeral-key revocation all happen server-side inside the workflow after @@ -27,9 +28,9 @@ import { runAgentWorkflow } from "@/app/lib/workflows/runAgentWorkflow"; * fails to start, we revoke the key here since the workflow never ran. * * @param request - The incoming request (x-api-key auth). - * @returns 202 `{ runId }`, or a 4xx/5xx error. + * @returns 202 `{ runId, chatId, sessionId }`, or a 4xx/5xx error. */ -export async function handleChatGenerate(request: NextRequest): Promise { +export async function handleStartChatRun(request: NextRequest): Promise { const validated = await validateGenerateRequest(request); if (validated instanceof NextResponse) return validated; @@ -66,11 +67,14 @@ export async function handleChatGenerate(request: NextRequest): Promise Date: Wed, 24 Jun 2026 13:47:33 -0500 Subject: [PATCH 4/8] refactor(chat/runs): drop dead roomId from the request schema MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit roomId was accepted-but-ignored on the re-pointed endpoint (it mints its own session+chat per run and returns chatId/sessionId). Nothing sends it anymore (tasks#152 stopped), and Zod strips unknown keys regardless — so remove it from the schema to keep docs↔api in sync. excludeTools was already gone. (chat#1813) Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/chat/generate/validateGenerateRequest.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/chat/generate/validateGenerateRequest.ts b/lib/chat/generate/validateGenerateRequest.ts index da5b6f9aa..9b93602a4 100644 --- a/lib/chat/generate/validateGenerateRequest.ts +++ b/lib/chat/generate/validateGenerateRequest.ts @@ -13,8 +13,9 @@ export const DEFAULT_GENERATE_MODEL_ID = "anthropic/claude-haiku-4.5"; /** * Body schema for `POST /api/chat/runs` (the durable-workflow re-point, * recoupable/chat#1813). Exactly one of `prompt` / `messages` must be present. - * `roomId` / `topic` are accepted-but-ignored for back-compat with the - * scheduled caller — the new path mints its own session + chat per run. + * `topic` sets the provisioned session's title. (The legacy `roomId` / + * `excludeTools` fields are gone — this path mints its own session + chat and + * runs native sandbox tools, so neither applies.) */ export const generateBodySchema = z.object({ prompt: z.string().optional(), @@ -24,7 +25,6 @@ export const generateBodySchema = z.object({ organizationId: z.string().optional(), model: z.string().optional(), topic: z.string().optional(), - roomId: z.string().optional(), }); export type GenerateRequest = { From f407058332e260b4ae90f6bb46f0979a5a482832 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Wed, 24 Jun 2026 13:56:25 -0500 Subject: [PATCH 5/8] refactor(chat/runs): remove topic param to match /api/chat /api/chat takes no session-title param, so /api/chat/runs shouldn't either. The endpoint provisions its own session with a default title; drop topic from the request schema and the GenerateRequest type. (chat#1813 review) Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/chat/__tests__/handleStartChatRun.test.ts | 1 - .../__tests__/validateGenerateRequest.test.ts | 2 +- lib/chat/generate/validateGenerateRequest.ts | 11 ++++------- lib/chat/handleStartChatRun.ts | 9 ++++++--- 4 files changed, 11 insertions(+), 12 deletions(-) diff --git a/lib/chat/__tests__/handleStartChatRun.test.ts b/lib/chat/__tests__/handleStartChatRun.test.ts index 6334d745d..e62703732 100644 --- a/lib/chat/__tests__/handleStartChatRun.test.ts +++ b/lib/chat/__tests__/handleStartChatRun.test.ts @@ -45,7 +45,6 @@ const validated = { messages: [{ id: "m1", role: "user", parts: [{ type: "text", text: "go" }] }], artistId: undefined, modelId: "anthropic/claude-haiku-4.5", - sessionTitle: undefined, }; const provisioned = { diff --git a/lib/chat/generate/__tests__/validateGenerateRequest.test.ts b/lib/chat/generate/__tests__/validateGenerateRequest.test.ts index 62340d323..0ee1cae15 100644 --- a/lib/chat/generate/__tests__/validateGenerateRequest.test.ts +++ b/lib/chat/generate/__tests__/validateGenerateRequest.test.ts @@ -54,7 +54,7 @@ describe("validateGenerateRequest", () => { }); it("rejects when neither prompt nor messages is provided (400)", async () => { - const result = await validateGenerateRequest(req({ topic: "test" })); + const result = await validateGenerateRequest(req({ model: "anthropic/claude-haiku-4.5" })); expect(result).toBeInstanceOf(NextResponse); if (!(result instanceof NextResponse)) return; expect(result.status).toBe(400); diff --git a/lib/chat/generate/validateGenerateRequest.ts b/lib/chat/generate/validateGenerateRequest.ts index 9b93602a4..faf53489e 100644 --- a/lib/chat/generate/validateGenerateRequest.ts +++ b/lib/chat/generate/validateGenerateRequest.ts @@ -13,9 +13,9 @@ export const DEFAULT_GENERATE_MODEL_ID = "anthropic/claude-haiku-4.5"; /** * Body schema for `POST /api/chat/runs` (the durable-workflow re-point, * recoupable/chat#1813). Exactly one of `prompt` / `messages` must be present. - * `topic` sets the provisioned session's title. (The legacy `roomId` / - * `excludeTools` fields are gone — this path mints its own session + chat and - * runs native sandbox tools, so neither applies.) + * Mirrors `/api/chat`: no session-title / room / tool-exclusion params — this + * path mints its own session + chat (with a default title) and runs native + * sandbox tools. The legacy `topic` / `roomId` / `excludeTools` fields are gone. */ export const generateBodySchema = z.object({ prompt: z.string().optional(), @@ -24,7 +24,6 @@ export const generateBodySchema = z.object({ accountId: z.string().optional(), organizationId: z.string().optional(), model: z.string().optional(), - topic: z.string().optional(), }); export type GenerateRequest = { @@ -33,7 +32,6 @@ export type GenerateRequest = { messages: UIMessage[]; artistId?: string; modelId: string; - sessionTitle?: string; }; /** @@ -61,7 +59,7 @@ export async function validateGenerateRequest( return validationErrorResponse(firstError.message, firstError.path); } - const { prompt, messages, artistId, accountId, organizationId, model, topic } = parsed.data; + const { prompt, messages, artistId, accountId, organizationId, model } = parsed.data; const trimmedPrompt = typeof prompt === "string" ? prompt.trim() : ""; const hasPrompt = trimmedPrompt.length > 0; @@ -86,6 +84,5 @@ export async function validateGenerateRequest( messages: uiMessages, artistId, modelId: model ?? DEFAULT_GENERATE_MODEL_ID, - sessionTitle: topic, }; } diff --git a/lib/chat/handleStartChatRun.ts b/lib/chat/handleStartChatRun.ts index d3026ddf0..508ef7aa6 100644 --- a/lib/chat/handleStartChatRun.ts +++ b/lib/chat/handleStartChatRun.ts @@ -9,6 +9,9 @@ import { deleteApiKey } from "@/lib/supabase/account_api_keys/deleteApiKey"; import { buildRunAgentInput } from "@/lib/chat/buildRunAgentInput"; import { runAgentWorkflow } from "@/app/lib/workflows/runAgentWorkflow"; +/** Default title for the session a headless run provisions (no caller-supplied title). */ +const DEFAULT_RUN_SESSION_TITLE = "Scheduled generation"; + /** * Handles `POST /api/chat/runs` — the headless, asynchronous counterpart of * interactive `/api/chat`. Runs on the durable `runAgentWorkflow` @@ -34,13 +37,13 @@ export async function handleStartChatRun(request: NextRequest): Promise Date: Wed, 24 Jun 2026 14:46:50 -0500 Subject: [PATCH 6/8] feat(chat/runs): implement GET /api/chat/runs/{runId} status endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Brings the api to parity with the merged docs#249, which documented the run- status endpoint. Wraps the durable workflow's getRun(runId).status and returns { runId, status } (normalized to queued|running|completed|failed|cancelled). 404 when the run is unknown; x-api-key auth. Returns { runId, status } rather than the documented chatId/sessionId: getRun exposes only status, and there's no durable runId→chat mapping (the caller already holds chatId/sessionId from the 202 start response). Docs reconciled to match; full chatId/sessionId + per-run ownership would need a chats.last_run_id column (follow-up). (chat#1813) Co-Authored-By: Claude Opus 4.8 (1M context) --- app/api/chat/runs/[runId]/route.ts | 42 ++++++++++++ .../__tests__/handleChatRunStatus.test.ts | 67 +++++++++++++++++++ lib/chat/generate/handleChatRunStatus.ts | 66 ++++++++++++++++++ 3 files changed, 175 insertions(+) create mode 100644 app/api/chat/runs/[runId]/route.ts create mode 100644 lib/chat/generate/__tests__/handleChatRunStatus.test.ts create mode 100644 lib/chat/generate/handleChatRunStatus.ts diff --git a/app/api/chat/runs/[runId]/route.ts b/app/api/chat/runs/[runId]/route.ts new file mode 100644 index 000000000..05bb8c0ff --- /dev/null +++ b/app/api/chat/runs/[runId]/route.ts @@ -0,0 +1,42 @@ +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { handleChatRunStatus } from "@/lib/chat/generate/handleChatRunStatus"; + +/** + * OPTIONS handler for CORS preflight requests. + * + * @returns A NextResponse with CORS headers. + */ +export async function OPTIONS() { + return new NextResponse(null, { + status: 200, + headers: getCorsHeaders(), + }); +} + +/** + * GET /api/chat/runs/{runId} + * + * Point-in-time status of an asynchronous run started via `POST /api/chat/runs` + * (recoupable/chat#1813). Returns `{ runId, status }` — a snapshot ("is it + * done?"), not the generated content. Read the content via the chat (`chatId` + * from the start response): `GET /api/chat/{chatId}/stream`, or the persisted + * messages. + * + * Authentication: x-api-key header required. + * + * @param request - The request object. + * @param ctx - Route context with the `runId` path param. + * @param ctx.params - Promise resolving to the `{ runId }` path params. + * @returns 200 `{ runId, status }`, 401/403 on auth, or 404 if the run is unknown. + */ +export async function GET( + request: NextRequest, + ctx: { params: Promise<{ runId: string }> }, +): Promise { + const { runId } = await ctx.params; + return handleChatRunStatus(request, runId); +} + +export const dynamic = "force-dynamic"; diff --git a/lib/chat/generate/__tests__/handleChatRunStatus.test.ts b/lib/chat/generate/__tests__/handleChatRunStatus.test.ts new file mode 100644 index 000000000..c95b84e72 --- /dev/null +++ b/lib/chat/generate/__tests__/handleChatRunStatus.test.ts @@ -0,0 +1,67 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest, NextResponse } from "next/server"; + +import { handleChatRunStatus } from "@/lib/chat/generate/handleChatRunStatus"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; +import { getRun } from "workflow/api"; + +vi.mock("@/lib/networking/getCorsHeaders", () => ({ + getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })), +})); +vi.mock("@/lib/auth/validateAuthContext", () => ({ + validateAuthContext: vi.fn(), +})); +vi.mock("workflow/api", () => ({ + getRun: vi.fn(), +})); + +const req = () => + new NextRequest("https://x.test/api/chat/runs/wrun_abc", { + headers: { "x-api-key": "recoup_sk_test" }, + }); + +const okAuth = { accountId: "acc-1", orgId: null, authToken: "recoup_sk_test" }; + +describe("handleChatRunStatus", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(validateAuthContext).mockResolvedValue(okAuth); + }); + + it("returns 200 { runId, status } mapping the workflow status", async () => { + vi.mocked(getRun).mockReturnValue({ status: Promise.resolve("running") } as never); + const res = await handleChatRunStatus(req(), "wrun_abc"); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ runId: "wrun_abc", status: "running" }); + }); + + it("normalizes pending → running and completed/failed/cancelled through", async () => { + for (const [raw, want] of [ + ["pending", "running"], + ["completed", "completed"], + ["failed", "failed"], + ["cancelled", "cancelled"], + ] as const) { + vi.mocked(getRun).mockReturnValue({ status: Promise.resolve(raw) } as never); + const res = await handleChatRunStatus(req(), "wrun_abc"); + expect((await res.json()).status).toBe(want); + } + }); + + it("returns the auth error short-circuit", async () => { + vi.mocked(validateAuthContext).mockResolvedValue( + NextResponse.json({ status: "error" }, { status: 401 }), + ); + const res = await handleChatRunStatus(req(), "wrun_abc"); + expect(res.status).toBe(401); + expect(getRun).not.toHaveBeenCalled(); + }); + + it("404s when the run is not found (getRun throws)", async () => { + vi.mocked(getRun).mockImplementation(() => { + throw new Error("run not found"); + }); + const res = await handleChatRunStatus(req(), "wrun_missing"); + expect(res.status).toBe(404); + }); +}); diff --git a/lib/chat/generate/handleChatRunStatus.ts b/lib/chat/generate/handleChatRunStatus.ts new file mode 100644 index 000000000..8aedf1389 --- /dev/null +++ b/lib/chat/generate/handleChatRunStatus.ts @@ -0,0 +1,66 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getRun } from "workflow/api"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { errorResponse } from "@/lib/networking/errorResponse"; + +export type ChatRunStatus = "queued" | "running" | "completed" | "failed" | "cancelled"; + +/** + * Normalize a Vercel Workflow run state to the documented `ChatRunStatusResponse` + * enum. `pending` is treated as running (the codebase's `RUNNING_STATUSES`). + */ +function normalizeRunStatus(raw: string): ChatRunStatus { + switch (raw.toLowerCase()) { + case "queued": + return "queued"; + case "running": + case "pending": + return "running"; + case "completed": + case "complete": + case "succeeded": + case "success": + return "completed"; + case "failed": + case "errored": + case "error": + return "failed"; + case "cancelled": + case "canceled": + return "cancelled"; + default: + // Unknown terminal/transient string — surface as running rather than + // inventing a terminal state. Refined against real runs in preview. + return "running"; + } +} + +/** + * Handles `GET /api/chat/runs/{runId}` — a point-in-time status snapshot for an + * asynchronous run started via `POST /api/chat/runs` (recoupable/chat#1813). + * Wraps the durable workflow's `getRun(runId).status`; returns `{ runId, status }`. + * Not the generated content — read that via the chat (`chatId` from the 202 start + * response): `GET /api/chat/{chatId}/stream` or the persisted messages. + * + * @param request - The incoming request (x-api-key auth). + * @param runId - The durable workflow run id from the path. + * @returns 200 `{ runId, status }`, 401/403 on auth, or 404 if the run is unknown. + */ +export async function handleChatRunStatus(request: NextRequest, runId: string): Promise { + const auth = await validateAuthContext(request); + if (auth instanceof NextResponse) return auth; + + let rawStatus: string; + try { + rawStatus = await getRun(runId).status; + } catch (error) { + console.error(`[handleChatRunStatus] run not found ${runId}:`, error); + return errorResponse("Run not found", 404); + } + + return NextResponse.json( + { runId, status: normalizeRunStatus(rawStatus) }, + { status: 200, headers: getCorsHeaders() }, + ); +} From d1394890c7c1cb70e3389fb96d8ff41b3a37df9e Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Wed, 24 Jun 2026 15:21:05 -0500 Subject: [PATCH 7/8] =?UTF-8?q?refactor(chat/runs):=20SRP=20+=20DRY=20?= =?UTF-8?q?=E2=80=94=20share=20session/sandbox=20provisioning=20libs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses review on api#704: SRP — extract normalizeRunStatus into its own file (one exported fn per file). DRY — the headless provisionGenerateSession duplicated the interactive flow. Extract the shared blocks and use them in both paths: - lib/sessions/createSessionWithInitialChat — ensurePersonalRepo → insertSession → insertChat with rollback. Used by createSessionHandler (POST /api/sessions) AND provisionGenerateSession. Also fixes the headless rollback gap (cubic P2). - lib/sandbox/markSessionSandboxActive — bind sandbox state to a session + mark active. Used by createSandboxHandler (POST /api/sandbox) AND provisionGenerateSession. The sandbox connectSandbox call itself is left in each caller: the interactive createSandboxHandler interleaves org-snapshot warm-boot + one-shot (no-session) provisioning + skill-install + lifecycle-kick that the lean headless path intentionally omits, so forcing a shared connect would couple unrelated concerns. Behavior-preserving: full lib/sessions + lib/sandbox suites green; new unit tests for the 3 extracted fns. (chat#1813) Co-Authored-By: Claude Opus 4.8 (1M context) --- .../__tests__/normalizeRunStatus.test.ts | 25 +++++++ lib/chat/generate/handleChatRunStatus.ts | 33 +--------- lib/chat/generate/normalizeRunStatus.ts | 34 ++++++++++ lib/chat/generate/provisionGenerateSession.ts | 65 ++++++++---------- .../markSessionSandboxActive.test.ts | 36 ++++++++++ lib/sandbox/createSandboxHandler.ts | 24 ++----- lib/sandbox/markSessionSandboxActive.ts | 30 +++++++++ .../createSessionWithInitialChat.test.ts | 63 ++++++++++++++++++ lib/sessions/createSessionHandler.ts | 66 +++++++------------ lib/sessions/createSessionWithInitialChat.ts | 66 +++++++++++++++++++ 10 files changed, 311 insertions(+), 131 deletions(-) create mode 100644 lib/chat/generate/__tests__/normalizeRunStatus.test.ts create mode 100644 lib/chat/generate/normalizeRunStatus.ts create mode 100644 lib/sandbox/__tests__/markSessionSandboxActive.test.ts create mode 100644 lib/sandbox/markSessionSandboxActive.ts create mode 100644 lib/sessions/__tests__/createSessionWithInitialChat.test.ts create mode 100644 lib/sessions/createSessionWithInitialChat.ts diff --git a/lib/chat/generate/__tests__/normalizeRunStatus.test.ts b/lib/chat/generate/__tests__/normalizeRunStatus.test.ts new file mode 100644 index 000000000..150ccdaa4 --- /dev/null +++ b/lib/chat/generate/__tests__/normalizeRunStatus.test.ts @@ -0,0 +1,25 @@ +import { describe, it, expect } from "vitest"; +import { normalizeRunStatus } from "@/lib/chat/generate/normalizeRunStatus"; + +describe("normalizeRunStatus", () => { + it("maps known states to the documented enum", () => { + expect(normalizeRunStatus("queued")).toBe("queued"); + expect(normalizeRunStatus("running")).toBe("running"); + expect(normalizeRunStatus("pending")).toBe("running"); + expect(normalizeRunStatus("completed")).toBe("completed"); + expect(normalizeRunStatus("succeeded")).toBe("completed"); + expect(normalizeRunStatus("failed")).toBe("failed"); + expect(normalizeRunStatus("error")).toBe("failed"); + expect(normalizeRunStatus("cancelled")).toBe("cancelled"); + expect(normalizeRunStatus("canceled")).toBe("cancelled"); + }); + + it("is case-insensitive", () => { + expect(normalizeRunStatus("COMPLETED")).toBe("completed"); + expect(normalizeRunStatus("Running")).toBe("running"); + }); + + it("defaults unknown strings to running (no invented terminal state)", () => { + expect(normalizeRunStatus("something-new")).toBe("running"); + }); +}); diff --git a/lib/chat/generate/handleChatRunStatus.ts b/lib/chat/generate/handleChatRunStatus.ts index 8aedf1389..22b06fad1 100644 --- a/lib/chat/generate/handleChatRunStatus.ts +++ b/lib/chat/generate/handleChatRunStatus.ts @@ -3,38 +3,7 @@ import { getRun } from "workflow/api"; import { validateAuthContext } from "@/lib/auth/validateAuthContext"; import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; import { errorResponse } from "@/lib/networking/errorResponse"; - -export type ChatRunStatus = "queued" | "running" | "completed" | "failed" | "cancelled"; - -/** - * Normalize a Vercel Workflow run state to the documented `ChatRunStatusResponse` - * enum. `pending` is treated as running (the codebase's `RUNNING_STATUSES`). - */ -function normalizeRunStatus(raw: string): ChatRunStatus { - switch (raw.toLowerCase()) { - case "queued": - return "queued"; - case "running": - case "pending": - return "running"; - case "completed": - case "complete": - case "succeeded": - case "success": - return "completed"; - case "failed": - case "errored": - case "error": - return "failed"; - case "cancelled": - case "canceled": - return "cancelled"; - default: - // Unknown terminal/transient string — surface as running rather than - // inventing a terminal state. Refined against real runs in preview. - return "running"; - } -} +import { normalizeRunStatus } from "@/lib/chat/generate/normalizeRunStatus"; /** * Handles `GET /api/chat/runs/{runId}` — a point-in-time status snapshot for an diff --git a/lib/chat/generate/normalizeRunStatus.ts b/lib/chat/generate/normalizeRunStatus.ts new file mode 100644 index 000000000..f6411c8fb --- /dev/null +++ b/lib/chat/generate/normalizeRunStatus.ts @@ -0,0 +1,34 @@ +export type ChatRunStatus = "queued" | "running" | "completed" | "failed" | "cancelled"; + +/** + * Normalize a Vercel Workflow run state to the `ChatRunStatusResponse` enum + * documented for `GET /api/chat/runs/{runId}` (recoupable/chat#1813). `pending` + * is treated as running (matching the codebase's `RUNNING_STATUSES`). An unknown + * string is surfaced as `running` rather than inventing a terminal state. + * + * @param raw - The raw `getRun(runId).status` string. + * @returns The normalized lifecycle state. + */ +export function normalizeRunStatus(raw: string): ChatRunStatus { + switch (raw.toLowerCase()) { + case "queued": + return "queued"; + case "running": + case "pending": + return "running"; + case "completed": + case "complete": + case "succeeded": + case "success": + return "completed"; + case "failed": + case "errored": + case "error": + return "failed"; + case "cancelled": + case "canceled": + return "cancelled"; + default: + return "running"; + } +} diff --git a/lib/chat/generate/provisionGenerateSession.ts b/lib/chat/generate/provisionGenerateSession.ts index e6cda2adf..02b6b18d3 100644 --- a/lib/chat/generate/provisionGenerateSession.ts +++ b/lib/chat/generate/provisionGenerateSession.ts @@ -1,15 +1,10 @@ import ms from "ms"; -import { generateUUID } from "@/lib/uuid/generateUUID"; -import { ensurePersonalRepo } from "@/lib/recoupable/ensurePersonalRepo"; -import { buildSessionInsertRow } from "@/lib/sessions/buildSessionInsertRow"; -import { insertSession } from "@/lib/supabase/sessions/insertSession"; -import { insertChat } from "@/lib/supabase/chats/insertChat"; +import { createSessionWithInitialChat } from "@/lib/sessions/createSessionWithInitialChat"; import { connectSandbox } from "@/lib/sandbox/factory"; import { getSessionSandboxName } from "@/lib/sandbox/getSessionSandboxName"; import { resolveGitUser } from "@/lib/sandbox/resolveGitUser"; import { getServiceGithubToken } from "@/lib/github/getServiceGithubToken"; -import { buildActiveLifecycleUpdate } from "@/lib/sandbox/buildActiveLifecycleUpdate"; -import { updateSession } from "@/lib/supabase/sessions/updateSession"; +import { markSessionSandboxActive } from "@/lib/sandbox/markSessionSandboxActive"; import { discoverSkills } from "@/lib/skills/discoverSkills"; import { getSandboxSkillDirectories } from "@/lib/skills/getSandboxSkillDirectories"; import { DEFAULT_WORKING_DIRECTORY } from "@/lib/sandbox/vercel/sandbox/constants"; @@ -28,12 +23,11 @@ export type ProvisionedGenerateSession = { }; /** - * Headlessly provision a session + chat with an ACTIVE sandbox for a scheduled - * `/api/chat/runs` run (recoupable/chat#1813) — the same building blocks the - * interactive path uses (`POST /api/sessions` + `POST /api/sandbox`), composed - * server-side since there is no client session. Mirrors `createSandboxHandler`'s - * connect → `buildActiveLifecycleUpdate` → `updateSession` binding so the - * provisioned session passes `isSandboxActive`. + * Headlessly provision a session + chat with an ACTIVE sandbox for a + * `POST /api/chat/runs` run (recoupable/chat#1813) — composing the same shared + * building blocks the interactive path uses: `createSessionWithInitialChat` + * (`POST /api/sessions`) + `connectSandbox` / `markSessionSandboxActive` + * (`POST /api/sandbox`). Server-side since there is no client session. * * @throws if repo/session/chat/sandbox provisioning fails — the caller maps it * to a 5xx and revokes any minted ephemeral key. @@ -47,44 +41,39 @@ export async function provisionGenerateSession({ title: string; artistId?: string; }): Promise { - const cloneUrl = await ensurePersonalRepo({ accountId }); - if (!cloneUrl) throw new Error("Failed to provision workspace repository"); - - const session = await insertSession( - buildSessionInsertRow({ accountId, title, cloneUrl, artistId }), - ); - if (!session) throw new Error("Failed to create session"); - - const chat = await insertChat({ - id: generateUUID(), - session_id: session.id, - title: "Scheduled generation", + const created = await createSessionWithInitialChat({ + accountId, + title, + chatTitle: "Scheduled generation", + artistId, }); - if (!chat) throw new Error("Failed to create chat"); + if (created.ok === false) { + throw new Error( + created.reason === "repo" + ? "Failed to provision workspace repository" + : "Failed to create session", + ); + } + const { session, chat } = created; - const sandboxName = getSessionSandboxName(session.id); - const gitUser = await resolveGitUser(accountId); const sandbox = await connectSandbox({ - state: { type: "vercel", sandboxName, source: { repo: cloneUrl, prebuilt: false } }, + state: { + type: "vercel", + sandboxName: getSessionSandboxName(session.id), + source: { repo: session.clone_url, prebuilt: false }, + }, options: { timeout: SANDBOX_TIMEOUT_MS, ports: [3000], githubToken: getServiceGithubToken(), - gitUser, + gitUser: await resolveGitUser(accountId), persistent: true, resume: true, createIfMissing: true, }, }); - const sandboxState = sandbox.getState() as Json; - const updated = await updateSession(session.id, { - sandbox_state: sandboxState, - lifecycle_version: session.lifecycle_version + 1, - ...buildActiveLifecycleUpdate(sandboxState), - snapshot_url: null, - snapshot_created_at: null, - }); + const updated = await markSessionSandboxActive(session, sandbox.getState() as Json); if (!updated) throw new Error("Failed to activate session sandbox"); // Best-effort skill + working-directory discovery from the live handle — diff --git a/lib/sandbox/__tests__/markSessionSandboxActive.test.ts b/lib/sandbox/__tests__/markSessionSandboxActive.test.ts new file mode 100644 index 000000000..99621fa25 --- /dev/null +++ b/lib/sandbox/__tests__/markSessionSandboxActive.test.ts @@ -0,0 +1,36 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +import { markSessionSandboxActive } from "@/lib/sandbox/markSessionSandboxActive"; +import { buildActiveLifecycleUpdate } from "@/lib/sandbox/buildActiveLifecycleUpdate"; +import { updateSession } from "@/lib/supabase/sessions/updateSession"; + +vi.mock("@/lib/sandbox/buildActiveLifecycleUpdate", () => ({ + buildActiveLifecycleUpdate: vi.fn(() => ({ + lifecycle_state: "active", + sandbox_expires_at: "T+30m", + })), +})); +vi.mock("@/lib/supabase/sessions/updateSession", () => ({ updateSession: vi.fn() })); + +describe("markSessionSandboxActive", () => { + beforeEach(() => vi.clearAllMocks()); + + it("updates the session with the state, bumped version, active lifecycle, and cleared snapshot", async () => { + vi.mocked(updateSession).mockResolvedValue({ id: "sess-1" } as never); + const sessionRow = { id: "sess-1", lifecycle_version: 4 } as never; + const state = { type: "vercel", sandboxName: "session-sess-1" } as never; + + const out = await markSessionSandboxActive(sessionRow, state); + + expect(buildActiveLifecycleUpdate).toHaveBeenCalledWith(state); + expect(updateSession).toHaveBeenCalledWith("sess-1", { + sandbox_state: state, + lifecycle_version: 5, + lifecycle_state: "active", + sandbox_expires_at: "T+30m", + snapshot_url: null, + snapshot_created_at: null, + }); + expect(out).toEqual({ id: "sess-1" }); + }); +}); diff --git a/lib/sandbox/createSandboxHandler.ts b/lib/sandbox/createSandboxHandler.ts index a4f5237b5..83d74d0a8 100644 --- a/lib/sandbox/createSandboxHandler.ts +++ b/lib/sandbox/createSandboxHandler.ts @@ -3,7 +3,7 @@ import { NextRequest, NextResponse, after } from "next/server"; import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; import { validateCreateSandboxBody } from "@/lib/sandbox/validateCreateSandboxBody"; import { selectSessions } from "@/lib/supabase/sessions/selectSessions"; -import { buildActiveLifecycleUpdate } from "@/lib/sandbox/buildActiveLifecycleUpdate"; +import { markSessionSandboxActive } from "@/lib/sandbox/markSessionSandboxActive"; import { connectSandbox } from "@/lib/sandbox/factory"; import { findOrgSnapshot } from "@/lib/sandbox/findOrgSnapshot"; import { getSessionSandboxName } from "@/lib/sandbox/getSessionSandboxName"; @@ -12,7 +12,6 @@ import { kickBuildOrgSnapshotWorkflow } from "@/lib/sandbox/kickBuildOrgSnapshot import { kickSandboxLifecycleWorkflow } from "@/lib/sandbox/kickSandboxLifecycleWorkflow"; import { resolveGitUser } from "@/lib/sandbox/resolveGitUser"; import { extractOrgRepoName } from "@/lib/recoupable/extractOrgRepoName"; -import { updateSession } from "@/lib/supabase/sessions/updateSession"; import { getServiceGithubToken } from "@/lib/github/getServiceGithubToken"; import type { Json, Tables } from "@/types/database.types"; @@ -124,21 +123,12 @@ export async function createSandboxHandler(request: NextRequest): Promise, + sandboxState: Json, +): Promise | null> { + return updateSession(sessionRow.id, { + sandbox_state: sandboxState, + lifecycle_version: sessionRow.lifecycle_version + 1, + ...buildActiveLifecycleUpdate(sandboxState), + snapshot_url: null, + snapshot_created_at: null, + }); +} diff --git a/lib/sessions/__tests__/createSessionWithInitialChat.test.ts b/lib/sessions/__tests__/createSessionWithInitialChat.test.ts new file mode 100644 index 000000000..f30286ffd --- /dev/null +++ b/lib/sessions/__tests__/createSessionWithInitialChat.test.ts @@ -0,0 +1,63 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +import { createSessionWithInitialChat } from "@/lib/sessions/createSessionWithInitialChat"; +import { ensurePersonalRepo } from "@/lib/recoupable/ensurePersonalRepo"; +import { buildSessionInsertRow } from "@/lib/sessions/buildSessionInsertRow"; +import { insertSession } from "@/lib/supabase/sessions/insertSession"; +import { deleteSessionById } from "@/lib/supabase/sessions/deleteSessionById"; +import { insertChat } from "@/lib/supabase/chats/insertChat"; + +vi.mock("@/lib/uuid/generateUUID", () => ({ generateUUID: vi.fn(() => "chat-uuid") })); +vi.mock("@/lib/recoupable/ensurePersonalRepo", () => ({ ensurePersonalRepo: vi.fn() })); +vi.mock("@/lib/sessions/buildSessionInsertRow", () => ({ + buildSessionInsertRow: vi.fn(x => ({ row: true, ...x })), +})); +vi.mock("@/lib/supabase/sessions/insertSession", () => ({ insertSession: vi.fn() })); +vi.mock("@/lib/supabase/sessions/deleteSessionById", () => ({ deleteSessionById: vi.fn() })); +vi.mock("@/lib/supabase/chats/insertChat", () => ({ insertChat: vi.fn() })); + +const args = { accountId: "acc-1", title: "T", chatTitle: "New chat", artistId: "art-1" }; + +describe("createSessionWithInitialChat", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(ensurePersonalRepo).mockResolvedValue("https://github.com/recoupable/acc-1"); + vi.mocked(insertSession).mockResolvedValue({ id: "sess-1" } as never); + vi.mocked(insertChat).mockResolvedValue({ id: "chat-1" } as never); + vi.mocked(deleteSessionById).mockResolvedValue(true as never); + }); + + it("returns { ok, session, chat } on success and builds the row with the resolved clone url", async () => { + const r = await createSessionWithInitialChat(args); + expect(r).toEqual({ ok: true, session: { id: "sess-1" }, chat: { id: "chat-1" } }); + expect(buildSessionInsertRow).toHaveBeenCalledWith( + expect.objectContaining({ + accountId: "acc-1", + cloneUrl: "https://github.com/recoupable/acc-1", + }), + ); + }); + + it("uses workspaceAccountId for the repo when provided", async () => { + await createSessionWithInitialChat({ ...args, workspaceAccountId: "org-9" }); + expect(ensurePersonalRepo).toHaveBeenCalledWith({ accountId: "org-9" }); + }); + + it("returns reason 'repo' when the workspace repo can't be provisioned", async () => { + vi.mocked(ensurePersonalRepo).mockResolvedValue(null); + expect(await createSessionWithInitialChat(args)).toEqual({ ok: false, reason: "repo" }); + expect(insertSession).not.toHaveBeenCalled(); + }); + + it("returns reason 'insert' when the session insert fails", async () => { + vi.mocked(insertSession).mockResolvedValue(null as never); + expect(await createSessionWithInitialChat(args)).toEqual({ ok: false, reason: "insert" }); + }); + + it("rolls back the session and returns 'insert' when the chat insert fails", async () => { + vi.mocked(insertChat).mockResolvedValue(null as never); + const r = await createSessionWithInitialChat(args); + expect(r).toEqual({ ok: false, reason: "insert" }); + expect(deleteSessionById).toHaveBeenCalledWith("sess-1"); + }); +}); diff --git a/lib/sessions/createSessionHandler.ts b/lib/sessions/createSessionHandler.ts index e48020537..9667695cd 100644 --- a/lib/sessions/createSessionHandler.ts +++ b/lib/sessions/createSessionHandler.ts @@ -1,14 +1,12 @@ import { NextRequest, NextResponse } from "next/server"; import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; -import { generateUUID } from "@/lib/uuid/generateUUID"; import { validateCreateSessionBody } from "@/lib/sessions/validateCreateSessionBody"; import { resolveSessionTitle } from "@/lib/sessions/resolveSessionTitle"; -import { ensurePersonalRepo } from "@/lib/recoupable/ensurePersonalRepo"; -import { buildSessionInsertRow } from "@/lib/sessions/buildSessionInsertRow"; +import { + createSessionWithInitialChat, + type CreateSessionWithChatResult, +} from "@/lib/sessions/createSessionWithInitialChat"; import { failedToCreateSession } from "@/lib/sessions/failedToCreateSession"; -import { insertSession } from "@/lib/supabase/sessions/insertSession"; -import { deleteSessionById } from "@/lib/supabase/sessions/deleteSessionById"; -import { insertChat } from "@/lib/supabase/chats/insertChat"; import { toSessionResponse } from "@/lib/sessions/toSessionResponse"; import { toChatResponse } from "@/lib/sessions/toChatResponse"; @@ -18,11 +16,12 @@ const INITIAL_CHAT_TITLE = "New chat"; * Handles `POST /api/sessions`. * * Authenticates, validates the request, resolves a final session - * title (provided > random city fallback), ensures the workspace repo - * exists at `recoupable/`, then creates - * a session row and an initial chat row. If the chat insert fails - * after the session row is persisted, the session is rolled back so - * callers never observe an orphaned session. + * title (provided > random city fallback), then provisions the workspace + * repo + session + initial chat via the shared + * `createSessionWithInitialChat` (also used by the headless + * `POST /api/chat/runs` path). If the chat insert fails after the session + * is persisted, the session is rolled back so callers never observe an + * orphaned session. * * The clone URL is derived server-side — callers never construct * GitHub URLs. Personal sessions (no `organizationId` in body) use @@ -44,47 +43,26 @@ export async function createSessionHandler(request: NextRequest): Promise; chat: Tables<"chats"> } + | { ok: false; reason: "repo" | "insert" }; + +/** + * Shared core for provisioning a session + its initial chat — used by both the + * interactive `POST /api/sessions` (`createSessionHandler`) and the headless + * `POST /api/chat/runs` path (`provisionGenerateSession`), so the two stay in + * lockstep (recoupable/chat#1813). + * + * Ensures the workspace repo exists (`recoupable/`), inserts + * the session row, then the initial chat row. If the chat insert fails after the + * session is persisted, the session is rolled back so callers never observe an + * orphaned session. + * + * The clone URL is derived server-side. `workspaceAccountId` (org id for org + * sessions) defaults to `accountId` for personal sessions. + * + * @returns `{ ok: true, session, chat }`, or `{ ok: false, reason }` where + * `"repo"` = workspace-repo provisioning failed and `"insert"` = a session/chat + * insert failed (session already rolled back). Callers map these to their own + * error envelope (the route returns 502/500; the headless path throws). + */ +export async function createSessionWithInitialChat({ + accountId, + workspaceAccountId, + title, + chatTitle, + artistId, +}: { + accountId: string; + workspaceAccountId?: string; + title: string; + chatTitle: string; + artistId?: string; +}): Promise { + const cloneUrl = await ensurePersonalRepo({ accountId: workspaceAccountId ?? accountId }); + if (!cloneUrl) return { ok: false, reason: "repo" }; + + const session = await insertSession( + buildSessionInsertRow({ accountId, title, cloneUrl, artistId }), + ); + if (!session) return { ok: false, reason: "insert" }; + + const chat = await insertChat({ id: generateUUID(), session_id: session.id, title: chatTitle }); + if (!chat) { + const rolledBack = await deleteSessionById(session.id); + if (!rolledBack) { + console.error( + "[createSessionWithInitialChat] chat insert failed and session rollback failed — orphaned session:", + session.id, + ); + } + return { ok: false, reason: "insert" }; + } + + return { ok: true, session, chat }; +} From 10a727cea71219d6b0d9fb4be73d21762efcbcbe Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Wed, 24 Jun 2026 15:56:47 -0500 Subject: [PATCH 8/8] =?UTF-8?q?refactor(chat/runs):=20rename=20lib/chat/ge?= =?UTF-8?q?nerate=20=E2=86=92=20lib/chat/runs=20(match=20the=20endpoint)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The endpoint was renamed /api/chat/generate → /api/chat/runs, but the internal helpers kept "generate" — pointing at a removed concept, and split across two dirs (handleStartChatRun lived in lib/chat/, its helpers in lib/chat/generate/). Pure rename, no behavior change: - lib/chat/generate/ → lib/chat/runs/ (handleStartChatRun + its test moved in too) - validateGenerateRequest → validateChatRunRequest (file + symbol) - provisionGenerateSession → provisionRunSession (file + symbol) - ProvisionedGenerateSession → ProvisionedRunSession - generateBodySchema → chatRunBodySchema, GenerateRequest → ChatRunRequest - DEFAULT_GENERATE_MODEL_ID → DEFAULT_RUN_MODEL_ID - updated JSDoc refs in the shared createSandboxHandler / markSessionSandboxActive / createSessionWithInitialChat git mv preserves history. lib/chat/generateChatTitle (unrelated) left untouched. Feature suites green (126), tsc + lint clean. (chat#1813) Co-Authored-By: Claude Opus 4.8 (1M context) --- app/api/chat/runs/[runId]/route.ts | 2 +- app/api/chat/runs/route.ts | 2 +- .../__tests__/handleChatRunStatus.test.ts | 2 +- .../__tests__/handleStartChatRun.test.ts | 26 +++++++++---------- .../__tests__/normalizeRunStatus.test.ts | 2 +- .../__tests__/validateChatRunRequest.test.ts} | 18 ++++++------- .../{generate => runs}/handleChatRunStatus.ts | 2 +- lib/chat/{ => runs}/handleStartChatRun.ts | 8 +++--- .../{generate => runs}/normalizeRunStatus.ts | 0 .../provisionRunSession.ts} | 8 +++--- .../validateChatRunRequest.ts} | 14 +++++----- lib/sandbox/createSandboxHandler.ts | 2 +- lib/sandbox/markSessionSandboxActive.ts | 2 +- lib/sessions/createSessionWithInitialChat.ts | 2 +- 14 files changed, 45 insertions(+), 45 deletions(-) rename lib/chat/{generate => runs}/__tests__/handleChatRunStatus.test.ts (96%) rename lib/chat/{ => runs}/__tests__/handleStartChatRun.test.ts (81%) rename lib/chat/{generate => runs}/__tests__/normalizeRunStatus.test.ts (92%) rename lib/chat/{generate/__tests__/validateGenerateRequest.test.ts => runs/__tests__/validateChatRunRequest.test.ts} (80%) rename lib/chat/{generate => runs}/handleChatRunStatus.ts (95%) rename lib/chat/{ => runs}/handleStartChatRun.ts (93%) rename lib/chat/{generate => runs}/normalizeRunStatus.ts (100%) rename lib/chat/{generate/provisionGenerateSession.ts => runs/provisionRunSession.ts} (93%) rename lib/chat/{generate/validateGenerateRequest.ts => runs/validateChatRunRequest.ts} (89%) diff --git a/app/api/chat/runs/[runId]/route.ts b/app/api/chat/runs/[runId]/route.ts index 05bb8c0ff..725e06593 100644 --- a/app/api/chat/runs/[runId]/route.ts +++ b/app/api/chat/runs/[runId]/route.ts @@ -1,7 +1,7 @@ import type { NextRequest } from "next/server"; import { NextResponse } from "next/server"; import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; -import { handleChatRunStatus } from "@/lib/chat/generate/handleChatRunStatus"; +import { handleChatRunStatus } from "@/lib/chat/runs/handleChatRunStatus"; /** * OPTIONS handler for CORS preflight requests. diff --git a/app/api/chat/runs/route.ts b/app/api/chat/runs/route.ts index d847b5c2f..0f667665b 100644 --- a/app/api/chat/runs/route.ts +++ b/app/api/chat/runs/route.ts @@ -1,7 +1,7 @@ import type { NextRequest } from "next/server"; import { NextResponse } from "next/server"; import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; -import { handleStartChatRun } from "@/lib/chat/handleStartChatRun"; +import { handleStartChatRun } from "@/lib/chat/runs/handleStartChatRun"; /** * OPTIONS handler for CORS preflight requests. diff --git a/lib/chat/generate/__tests__/handleChatRunStatus.test.ts b/lib/chat/runs/__tests__/handleChatRunStatus.test.ts similarity index 96% rename from lib/chat/generate/__tests__/handleChatRunStatus.test.ts rename to lib/chat/runs/__tests__/handleChatRunStatus.test.ts index c95b84e72..d1f4169e8 100644 --- a/lib/chat/generate/__tests__/handleChatRunStatus.test.ts +++ b/lib/chat/runs/__tests__/handleChatRunStatus.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { NextRequest, NextResponse } from "next/server"; -import { handleChatRunStatus } from "@/lib/chat/generate/handleChatRunStatus"; +import { handleChatRunStatus } from "@/lib/chat/runs/handleChatRunStatus"; import { validateAuthContext } from "@/lib/auth/validateAuthContext"; import { getRun } from "workflow/api"; diff --git a/lib/chat/__tests__/handleStartChatRun.test.ts b/lib/chat/runs/__tests__/handleStartChatRun.test.ts similarity index 81% rename from lib/chat/__tests__/handleStartChatRun.test.ts rename to lib/chat/runs/__tests__/handleStartChatRun.test.ts index e62703732..3f1c8555b 100644 --- a/lib/chat/__tests__/handleStartChatRun.test.ts +++ b/lib/chat/runs/__tests__/handleStartChatRun.test.ts @@ -1,9 +1,9 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { NextRequest, NextResponse } from "next/server"; -import { handleStartChatRun } from "@/lib/chat/handleStartChatRun"; -import { validateGenerateRequest } from "@/lib/chat/generate/validateGenerateRequest"; -import { provisionGenerateSession } from "@/lib/chat/generate/provisionGenerateSession"; +import { handleStartChatRun } from "@/lib/chat/runs/handleStartChatRun"; +import { validateChatRunRequest } from "@/lib/chat/runs/validateChatRunRequest"; +import { provisionRunSession } from "@/lib/chat/runs/provisionRunSession"; import { mintEphemeralAccountKey } from "@/lib/keys/mintEphemeralAccountKey"; import { deleteApiKey } from "@/lib/supabase/account_api_keys/deleteApiKey"; import { buildRunAgentInput } from "@/lib/chat/buildRunAgentInput"; @@ -12,11 +12,11 @@ import { start } from "workflow/api"; vi.mock("@/lib/networking/getCorsHeaders", () => ({ getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })), })); -vi.mock("@/lib/chat/generate/validateGenerateRequest", () => ({ - validateGenerateRequest: vi.fn(), +vi.mock("@/lib/chat/runs/validateChatRunRequest", () => ({ + validateChatRunRequest: vi.fn(), })); -vi.mock("@/lib/chat/generate/provisionGenerateSession", () => ({ - provisionGenerateSession: vi.fn(), +vi.mock("@/lib/chat/runs/provisionRunSession", () => ({ + provisionRunSession: vi.fn(), })); vi.mock("@/lib/keys/mintEphemeralAccountKey", () => ({ mintEphemeralAccountKey: vi.fn(), @@ -62,8 +62,8 @@ const provisioned = { describe("handleStartChatRun", () => { beforeEach(() => { vi.clearAllMocks(); - vi.mocked(validateGenerateRequest).mockResolvedValue(validated as never); - vi.mocked(provisionGenerateSession).mockResolvedValue(provisioned as never); + vi.mocked(validateChatRunRequest).mockResolvedValue(validated as never); + vi.mocked(provisionRunSession).mockResolvedValue(provisioned as never); vi.mocked(mintEphemeralAccountKey).mockResolvedValue({ rawKey: "recoup_sk_raw", keyId: "key-1", @@ -81,7 +81,7 @@ describe("handleStartChatRun", () => { sessionId: "sess-1", }); - expect(provisionGenerateSession).toHaveBeenCalledWith( + expect(provisionRunSession).toHaveBeenCalledWith( expect.objectContaining({ accountId: "acc-1", title: "Scheduled generation" }), ); // the minted key is injected as recoupAccessToken AND threaded as ephemeralKeyId @@ -99,12 +99,12 @@ describe("handleStartChatRun", () => { }); it("returns the validation error short-circuit", async () => { - vi.mocked(validateGenerateRequest).mockResolvedValue( + vi.mocked(validateChatRunRequest).mockResolvedValue( NextResponse.json({ status: "error" }, { status: 401 }), ); const res = await handleStartChatRun(req()); expect(res.status).toBe(401); - expect(provisionGenerateSession).not.toHaveBeenCalled(); + expect(provisionRunSession).not.toHaveBeenCalled(); }); it("revokes the minted key and 500s when start() fails", async () => { @@ -115,7 +115,7 @@ describe("handleStartChatRun", () => { }); it("does not mint or delete a key when provisioning fails", async () => { - vi.mocked(provisionGenerateSession).mockRejectedValue(new Error("repo boom")); + vi.mocked(provisionRunSession).mockRejectedValue(new Error("repo boom")); const res = await handleStartChatRun(req()); expect(res.status).toBe(500); expect(mintEphemeralAccountKey).not.toHaveBeenCalled(); diff --git a/lib/chat/generate/__tests__/normalizeRunStatus.test.ts b/lib/chat/runs/__tests__/normalizeRunStatus.test.ts similarity index 92% rename from lib/chat/generate/__tests__/normalizeRunStatus.test.ts rename to lib/chat/runs/__tests__/normalizeRunStatus.test.ts index 150ccdaa4..1b6ccd845 100644 --- a/lib/chat/generate/__tests__/normalizeRunStatus.test.ts +++ b/lib/chat/runs/__tests__/normalizeRunStatus.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import { normalizeRunStatus } from "@/lib/chat/generate/normalizeRunStatus"; +import { normalizeRunStatus } from "@/lib/chat/runs/normalizeRunStatus"; describe("normalizeRunStatus", () => { it("maps known states to the documented enum", () => { diff --git a/lib/chat/generate/__tests__/validateGenerateRequest.test.ts b/lib/chat/runs/__tests__/validateChatRunRequest.test.ts similarity index 80% rename from lib/chat/generate/__tests__/validateGenerateRequest.test.ts rename to lib/chat/runs/__tests__/validateChatRunRequest.test.ts index 0ee1cae15..aae8f0903 100644 --- a/lib/chat/generate/__tests__/validateGenerateRequest.test.ts +++ b/lib/chat/runs/__tests__/validateChatRunRequest.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { NextRequest, NextResponse } from "next/server"; -import { validateGenerateRequest } from "@/lib/chat/generate/validateGenerateRequest"; +import { validateChatRunRequest } from "@/lib/chat/runs/validateChatRunRequest"; import { validateAuthContext } from "@/lib/auth/validateAuthContext"; vi.mock("@/lib/networking/getCorsHeaders", () => ({ @@ -22,14 +22,14 @@ function req(body: unknown): NextRequest { const okAuth = { accountId: "acc-1", orgId: null, authToken: "recoup_sk_test" }; -describe("validateGenerateRequest", () => { +describe("validateChatRunRequest", () => { beforeEach(() => { vi.clearAllMocks(); vi.mocked(validateAuthContext).mockResolvedValue(okAuth); }); it("converts a prompt into a single user UIMessage", async () => { - const result = await validateGenerateRequest(req({ prompt: "weekly report please" })); + const result = await validateChatRunRequest(req({ prompt: "weekly report please" })); expect(result).not.toBeInstanceOf(NextResponse); if (result instanceof NextResponse) return; expect(result.accountId).toBe("acc-1"); @@ -39,7 +39,7 @@ describe("validateGenerateRequest", () => { }); it("passes messages through and applies the model override + default", async () => { - const withModel = await validateGenerateRequest( + const withModel = await validateChatRunRequest( req({ messages: [{ id: "m1", role: "user", parts: [{ type: "text", text: "hi" }] }], model: "anthropic/claude-opus-4-8", @@ -48,20 +48,20 @@ describe("validateGenerateRequest", () => { if (withModel instanceof NextResponse) throw new Error("unexpected error"); expect(withModel.modelId).toBe("anthropic/claude-opus-4-8"); - const noModel = await validateGenerateRequest(req({ prompt: "hi" })); + const noModel = await validateChatRunRequest(req({ prompt: "hi" })); if (noModel instanceof NextResponse) throw new Error("unexpected error"); expect(noModel.modelId).toBe("anthropic/claude-haiku-4.5"); }); it("rejects when neither prompt nor messages is provided (400)", async () => { - const result = await validateGenerateRequest(req({ model: "anthropic/claude-haiku-4.5" })); + const result = await validateChatRunRequest(req({ model: "anthropic/claude-haiku-4.5" })); expect(result).toBeInstanceOf(NextResponse); if (!(result instanceof NextResponse)) return; expect(result.status).toBe(400); }); it("rejects a whitespace-only prompt (400)", async () => { - const result = await validateGenerateRequest(req({ prompt: " \n\t " })); + const result = await validateChatRunRequest(req({ prompt: " \n\t " })); expect(result).toBeInstanceOf(NextResponse); if (!(result instanceof NextResponse)) return; expect(result.status).toBe(400); @@ -71,14 +71,14 @@ describe("validateGenerateRequest", () => { vi.mocked(validateAuthContext).mockResolvedValue( NextResponse.json({ status: "error" }, { status: 401 }), ); - const result = await validateGenerateRequest(req({ prompt: "hi" })); + const result = await validateChatRunRequest(req({ prompt: "hi" })); expect(result).toBeInstanceOf(NextResponse); if (!(result instanceof NextResponse)) return; expect(result.status).toBe(401); }); it("forwards body accountId override to validateAuthContext", async () => { - await validateGenerateRequest(req({ prompt: "hi", accountId: "member-acc" })); + await validateChatRunRequest(req({ prompt: "hi", accountId: "member-acc" })); expect(validateAuthContext).toHaveBeenCalledWith( expect.anything(), expect.objectContaining({ accountId: "member-acc" }), diff --git a/lib/chat/generate/handleChatRunStatus.ts b/lib/chat/runs/handleChatRunStatus.ts similarity index 95% rename from lib/chat/generate/handleChatRunStatus.ts rename to lib/chat/runs/handleChatRunStatus.ts index 22b06fad1..a26fcbd8f 100644 --- a/lib/chat/generate/handleChatRunStatus.ts +++ b/lib/chat/runs/handleChatRunStatus.ts @@ -3,7 +3,7 @@ import { getRun } from "workflow/api"; import { validateAuthContext } from "@/lib/auth/validateAuthContext"; import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; import { errorResponse } from "@/lib/networking/errorResponse"; -import { normalizeRunStatus } from "@/lib/chat/generate/normalizeRunStatus"; +import { normalizeRunStatus } from "@/lib/chat/runs/normalizeRunStatus"; /** * Handles `GET /api/chat/runs/{runId}` — a point-in-time status snapshot for an diff --git a/lib/chat/handleStartChatRun.ts b/lib/chat/runs/handleStartChatRun.ts similarity index 93% rename from lib/chat/handleStartChatRun.ts rename to lib/chat/runs/handleStartChatRun.ts index 508ef7aa6..2db6a305a 100644 --- a/lib/chat/handleStartChatRun.ts +++ b/lib/chat/runs/handleStartChatRun.ts @@ -2,8 +2,8 @@ import { NextRequest, NextResponse } from "next/server"; import { start } from "workflow/api"; import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; import { errorResponse } from "@/lib/networking/errorResponse"; -import { validateGenerateRequest } from "@/lib/chat/generate/validateGenerateRequest"; -import { provisionGenerateSession } from "@/lib/chat/generate/provisionGenerateSession"; +import { validateChatRunRequest } from "@/lib/chat/runs/validateChatRunRequest"; +import { provisionRunSession } from "@/lib/chat/runs/provisionRunSession"; import { mintEphemeralAccountKey } from "@/lib/keys/mintEphemeralAccountKey"; import { deleteApiKey } from "@/lib/supabase/account_api_keys/deleteApiKey"; import { buildRunAgentInput } from "@/lib/chat/buildRunAgentInput"; @@ -34,14 +34,14 @@ const DEFAULT_RUN_SESSION_TITLE = "Scheduled generation"; * @returns 202 `{ runId, chatId, sessionId }`, or a 4xx/5xx error. */ export async function handleStartChatRun(request: NextRequest): Promise { - const validated = await validateGenerateRequest(request); + const validated = await validateChatRunRequest(request); if (validated instanceof NextResponse) return validated; const { accountId, messages, artistId, modelId } = validated; let ephemeralKeyId: string | undefined; try { - const provisioned = await provisionGenerateSession({ + const provisioned = await provisionRunSession({ accountId, title: DEFAULT_RUN_SESSION_TITLE, artistId, diff --git a/lib/chat/generate/normalizeRunStatus.ts b/lib/chat/runs/normalizeRunStatus.ts similarity index 100% rename from lib/chat/generate/normalizeRunStatus.ts rename to lib/chat/runs/normalizeRunStatus.ts diff --git a/lib/chat/generate/provisionGenerateSession.ts b/lib/chat/runs/provisionRunSession.ts similarity index 93% rename from lib/chat/generate/provisionGenerateSession.ts rename to lib/chat/runs/provisionRunSession.ts index 02b6b18d3..f1ed3bd95 100644 --- a/lib/chat/generate/provisionGenerateSession.ts +++ b/lib/chat/runs/provisionRunSession.ts @@ -14,7 +14,7 @@ import type { SkillMetadata } from "@/lib/skills/skillTypes"; const SANDBOX_TIMEOUT_MS = ms("30m"); -export type ProvisionedGenerateSession = { +export type ProvisionedRunSession = { session: Tables<"sessions">; chat: Tables<"chats">; sandboxState: VercelState; @@ -32,7 +32,7 @@ export type ProvisionedGenerateSession = { * @throws if repo/session/chat/sandbox provisioning fails — the caller maps it * to a 5xx and revokes any minted ephemeral key. */ -export async function provisionGenerateSession({ +export async function provisionRunSession({ accountId, title, artistId, @@ -40,7 +40,7 @@ export async function provisionGenerateSession({ accountId: string; title: string; artistId?: string; -}): Promise { +}): Promise { const created = await createSessionWithInitialChat({ accountId, title, @@ -85,7 +85,7 @@ export async function provisionGenerateSession({ workingDirectory = sandbox.workingDirectory; skills = await discoverSkills(sandbox, await getSandboxSkillDirectories(sandbox)); } catch (error) { - console.error("[provisionGenerateSession] skill discovery failed; using defaults:", error); + console.error("[provisionRunSession] skill discovery failed; using defaults:", error); } return { diff --git a/lib/chat/generate/validateGenerateRequest.ts b/lib/chat/runs/validateChatRunRequest.ts similarity index 89% rename from lib/chat/generate/validateGenerateRequest.ts rename to lib/chat/runs/validateChatRunRequest.ts index faf53489e..154f47e6c 100644 --- a/lib/chat/generate/validateGenerateRequest.ts +++ b/lib/chat/runs/validateChatRunRequest.ts @@ -8,7 +8,7 @@ import { validationErrorResponse } from "@/lib/zod/validationErrorResponse"; import { generateUUID } from "@/lib/uuid/generateUUID"; /** Default model for headless generation when the caller omits `model`. */ -export const DEFAULT_GENERATE_MODEL_ID = "anthropic/claude-haiku-4.5"; +export const DEFAULT_RUN_MODEL_ID = "anthropic/claude-haiku-4.5"; /** * Body schema for `POST /api/chat/runs` (the durable-workflow re-point, @@ -17,7 +17,7 @@ export const DEFAULT_GENERATE_MODEL_ID = "anthropic/claude-haiku-4.5"; * path mints its own session + chat (with a default title) and runs native * sandbox tools. The legacy `topic` / `roomId` / `excludeTools` fields are gone. */ -export const generateBodySchema = z.object({ +export const chatRunBodySchema = z.object({ prompt: z.string().optional(), messages: z.array(z.any()).optional(), artistId: z.string().uuid("artistId must be a valid UUID").optional(), @@ -26,7 +26,7 @@ export const generateBodySchema = z.object({ model: z.string().optional(), }); -export type GenerateRequest = { +export type ChatRunRequest = { accountId: string; orgId: string | null; messages: UIMessage[]; @@ -43,9 +43,9 @@ export type GenerateRequest = { * @returns A NextResponse error short-circuit (400/401/403) or the validated, * auth-augmented request ready to provision + start a workflow run. */ -export async function validateGenerateRequest( +export async function validateChatRunRequest( request: NextRequest, -): Promise { +): Promise { let rawBody: unknown; try { rawBody = await request.json(); @@ -53,7 +53,7 @@ export async function validateGenerateRequest( return errorResponse("Invalid JSON body", 400); } - const parsed = generateBodySchema.safeParse(rawBody); + const parsed = chatRunBodySchema.safeParse(rawBody); if (!parsed.success) { const firstError = parsed.error.issues[0]; return validationErrorResponse(firstError.message, firstError.path); @@ -83,6 +83,6 @@ export async function validateGenerateRequest( orgId: auth.orgId, messages: uiMessages, artistId, - modelId: model ?? DEFAULT_GENERATE_MODEL_ID, + modelId: model ?? DEFAULT_RUN_MODEL_ID, }; } diff --git a/lib/sandbox/createSandboxHandler.ts b/lib/sandbox/createSandboxHandler.ts index 83d74d0a8..3f2d435c4 100644 --- a/lib/sandbox/createSandboxHandler.ts +++ b/lib/sandbox/createSandboxHandler.ts @@ -124,7 +124,7 @@ export async function createSandboxHandler(request: NextRequest): Promise`), inserts