evOo-local/apps/api/src/media-jobs.ts
Claude 2796965256
feat(media): media tools, media RPCs and owner-only media settings API
Bots and users need to start and follow media jobs, and the deployment owner
needs to point the deployment at their GPU and transcription servers.

- Media tools are named like their operations. A run sees one only when the
  space can run that operation (MediaRuntime.capabilities); the per-bot
  allowlist still applies. applyTool hands every media tool to one
  dispatcher (media-tools.ts) that splits off continue_after, starts the job
  under the tool call's effect key (a replay returns the same job) and returns
  at once; refusals (invalid input, unknown artifact, unavailable) go back to
  the model as tool errors.
- media.jobs.start starts an export, transcription or image edit from a card:
  the conversation is resolved like every thread procedure, the card's bot
  (or the group's lead) posts the new card, and the runtime refuses source
  artifacts outside the actor's space. media.jobs.get and media.jobs.cancel
  only see the actor's own jobs.
- media.settings.get/update are deployment-owner only, never return keys and
  answer invalid URLs with a 400 that says what is wrong.
- createApp accepts media operations as a test seam.
- Tests: a Postgres suite over the RPCs (owner-only settings, encryption,
  key retention per server, URL policy, start/get/cancel isolation between
  users, card jobs end to end) and unit tests for tool exposure and dispatch.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
2026-10-03 13:47:44 +00:00

119 lines
3.9 KiB
TypeScript

import { ORPCError } from "@orpc/server";
import type { MediaRuntime } from "@rakazo/adapter-kit";
import {
type EncryptedSecretStore,
loadMediaSettings,
MediaJobRejectedError,
MediaSettingsInvalidError,
mediaSettingsView,
saveMediaSettings,
} from "@rakazo/adapters";
import type { Actor, MediaJobStartInput, MediaSettingsUpdate } from "@rakazo/contracts";
import type { PrismaClient } from "@rakazo/db";
import { resolveThreadTarget } from "./thread-target.js";
export interface MediaRouteDeps {
prisma: PrismaClient;
secrets: EncryptedSecretStore;
media?: MediaRuntime;
}
/** Media backends run on the deployment owner's own server, so only they configure them. */
function requireDeploymentOwner(actor: Actor) {
if (!actor.isDeploymentOwner) throw new ORPCError("FORBIDDEN");
}
function requireMedia(deps: MediaRouteDeps): MediaRuntime {
if (!deps.media) throw new ORPCError("NOT_IMPLEMENTED");
return deps.media;
}
export async function getMediaSettings(deps: MediaRouteDeps, actor: Actor) {
requireDeploymentOwner(actor);
return mediaSettingsView(await loadMediaSettings(deps));
}
export async function updateMediaSettings(
deps: MediaRouteDeps,
actor: Actor,
input: MediaSettingsUpdate,
) {
requireDeploymentOwner(actor);
try {
return await saveMediaSettings(deps, input);
} catch (error) {
if (error instanceof MediaSettingsInvalidError) {
throw new ORPCError("BAD_REQUEST", { message: error.message });
}
throw error;
}
}
/**
* Starts an operation a user picked on a card (export, transcribe, edit) in the same conversation.
* The thread is authorized like every thread procedure; the runtime checks that the operation's
* source artifacts belong to the actor, and the job can only ever read the actor's artifacts.
*/
export async function startMediaJob(deps: MediaRouteDeps, actor: Actor, input: MediaJobStartInput) {
const media = requireMedia(deps);
const target = await resolveThreadTarget(deps.prisma, actor, input);
const source = await deps.prisma.message.findFirst({
where: { id: input.messageId, threadId: target.threadId },
select: { botId: true },
});
if (!source) throw new ORPCError("NOT_FOUND");
const bots =
target.kind === "bot"
? [target.botId]
: [
...(target.leadBotId && target.memberBotIds.includes(target.leadBotId)
? [target.leadBotId]
: []),
...target.memberBotIds,
];
const botId = source.botId && bots.includes(source.botId) ? source.botId : bots[0];
if (!botId) throw new ORPCError("NOT_FOUND");
try {
return await media.start({
operation: input.operation,
input: input.input,
spaceId: actor.spaceId,
userId: actor.userId,
botId,
groupId: target.kind === "group" ? target.groupId : null,
threadId: target.threadId,
runId: null,
});
} catch (error) {
if (error instanceof MediaJobRejectedError) {
throw new ORPCError("BAD_REQUEST", { message: error.message });
}
throw error;
}
}
async function ownedJobId(deps: MediaRouteDeps, actor: Actor, jobId: string): Promise<string> {
const job = await deps.prisma.mediaJob.findFirst({
where: { id: jobId, spaceId: actor.spaceId, userId: actor.userId },
select: { id: true },
});
if (!job) throw new ORPCError("NOT_FOUND");
return job.id;
}
export async function getMediaJob(deps: MediaRouteDeps, actor: Actor, jobId: string) {
const media = requireMedia(deps);
const snapshot = await media.get(await ownedJobId(deps, actor, jobId));
if (!snapshot) throw new ORPCError("NOT_FOUND");
return snapshot;
}
export async function cancelMediaJob(deps: MediaRouteDeps, actor: Actor, jobId: string) {
const media = requireMedia(deps);
const id = await ownedJobId(deps, actor, jobId);
await media.cancel(id);
const snapshot = await media.get(id);
if (!snapshot) throw new ORPCError("NOT_FOUND");
return snapshot;
}