Skip to content
Merged

Test #706

Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
f99277e
feat(measurement-jobs): free-tier card gate (setup mode) + instant ba…
sweetmantech Jun 16, 2026
158a1d4
Merge remote-tracking branch 'origin/main' into test
sweetmantech Jun 16, 2026
5fa5e3a
fix(songstats-backfill): backoff on 429 + defer instead of churn (cha…
sweetmantech Jun 16, 2026
1e9deac
Merge remote-tracking branch 'origin/main' into test
sweetmantech Jun 16, 2026
5f45946
refactor(songstats): remove local quota ledger + budget gate (chat#17…
sweetmantech Jun 16, 2026
5472795
Merge remote-tracking branch 'origin/main' into test
sweetmantech Jun 16, 2026
f24d4c7
feat: POST /api/catalogs (create + materialize from valuation snapsho…
sweetmantech Jun 18, 2026
ace0b01
Merge remote-tracking branch 'origin/main' into test
sweetmantech Jun 18, 2026
a520a1d
fix: LEFT-join artists in catalog-songs read (materialized tracks wer…
sweetmantech Jun 18, 2026
76b5739
Merge remote-tracking branch 'origin/main' into test
sweetmantech Jun 18, 2026
709ea0c
feat: add X (Twitter) + LinkedIn to the Composio connector whitelist …
sweetmantech Jun 18, 2026
8a3c3cb
Merge remote-tracking branch 'origin/main' into test
sweetmantech Jun 18, 2026
66cc2fe
chore: remove unused ALLOWED_ARTIST_CONNECTORS from api (chat#1793) (…
sweetmantech Jun 18, 2026
79c22da
Merge remote-tracking branch 'origin/main' into test
sweetmantech Jun 18, 2026
6fc10cc
fix: enrich valuation-captured songs (artists + notes) so they render…
sweetmantech Jun 18, 2026
0e42d02
Merge remote-tracking branch 'origin/main' into test
sweetmantech Jun 18, 2026
2fa7029
fix(tasks): let admins fetch any task by id alone (cross-account read…
sweetmantech Jun 19, 2026
bd2ae10
Merge remote-tracking branch 'origin/main' into test
sweetmantech Jun 19, 2026
cdb34d0
feat(connectors): POST /api/connectors/files — stage images for Linke…
sweetmantech Jun 20, 2026
acae4d8
Merge main into test (sync #692 Songstats work)
sweetmantech Jun 23, 2026
2a7af68
feat(artists): account_id override for DELETE /api/artists/{id} (#693)
sweetmantech Jun 23, 2026
8c962ef
Merge main into test (sync #696)
sweetmantech Jun 23, 2026
8b6a3bb
feat(chats): admins (RECOUP_ORG) can access any chat — read + write (…
sweetmantech Jun 23, 2026
03e0ef5
Enforce account_api_keys.expires_at in x-api-key auth (chat#1813) (#700)
sweetmantech Jun 24, 2026
bff29ae
Merge branch 'main' into test
sweetmantech Jun 24, 2026
78bd71b
refactor(chat): extract shared buildRunAgentInput (chat#1813) (#701)
sweetmantech Jun 24, 2026
2209b7d
Re-point POST /api/chat/generate onto runAgentWorkflow + ephemeral ke…
sweetmantech Jun 24, 2026
af7eaa5
Merge remote-tracking branch 'origin/main' into test
sweetmantech Jun 24, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 0 additions & 49 deletions app/api/chat/generate/route.ts

This file was deleted.

42 changes: 42 additions & 0 deletions app/api/chat/runs/[runId]/route.ts
Original file line number Diff line number Diff line change
@@ -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/runs/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<Response> {
const { runId } = await ctx.params;
return handleChatRunStatus(request, runId);
}

export const dynamic = "force-dynamic";
55 changes: 55 additions & 0 deletions app/api/chat/runs/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import { getCorsHeaders } from "@/lib/networking/getCorsHeaders";
import { handleStartChatRun } from "@/lib/chat/runs/handleStartChatRun";

/**
* OPTIONS handler for CORS preflight requests.
*
* @returns A NextResponse with CORS headers.
*/
export async function OPTIONS() {
return new NextResponse(null, {
status: 200,
headers: getCorsHeaders(),
});
}

/**
* POST /api/chat/runs
*
* 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`).
*
* Request body:
* - prompt: String prompt (mutually exclusive with messages)
* - messages: Array of UIMessages (mutually exclusive with prompt)
* - artistId: Optional UUID of the artist account
* - 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 (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<Response> {
return handleStartChatRun(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";
35 changes: 35 additions & 0 deletions app/lib/workflows/__tests__/runAgentWorkflow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
}));
Expand Down Expand Up @@ -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",
Expand Down
25 changes: 25 additions & 0 deletions app/lib/workflows/deleteEphemeralKeyStep.ts
Original file line number Diff line number Diff line change
@@ -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/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.
*
* 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<void> {
"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);
}
}
13 changes: 11 additions & 2 deletions app/lib/workflows/runAgentWorkflow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -151,11 +152,19 @@ export async function runAgentWorkflow(input: RunAgentWorkflowInput): Promise<vo
// stream-end. Mirrors open-agents'
// `Promise.all([clearActiveStream, sendFinish.then(closeStream)])`.
//
// `Promise.all` is safe because both helpers swallow their own
// errors — a failure in one doesn't cancel the other.
// A third cleanup step runs only for headless `/api/chat/runs` runs:
// 3) `deleteEphemeralKeyStep` — revoke the per-run, account-scoped
// `recoup_sk_…` key minted for the sandbox the moment the run ends.
// The key's ~15m TTL is only the backstop if this delete is missed.
//
// `Promise.all` is safe because all helpers swallow their own errors —
// a failure in one doesn't cancel the others.
await Promise.all([
clearChatActiveStream(input.chatId, workflowRunId),
closeChatStream(writable),
...(input.agentContext.ephemeralKeyId
? [deleteEphemeralKeyStep(input.agentContext.ephemeralKeyId)]
: []),
]);
}
}
9 changes: 9 additions & 0 deletions lib/agent/tools/AgentContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,15 @@ export type AgentContext = {
* Empty / undefined when the sandbox has no `skills/` directory.
*/
skills?: SkillMetadata[];
/**
* Row id of an ephemeral, account-scoped `recoup_sk_…` key minted for a
* headless run (`/api/chat/runs`, recoupable/chat#1813). When set, the
* workflow deletes the key in its `finally` cleanup so the credential is
* revoked the moment the run ends (the key's ~15m `expires_at` TTL is the
* defense-in-depth backstop if that delete is missed). The interactive
* caller forwards a Privy JWT and never sets this.
*/
ephemeralKeyId?: string;
/**
* Main agent's language model. Tools read this via `getMainModel`.
* Set per-step by `runAgentStep` (not part of the durable input).
Expand Down
Loading
Loading