Merge media cards that work from any of the user's spaces
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
This commit is contained in:
commit
05196fd20e
@ -11,6 +11,7 @@ import {
|
|||||||
import type { Actor, MediaJobStartInput, MediaSettingsUpdate } from "@rakazo/contracts";
|
import type { Actor, MediaJobStartInput, MediaSettingsUpdate } from "@rakazo/contracts";
|
||||||
import { MediaOperationSchema } from "@rakazo/contracts";
|
import { MediaOperationSchema } from "@rakazo/contracts";
|
||||||
import type { PrismaClient } from "@rakazo/db";
|
import type { PrismaClient } from "@rakazo/db";
|
||||||
|
import { IsolationError, requireMembership } from "@rakazo/db";
|
||||||
import { resolveThreadTarget } from "./thread-target.js";
|
import { resolveThreadTarget } from "./thread-target.js";
|
||||||
|
|
||||||
export interface MediaRouteDeps {
|
export interface MediaRouteDeps {
|
||||||
@ -51,18 +52,45 @@ export async function updateMediaSettings(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Starts an operation a user picked on a card (export, transcribe, edit) in the same conversation.
|
* The actor in the space a card lives in. A card is acted on where it is shown, which need not be
|
||||||
* The thread is authorized like every thread procedure; the runtime checks that the operation's
|
* the space the client has selected (the main conversation lives in the home space), so the
|
||||||
* source artifacts belong to the actor, and the job can only ever read the actor's artifacts.
|
* space comes from the card's own record: it must belong to the actor, and the actor must still
|
||||||
|
* be a member of that space. Anything else reads as not found.
|
||||||
|
*/
|
||||||
|
async function actorInSpace(deps: MediaRouteDeps, actor: Actor, spaceId: string): Promise<Actor> {
|
||||||
|
if (spaceId === actor.spaceId) return actor;
|
||||||
|
try {
|
||||||
|
return await requireMembership(deps.prisma, actor.userId, spaceId);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof IsolationError) throw new ORPCError("NOT_FOUND");
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Starts an operation a user picked on a card (export, transcribe, edit) in the card's
|
||||||
|
* conversation. The conversation is authorized like every thread procedure, in the card's space;
|
||||||
|
* the runtime checks that the operation's source artifacts belong to the actor there, and the
|
||||||
|
* job can only ever read the actor's artifacts of that space.
|
||||||
*/
|
*/
|
||||||
export async function startMediaJob(deps: MediaRouteDeps, actor: Actor, input: MediaJobStartInput) {
|
export async function startMediaJob(deps: MediaRouteDeps, actor: Actor, input: MediaJobStartInput) {
|
||||||
const media = requireMedia(deps);
|
const media = requireMedia(deps);
|
||||||
const target = await resolveThreadTarget(deps.prisma, actor, input);
|
|
||||||
const source = await deps.prisma.message.findFirst({
|
const source = await deps.prisma.message.findFirst({
|
||||||
where: { id: input.messageId, threadId: target.threadId },
|
where: {
|
||||||
select: { botId: true },
|
id: input.messageId,
|
||||||
|
thread: {
|
||||||
|
userId: actor.userId,
|
||||||
|
...(input.botId ? { botId: input.botId } : { groupId: input.groupId }),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
select: { botId: true, thread: { select: { spaceId: true } } },
|
||||||
});
|
});
|
||||||
if (!source) throw new ORPCError("NOT_FOUND");
|
if (!source) throw new ORPCError("NOT_FOUND");
|
||||||
|
const scoped = await actorInSpace(deps, actor, source.thread.spaceId);
|
||||||
|
const target = await resolveThreadTarget(deps.prisma, scoped, input).catch((error) => {
|
||||||
|
if (error instanceof IsolationError) throw new ORPCError("NOT_FOUND");
|
||||||
|
throw error;
|
||||||
|
});
|
||||||
const bots =
|
const bots =
|
||||||
target.kind === "bot"
|
target.kind === "bot"
|
||||||
? [target.botId]
|
? [target.botId]
|
||||||
@ -78,8 +106,8 @@ export async function startMediaJob(deps: MediaRouteDeps, actor: Actor, input: M
|
|||||||
return await media.start({
|
return await media.start({
|
||||||
operation: input.operation,
|
operation: input.operation,
|
||||||
input: input.input,
|
input: input.input,
|
||||||
spaceId: actor.spaceId,
|
spaceId: scoped.spaceId,
|
||||||
userId: actor.userId,
|
userId: scoped.userId,
|
||||||
botId,
|
botId,
|
||||||
groupId: target.kind === "group" ? target.groupId : null,
|
groupId: target.kind === "group" ? target.groupId : null,
|
||||||
threadId: target.threadId,
|
threadId: target.threadId,
|
||||||
@ -93,36 +121,43 @@ export async function startMediaJob(deps: MediaRouteDeps, actor: Actor, input: M
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Booleans only: which operations the actor's space can run, never how they are configured. */
|
/**
|
||||||
|
* Booleans only: which operations a space can run, never how they are configured. With a job,
|
||||||
|
* the space is the job's (a card's follow-ups); otherwise the actor's current space.
|
||||||
|
*/
|
||||||
export async function getMediaCapabilities(
|
export async function getMediaCapabilities(
|
||||||
deps: MediaRouteDeps,
|
deps: MediaRouteDeps,
|
||||||
actor: Actor,
|
actor: Actor,
|
||||||
|
input: { jobId?: string } = {},
|
||||||
): Promise<MediaCapabilities> {
|
): Promise<MediaCapabilities> {
|
||||||
if (deps.media) return deps.media.capabilities(actor.spaceId);
|
const spaceId = input.jobId ? (await ownedJob(deps, actor, input.jobId)).spaceId : actor.spaceId;
|
||||||
|
if (deps.media) return deps.media.capabilities(spaceId);
|
||||||
return Object.fromEntries(
|
return Object.fromEntries(
|
||||||
MediaOperationSchema.options.map((operation) => [operation, false]),
|
MediaOperationSchema.options.map((operation) => [operation, false]),
|
||||||
) as MediaCapabilities;
|
) as MediaCapabilities;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function ownedJobId(deps: MediaRouteDeps, actor: Actor, jobId: string): Promise<string> {
|
/** The actor's job, in whichever of the actor's spaces it runs. */
|
||||||
|
async function ownedJob(deps: MediaRouteDeps, actor: Actor, jobId: string) {
|
||||||
const job = await deps.prisma.mediaJob.findFirst({
|
const job = await deps.prisma.mediaJob.findFirst({
|
||||||
where: { id: jobId, spaceId: actor.spaceId, userId: actor.userId },
|
where: { id: jobId, userId: actor.userId },
|
||||||
select: { id: true },
|
select: { id: true, spaceId: true },
|
||||||
});
|
});
|
||||||
if (!job) throw new ORPCError("NOT_FOUND");
|
if (!job) throw new ORPCError("NOT_FOUND");
|
||||||
return job.id;
|
await actorInSpace(deps, actor, job.spaceId);
|
||||||
|
return job;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getMediaJob(deps: MediaRouteDeps, actor: Actor, jobId: string) {
|
export async function getMediaJob(deps: MediaRouteDeps, actor: Actor, jobId: string) {
|
||||||
const media = requireMedia(deps);
|
const media = requireMedia(deps);
|
||||||
const snapshot = await media.get(await ownedJobId(deps, actor, jobId));
|
const snapshot = await media.get((await ownedJob(deps, actor, jobId)).id);
|
||||||
if (!snapshot) throw new ORPCError("NOT_FOUND");
|
if (!snapshot) throw new ORPCError("NOT_FOUND");
|
||||||
return snapshot;
|
return snapshot;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function cancelMediaJob(deps: MediaRouteDeps, actor: Actor, jobId: string) {
|
export async function cancelMediaJob(deps: MediaRouteDeps, actor: Actor, jobId: string) {
|
||||||
const media = requireMedia(deps);
|
const media = requireMedia(deps);
|
||||||
const id = await ownedJobId(deps, actor, jobId);
|
const { id } = await ownedJob(deps, actor, jobId);
|
||||||
await media.cancel(id);
|
await media.cancel(id);
|
||||||
const snapshot = await media.get(id);
|
const snapshot = await media.get(id);
|
||||||
if (!snapshot) throw new ORPCError("NOT_FOUND");
|
if (!snapshot) throw new ORPCError("NOT_FOUND");
|
||||||
|
|||||||
@ -3501,8 +3501,8 @@ export function createRouter(deps: RouterDeps) {
|
|||||||
updateMediaSettings(deps, context.actor, input),
|
updateMediaSettings(deps, context.actor, input),
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
capabilities: authed.media.capabilities.handler(({ context }) =>
|
capabilities: authed.media.capabilities.handler(({ context, input }) =>
|
||||||
getMediaCapabilities(deps, context.actor),
|
getMediaCapabilities(deps, context.actor, input),
|
||||||
),
|
),
|
||||||
jobs: {
|
jobs: {
|
||||||
start: authed.media.jobs.start.handler(({ context, input }) =>
|
start: authed.media.jobs.start.handler(({ context, input }) =>
|
||||||
|
|||||||
@ -21,7 +21,7 @@ import { Captions, ChevronDown, Download, Film, ImageIcon, Scissors, Sparkles }
|
|||||||
import { useEffect, useId, useState } from "react";
|
import { useEffect, useId, useState } from "react";
|
||||||
import type { ArtifactTarget } from "../../lib/artifact-open";
|
import type { ArtifactTarget } from "../../lib/artifact-open";
|
||||||
import { artifactUrl } from "../../lib/artifact-url";
|
import { artifactUrl } from "../../lib/artifact-url";
|
||||||
import { rpc, selectedSpaceId } from "../../lib/rpc";
|
import { rpc } from "../../lib/rpc";
|
||||||
import { ArtifactImage } from "../../pages/shell/message-cards";
|
import { ArtifactImage } from "../../pages/shell/message-cards";
|
||||||
import { ArtifactFileCard } from "../ArtifactFileCard";
|
import { ArtifactFileCard } from "../ArtifactFileCard";
|
||||||
import { Shimmer } from "../ai/primitives";
|
import { Shimmer } from "../ai/primitives";
|
||||||
@ -305,22 +305,26 @@ export function MediaCardView({
|
|||||||
|
|
||||||
const CAPABILITIES_TTL_MS = 60_000;
|
const CAPABILITIES_TTL_MS = 60_000;
|
||||||
let capabilities:
|
let capabilities:
|
||||||
| { spaceId: string | null; at: number; value: Promise<MediaCardAvailability> }
|
| { conversation: string; at: number; value: Promise<MediaCardAvailability> }
|
||||||
| undefined;
|
| undefined;
|
||||||
|
|
||||||
/** What the space can run, shared by every card on screen and refreshed after a minute. */
|
/**
|
||||||
function loadAvailability(): Promise<MediaCardAvailability> {
|
* What the card's space can run, shared by every card of the conversation on screen and
|
||||||
const spaceId = selectedSpaceId();
|
* refreshed after a minute. Asked through a job, since the main conversation can live in another
|
||||||
|
* space than the one selected.
|
||||||
|
*/
|
||||||
|
function loadAvailability(target: ArtifactTarget, jobId: string): Promise<MediaCardAvailability> {
|
||||||
|
const conversation = "botId" in target ? `bot:${target.botId}` : `group:${target.groupId}`;
|
||||||
if (
|
if (
|
||||||
!capabilities ||
|
!capabilities ||
|
||||||
capabilities.spaceId !== spaceId ||
|
capabilities.conversation !== conversation ||
|
||||||
Date.now() - capabilities.at > CAPABILITIES_TTL_MS
|
Date.now() - capabilities.at > CAPABILITIES_TTL_MS
|
||||||
) {
|
) {
|
||||||
capabilities = {
|
capabilities = {
|
||||||
spaceId,
|
conversation,
|
||||||
at: Date.now(),
|
at: Date.now(),
|
||||||
value: rpc.media
|
value: rpc.media
|
||||||
.capabilities()
|
.capabilities({ jobId })
|
||||||
.then((value) => ({
|
.then((value) => ({
|
||||||
export: value.export_video === true,
|
export: value.export_video === true,
|
||||||
transcribe: value.transcribe === true,
|
transcribe: value.transcribe === true,
|
||||||
@ -331,18 +335,22 @@ function loadAvailability(): Promise<MediaCardAvailability> {
|
|||||||
return capabilities.value;
|
return capabilities.value;
|
||||||
}
|
}
|
||||||
|
|
||||||
function useAvailability(enabled: boolean): MediaCardAvailability {
|
function useAvailability(
|
||||||
|
enabled: boolean,
|
||||||
|
target: ArtifactTarget,
|
||||||
|
jobId: string,
|
||||||
|
): MediaCardAvailability {
|
||||||
const [available, setAvailable] = useState(NOTHING_AVAILABLE);
|
const [available, setAvailable] = useState(NOTHING_AVAILABLE);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!enabled) return;
|
if (!enabled) return;
|
||||||
let current = true;
|
let current = true;
|
||||||
void loadAvailability().then((value) => {
|
void loadAvailability(target, jobId).then((value) => {
|
||||||
if (current) setAvailable(value);
|
if (current) setAvailable(value);
|
||||||
});
|
});
|
||||||
return () => {
|
return () => {
|
||||||
current = false;
|
current = false;
|
||||||
};
|
};
|
||||||
}, [enabled]);
|
}, [enabled, target, jobId]);
|
||||||
return available;
|
return available;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -361,6 +369,8 @@ export function MediaCard({
|
|||||||
const available = useAvailability(
|
const available = useAvailability(
|
||||||
!isActiveMediaStatus(block.status) &&
|
!isActiveMediaStatus(block.status) &&
|
||||||
block.outputs.some((output) => output.kind === "video" || output.kind === "audio"),
|
block.outputs.some((output) => output.kind === "video" || output.kind === "audio"),
|
||||||
|
target,
|
||||||
|
block.jobId,
|
||||||
);
|
);
|
||||||
const onAction = async (action: MediaCardAction) => {
|
const onAction = async (action: MediaCardAction) => {
|
||||||
if (action.kind === "cancel") {
|
if (action.kind === "cancel") {
|
||||||
|
|||||||
@ -703,7 +703,10 @@ export const appContract = {
|
|||||||
get: oc.output(MediaSettingsViewSchema),
|
get: oc.output(MediaSettingsViewSchema),
|
||||||
update: oc.input(MediaSettingsUpdateSchema).output(MediaSettingsViewSchema),
|
update: oc.input(MediaSettingsUpdateSchema).output(MediaSettingsViewSchema),
|
||||||
},
|
},
|
||||||
capabilities: oc.output(MediaCapabilitiesSchema),
|
/** With a job id, what the job's space can run (a card's follow-ups). */
|
||||||
|
capabilities: oc
|
||||||
|
.input(z.object({ jobId: Id.optional() }).optional())
|
||||||
|
.output(MediaCapabilitiesSchema),
|
||||||
jobs: {
|
jobs: {
|
||||||
start: oc.input(MediaJobStartInputSchema).output(MediaJobStartOutputSchema),
|
start: oc.input(MediaJobStartInputSchema).output(MediaJobStartOutputSchema),
|
||||||
get: oc.input(z.object({ jobId: Id })).output(MediaJobSnapshotSchema),
|
get: oc.input(z.object({ jobId: Id })).output(MediaJobSnapshotSchema),
|
||||||
|
|||||||
@ -6,7 +6,7 @@ import type { MediaOperationRegistration } from "@rakazo/adapters";
|
|||||||
import { ComposioEmulator } from "@rakazo/adapters";
|
import { ComposioEmulator } from "@rakazo/adapters";
|
||||||
import type { MediaJobBlock, MediaSettingsView, MessageBlock } from "@rakazo/contracts";
|
import type { MediaJobBlock, MediaSettingsView, MessageBlock } from "@rakazo/contracts";
|
||||||
import { TranscribeInputSchema } from "@rakazo/contracts";
|
import { TranscribeInputSchema } from "@rakazo/contracts";
|
||||||
import { createThreadMessage } from "@rakazo/db";
|
import { createThreadMessage, ensureMainConversation, MAIN_BOT_SPAWN_KEY } from "@rakazo/db";
|
||||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||||
import type { createApp } from "../../../apps/api/src/app.ts";
|
import type { createApp } from "../../../apps/api/src/app.ts";
|
||||||
import { sessionCookieHeader } from "./index.js";
|
import { sessionCookieHeader } from "./index.js";
|
||||||
@ -344,6 +344,160 @@ describeWithDatabase("media RPCs (PostgreSQL)", () => {
|
|||||||
expect(["running", "cancelled"]).toContain(cancelled.status);
|
expect(["running", "cancelled"]).toContain(cancelled.status);
|
||||||
await waitForStatus(slow.jobId, "cancelled");
|
await waitForStatus(slow.jobId, "cancelled");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("acts on a card where it lives, whichever of the user's spaces is selected", async () => {
|
||||||
|
const cookie = await signup(app, `media-main-${stamp}@rakazo.test`, "media-main");
|
||||||
|
const me = await rpc<{ userId: string; spaceId: string }>(app, cookie, "me");
|
||||||
|
await rpc(app, cookie, "bots/create", {
|
||||||
|
name: "Chief",
|
||||||
|
title: "",
|
||||||
|
description: "",
|
||||||
|
instructions: "",
|
||||||
|
notifyOnFinish: false,
|
||||||
|
spawnKey: MAIN_BOT_SPAWN_KEY,
|
||||||
|
});
|
||||||
|
const main = await ensureMainConversation(handles.prisma, me.userId);
|
||||||
|
expect(main.spaceId).toBe(me.spaceId);
|
||||||
|
const card = await createThreadMessage(handles.prisma, {
|
||||||
|
threadId: main.threadId,
|
||||||
|
role: "bot",
|
||||||
|
botId: main.leadBotId,
|
||||||
|
blocks: [{ kind: "text", text: "Here is your clip." }],
|
||||||
|
});
|
||||||
|
const clip = await rpc<{ id: string }>(app, cookie, "artifacts/create", {
|
||||||
|
botId: main.leadBotId,
|
||||||
|
name: "clip.png",
|
||||||
|
mimeType: "image/png",
|
||||||
|
contentBase64: PNG_BASE64,
|
||||||
|
});
|
||||||
|
// The user has moved on to another space; the main conversation stays in the home space.
|
||||||
|
const elsewhere = await rpc<{ id: string }>(app, cookie, "spaces/create", {
|
||||||
|
name: "Elsewhere",
|
||||||
|
});
|
||||||
|
|
||||||
|
const started = await rpc<{ jobId: string; messageId: string }>(
|
||||||
|
app,
|
||||||
|
cookie,
|
||||||
|
"media/jobs/start",
|
||||||
|
{
|
||||||
|
groupId: main.groupId,
|
||||||
|
messageId: card.id,
|
||||||
|
operation: "export_video",
|
||||||
|
input: { artifact_id: clip.id },
|
||||||
|
},
|
||||||
|
elsewhere.id,
|
||||||
|
);
|
||||||
|
expect(await waitForStatus(started.jobId, "done")).toMatchObject({
|
||||||
|
spaceId: main.spaceId,
|
||||||
|
threadId: main.threadId,
|
||||||
|
groupId: main.groupId,
|
||||||
|
botId: main.leadBotId,
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
await rpc<{ status: string }>(
|
||||||
|
app,
|
||||||
|
cookie,
|
||||||
|
"media/jobs/get",
|
||||||
|
{ jobId: started.jobId },
|
||||||
|
elsewhere.id,
|
||||||
|
),
|
||||||
|
).toMatchObject({ status: "done" });
|
||||||
|
expect(
|
||||||
|
await rpc(app, cookie, "media/capabilities", { jobId: started.jobId }, elsewhere.id),
|
||||||
|
).toMatchObject({ export_video: true, transcribe: true, generate_image: false });
|
||||||
|
const slow = await rpc<{ jobId: string }>(
|
||||||
|
app,
|
||||||
|
cookie,
|
||||||
|
"media/jobs/start",
|
||||||
|
{
|
||||||
|
groupId: main.groupId,
|
||||||
|
messageId: card.id,
|
||||||
|
operation: "transcribe",
|
||||||
|
input: { artifact_id: clip.id },
|
||||||
|
},
|
||||||
|
elsewhere.id,
|
||||||
|
);
|
||||||
|
await waitForStatus(slow.jobId, "running");
|
||||||
|
await rpc(app, cookie, "media/jobs/cancel", { jobId: slow.jobId }, elsewhere.id);
|
||||||
|
await waitForStatus(slow.jobId, "cancelled");
|
||||||
|
|
||||||
|
// Another user gets nothing, from any space of theirs.
|
||||||
|
const mallory = await user("media-main-mallory");
|
||||||
|
for (const [proc, body] of [
|
||||||
|
["media/jobs/get", { jobId: started.jobId }],
|
||||||
|
["media/jobs/cancel", { jobId: started.jobId }],
|
||||||
|
["media/capabilities", { jobId: started.jobId }],
|
||||||
|
[
|
||||||
|
"media/jobs/start",
|
||||||
|
{
|
||||||
|
groupId: main.groupId,
|
||||||
|
messageId: card.id,
|
||||||
|
operation: "export_video",
|
||||||
|
input: { artifact_id: clip.id },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"media/jobs/start",
|
||||||
|
{
|
||||||
|
botId: mallory.bot.id,
|
||||||
|
messageId: card.id,
|
||||||
|
operation: "export_video",
|
||||||
|
input: { artifact_id: mallory.artifact.id },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
] as const) {
|
||||||
|
expect((await raw(app, mallory.cookie, proc, body)).status, proc).toBe(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A card in a space the user has left reads as not found too.
|
||||||
|
const bot = await rpc<{ id: string }>(
|
||||||
|
app,
|
||||||
|
cookie,
|
||||||
|
"bots/create",
|
||||||
|
{ name: "Away", title: "", description: "", instructions: "", notifyOnFinish: false },
|
||||||
|
elsewhere.id,
|
||||||
|
);
|
||||||
|
const awayThread = await handles.prisma.thread.findUniqueOrThrow({ where: { botId: bot.id } });
|
||||||
|
const awayCard = await createThreadMessage(handles.prisma, {
|
||||||
|
threadId: awayThread.id,
|
||||||
|
role: "bot",
|
||||||
|
botId: bot.id,
|
||||||
|
blocks: [{ kind: "text", text: "Here is another clip." }],
|
||||||
|
});
|
||||||
|
const awayClip = await rpc<{ id: string }>(
|
||||||
|
app,
|
||||||
|
cookie,
|
||||||
|
"artifacts/create",
|
||||||
|
{ botId: bot.id, name: "away.png", mimeType: "image/png", contentBase64: PNG_BASE64 },
|
||||||
|
elsewhere.id,
|
||||||
|
);
|
||||||
|
const away = await rpc<{ jobId: string }>(app, cookie, "media/jobs/start", {
|
||||||
|
botId: bot.id,
|
||||||
|
messageId: awayCard.id,
|
||||||
|
operation: "export_video",
|
||||||
|
input: { artifact_id: awayClip.id },
|
||||||
|
});
|
||||||
|
await waitForStatus(away.jobId, "done");
|
||||||
|
await handles.prisma.spaceMember.deleteMany({
|
||||||
|
where: { spaceId: elsewhere.id, userId: me.userId },
|
||||||
|
});
|
||||||
|
for (const [proc, body] of [
|
||||||
|
["media/jobs/get", { jobId: away.jobId }],
|
||||||
|
["media/jobs/cancel", { jobId: away.jobId }],
|
||||||
|
["media/capabilities", { jobId: away.jobId }],
|
||||||
|
[
|
||||||
|
"media/jobs/start",
|
||||||
|
{
|
||||||
|
botId: bot.id,
|
||||||
|
messageId: awayCard.id,
|
||||||
|
operation: "export_video",
|
||||||
|
input: { artifact_id: awayClip.id },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
] as const) {
|
||||||
|
expect((await raw(app, cookie, proc, body)).status, proc).toBe(404);
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
async function signup(app: App, email: string, name: string) {
|
async function signup(app: App, email: string, name: string) {
|
||||||
@ -356,16 +510,27 @@ async function signup(app: App, email: string, name: string) {
|
|||||||
return sessionCookieHeader(res);
|
return sessionCookieHeader(res);
|
||||||
}
|
}
|
||||||
|
|
||||||
function raw(app: App, cookie: string, proc: string, body: unknown = {}) {
|
function raw(app: App, cookie: string, proc: string, body: unknown = {}, spaceId?: string) {
|
||||||
return app.request(`/rpc/${proc}`, {
|
return app.request(`/rpc/${proc}`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "content-type": "application/json", cookie, origin: "http://127.0.0.1:5173" },
|
headers: {
|
||||||
|
"content-type": "application/json",
|
||||||
|
cookie,
|
||||||
|
origin: "http://127.0.0.1:5173",
|
||||||
|
...(spaceId ? { "x-rakazo-space-id": spaceId } : {}),
|
||||||
|
},
|
||||||
body: JSON.stringify({ json: body }),
|
body: JSON.stringify({ json: body }),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function rpc<T>(app: App, cookie: string, proc: string, body: unknown = {}): Promise<T> {
|
async function rpc<T>(
|
||||||
const res = await raw(app, cookie, proc, body);
|
app: App,
|
||||||
|
cookie: string,
|
||||||
|
proc: string,
|
||||||
|
body: unknown = {},
|
||||||
|
spaceId?: string,
|
||||||
|
): Promise<T> {
|
||||||
|
const res = await raw(app, cookie, proc, body, spaceId);
|
||||||
const text = await res.text();
|
const text = await res.text();
|
||||||
const parsed = JSON.parse(text) as { json?: T; error?: { message?: string } };
|
const parsed = JSON.parse(text) as { json?: T; error?: { message?: string } };
|
||||||
if (res.status >= 400 || parsed.error) {
|
if (res.status >= 400 || parsed.error) {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user