Merge the media integration branch into video editing
Brings in artifact streaming and the media job runtime that the video operations register with. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
This commit is contained in:
commit
d8c324a7af
@ -25,6 +25,8 @@ import {
|
||||
createCloudAgentConnection,
|
||||
createConnectorStack,
|
||||
createJobReconciler,
|
||||
createMediaOperations,
|
||||
createMediaRuntime,
|
||||
createMessagingContextLoader,
|
||||
createMessagingTeamChatSender,
|
||||
createProfileConsolidationScheduler,
|
||||
@ -47,6 +49,7 @@ import {
|
||||
isPipedreamEnabled,
|
||||
LocalAgentHomeStore,
|
||||
LocalArtifactStore,
|
||||
loadMediaSettings,
|
||||
McpConnector,
|
||||
McpOAuthBroker,
|
||||
messagingPlatformsFromEnv,
|
||||
@ -89,6 +92,7 @@ import { requestLogging } from "@rakazo/logging/hono";
|
||||
import { MarkdownMemoryStore } from "@rakazo/memory";
|
||||
import { Hono } from "hono";
|
||||
import { cors } from "hono/cors";
|
||||
import { mountArtifactHttpRoutes } from "./artifact-http.js";
|
||||
import type { AppEnv } from "./env.js";
|
||||
import { loadEnv } from "./env.js";
|
||||
import { mountLocalSettings } from "./local-settings.js";
|
||||
@ -375,6 +379,15 @@ export async function createApp(
|
||||
CLOUD_AGENT_SPACE_ID: env.cloudAgentSpaceId,
|
||||
});
|
||||
const shutdown = new AbortController();
|
||||
const media = createMediaRuntime({
|
||||
prisma,
|
||||
events,
|
||||
jobs,
|
||||
artifacts,
|
||||
registrations: createMediaOperations({
|
||||
loadSettings: () => loadMediaSettings({ prisma, secrets }),
|
||||
}),
|
||||
});
|
||||
const executor = createRunExecutor({
|
||||
prisma,
|
||||
runtime,
|
||||
@ -411,6 +424,7 @@ export async function createApp(
|
||||
messaging: messaging ? createMessagingContextLoader(prisma) : undefined,
|
||||
web: createWebProvider(),
|
||||
cloudAgent,
|
||||
media,
|
||||
shutdownSignal: shutdown.signal,
|
||||
});
|
||||
|
||||
@ -428,6 +442,7 @@ export async function createApp(
|
||||
deploymentModelKey: env.deploymentModelKey,
|
||||
messaging,
|
||||
cloudAgent,
|
||||
media,
|
||||
});
|
||||
if (inMemoryJobs) {
|
||||
await inMemoryJobs.start(jobHandlers);
|
||||
@ -446,6 +461,7 @@ export async function createApp(
|
||||
reconcileComputerUpdates: () => reconcileComputerUpdates({ prisma, jobs }),
|
||||
reconcileProfileConsolidation: createProfileConsolidationScheduler({ prisma, jobs }),
|
||||
reconcileDelegatedTasks: () => reconcileDelegatedTasks({ prisma, jobs, events }),
|
||||
reconcileMediaJobs: () => media.reconcile(),
|
||||
})
|
||||
: undefined;
|
||||
reconciler?.start();
|
||||
@ -513,7 +529,7 @@ export async function createApp(
|
||||
await next();
|
||||
c.res.headers.set(
|
||||
"Content-Security-Policy",
|
||||
"default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self'; connect-src 'self' ws:; frame-ancestors 'none'; base-uri 'self'; form-action 'self'",
|
||||
"default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; media-src 'self' blob:; font-src 'self'; connect-src 'self' ws:; frame-ancestors 'none'; base-uri 'self'; form-action 'self'",
|
||||
);
|
||||
c.res.headers.set("Strict-Transport-Security", "max-age=31536000; includeSubDomains");
|
||||
c.res.headers.set("X-Frame-Options", "DENY");
|
||||
@ -583,6 +599,12 @@ export async function createApp(
|
||||
if (actor) enrichLogContext({ "user.id": actor.userId, "space.id": actor.spaceId });
|
||||
return actor;
|
||||
});
|
||||
mountArtifactHttpRoutes(app, {
|
||||
prisma,
|
||||
artifacts,
|
||||
sessionUserId: async (request) =>
|
||||
(await auth.api.getSession({ headers: sessionHeaders(request) }))?.user?.id ?? null,
|
||||
});
|
||||
mountWebhookHttpRoutes(app, { prisma, secrets, events, jobs });
|
||||
// Shared with stop so a shutdown during retry delays does not restart polling.
|
||||
let messagingStopped = false;
|
||||
|
||||
142
apps/api/src/artifact-http.test.ts
Normal file
142
apps/api/src/artifact-http.test.ts
Normal file
@ -0,0 +1,142 @@
|
||||
import type { ArtifactStore } from "@rakazo/adapter-kit";
|
||||
import type { PrismaClient } from "@rakazo/db";
|
||||
import { Hono } from "hono";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { contentDisposition, mountArtifactHttpRoutes, parseByteRange } from "./artifact-http.js";
|
||||
|
||||
describe("parseByteRange", () => {
|
||||
it("ignores an absent header or another unit", () => {
|
||||
expect(parseByteRange(undefined, 100)).toBeNull();
|
||||
expect(parseByteRange("", 100)).toBeNull();
|
||||
expect(parseByteRange("items=0-1", 100)).toBeNull();
|
||||
});
|
||||
|
||||
it("parses closed, open and suffix ranges and clamps the end to the size", () => {
|
||||
expect(parseByteRange("bytes=0-9", 100)).toEqual({ start: 0, end: 9 });
|
||||
expect(parseByteRange("bytes=90-", 100)).toEqual({ start: 90, end: 99 });
|
||||
expect(parseByteRange("bytes=-10", 100)).toEqual({ start: 90, end: 99 });
|
||||
expect(parseByteRange("bytes=-500", 100)).toEqual({ start: 0, end: 99 });
|
||||
expect(parseByteRange("bytes=50-1000", 100)).toEqual({ start: 50, end: 99 });
|
||||
expect(parseByteRange("Bytes= 0-0 ", 100)).toEqual({ start: 0, end: 0 });
|
||||
});
|
||||
|
||||
it("rejects multiple, inverted, out-of-file and malformed ranges", () => {
|
||||
for (const header of [
|
||||
"bytes=0-1,5-6",
|
||||
"bytes=5-4",
|
||||
"bytes=100-",
|
||||
"bytes=100-200",
|
||||
"bytes=-0",
|
||||
"bytes=-",
|
||||
"bytes=a-b",
|
||||
"bytes=1.5-2",
|
||||
"bytes= -1-2",
|
||||
"bytes=0x10-20",
|
||||
`bytes=${"9".repeat(16)}-`,
|
||||
]) {
|
||||
expect(parseByteRange(header, 100), header).toBe("unsatisfiable");
|
||||
}
|
||||
expect(parseByteRange("bytes=0-0", 0)).toBe("unsatisfiable");
|
||||
});
|
||||
});
|
||||
|
||||
describe("contentDisposition", () => {
|
||||
it("keeps plain names and marks media inline", () => {
|
||||
expect(contentDisposition("clip.mp4", true)).toBe(
|
||||
`inline; filename="clip.mp4"; filename*=UTF-8''clip.mp4`,
|
||||
);
|
||||
});
|
||||
|
||||
it("neutralizes quotes, separators, control characters and header injection", () => {
|
||||
const header = contentDisposition('../evil"\r\nSet-Cookie: x=1;.txt', false);
|
||||
expect(header.startsWith("attachment; ")).toBe(true);
|
||||
expect(header).not.toMatch(/[\r\n]/);
|
||||
expect(header).toContain(`filename=".._evil___Set-Cookie: x=1_.txt"`);
|
||||
expect(header).not.toContain("/");
|
||||
});
|
||||
|
||||
it("encodes non-ASCII names with an ASCII fallback", () => {
|
||||
expect(contentDisposition("Grüße (1).png", true)).toBe(
|
||||
`inline; filename="Gr__e (1).png"; filename*=UTF-8''Gr%C3%BC%C3%9Fe%20%281%29.png`,
|
||||
);
|
||||
expect(contentDisposition(" ", false)).toBe(
|
||||
`attachment; filename="download"; filename*=UTF-8''download`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("artifact HTTP routes", () => {
|
||||
function app(sessionUserId: () => Promise<string | null>) {
|
||||
const prisma = {
|
||||
artifact: { findUnique: vi.fn(), create: vi.fn() },
|
||||
spaceMember: { findFirst: vi.fn() },
|
||||
};
|
||||
const artifacts = { putStream: vi.fn(), stat: vi.fn(), createReadStream: vi.fn() };
|
||||
const hono = new Hono();
|
||||
mountArtifactHttpRoutes(hono, {
|
||||
prisma: prisma as unknown as PrismaClient,
|
||||
artifacts: artifacts as unknown as ArtifactStore,
|
||||
sessionUserId,
|
||||
});
|
||||
return { hono, prisma, artifacts };
|
||||
}
|
||||
|
||||
it("rejects signed-out reads and uploads before touching storage", async () => {
|
||||
const { hono, prisma, artifacts } = app(async () => null);
|
||||
const read = await hono.request("/api/artifacts/art-1");
|
||||
const upload = await hono.request("/api/artifacts?botId=bot-1&name=a.mp4", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "video/mp4" },
|
||||
body: "data",
|
||||
});
|
||||
expect(read.status).toBe(401);
|
||||
expect(upload.status).toBe(401);
|
||||
expect(prisma.artifact.findUnique).not.toHaveBeenCalled();
|
||||
expect(artifacts.putStream).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("answers ids that are not artifact ids with 404 without a lookup", async () => {
|
||||
const { hono, prisma } = app(async () => "user-1");
|
||||
const response = await hono.request(`/api/artifacts/${encodeURIComponent("../etc/passwd")}`);
|
||||
expect(response.status).toBe(404);
|
||||
expect(prisma.artifact.findUnique).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects declared oversized and disallowed uploads without reading the body", async () => {
|
||||
const { hono, prisma, artifacts } = app(async () => "user-1");
|
||||
prisma.spaceMember.findFirst.mockResolvedValue({
|
||||
userId: "user-1",
|
||||
spaceId: "space-1",
|
||||
member: { user: { email: "user@example.test" } },
|
||||
});
|
||||
(prisma as unknown as { deploymentSettings: unknown }).deploymentSettings = {
|
||||
findUnique: vi.fn(async () => null),
|
||||
};
|
||||
const cancel = vi.fn(() => new Promise<void>(() => undefined));
|
||||
const oversized = await hono.request(
|
||||
new Request("http://localhost/api/artifacts?botId=bot-1&name=a.mp4", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "video/mp4", "content-length": String(501 * 1024 * 1024) },
|
||||
body: new ReadableStream({ cancel }),
|
||||
duplex: "half",
|
||||
} as RequestInit & { duplex: "half" }),
|
||||
);
|
||||
expect(oversized.status).toBe(413);
|
||||
expect(cancel).toHaveBeenCalled();
|
||||
|
||||
const html = await hono.request("/api/artifacts?botId=bot-1&name=a.html", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "text/html" },
|
||||
body: "<script></script>",
|
||||
});
|
||||
expect(html.status).toBe(415);
|
||||
|
||||
const noTarget = await hono.request("/api/artifacts?name=a.mp4", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "video/mp4" },
|
||||
body: "data",
|
||||
});
|
||||
expect(noTarget.status).toBe(400);
|
||||
expect(artifacts.putStream).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
285
apps/api/src/artifact-http.ts
Normal file
285
apps/api/src/artifact-http.ts
Normal file
@ -0,0 +1,285 @@
|
||||
import type { AdapterContext, ArtifactByteRange, ArtifactStore } from "@rakazo/adapter-kit";
|
||||
import { ArtifactTooLargeError } from "@rakazo/adapter-kit";
|
||||
import { artifactMaxBytes, storeArtifactStream } from "@rakazo/adapters";
|
||||
import type { Actor } from "@rakazo/contracts";
|
||||
import { attachmentKindOf, isAllowedAttachmentMimeType } from "@rakazo/contracts";
|
||||
import {
|
||||
createGroupRepos,
|
||||
createRepos,
|
||||
IsolationError,
|
||||
type PrismaClient,
|
||||
requireMembership,
|
||||
} from "@rakazo/db";
|
||||
import type { Context, Hono } from "hono";
|
||||
import { cancelBody } from "./http-body.js";
|
||||
|
||||
export const ARTIFACT_HTTP_PATH = "/api/artifacts";
|
||||
|
||||
export type ArtifactHttpDeps = {
|
||||
prisma: PrismaClient;
|
||||
artifacts: ArtifactStore;
|
||||
/** The signed-in user (cookie or bearer session), or null. */
|
||||
sessionUserId: (request: Request) => Promise<string | null>;
|
||||
};
|
||||
|
||||
/** Generated ids only; anything else is not an artifact. */
|
||||
const ARTIFACT_ID = /^[A-Za-z0-9_-]{1,64}$/;
|
||||
const MAX_RANGE_DIGITS = 15;
|
||||
|
||||
/**
|
||||
* Parses a single `bytes=` range against the stored size. Returns null when the header should
|
||||
* be ignored (absent or another unit) and "unsatisfiable" for multiple ranges or offsets
|
||||
* outside the file, which the caller answers with 416.
|
||||
*/
|
||||
export function parseByteRange(
|
||||
header: string | undefined,
|
||||
size: number,
|
||||
): ArtifactByteRange | "unsatisfiable" | null {
|
||||
if (!header) return null;
|
||||
const value = header.trim();
|
||||
if (!value.toLowerCase().startsWith("bytes=")) return null;
|
||||
const spec = value.slice("bytes=".length).trim();
|
||||
const match = new RegExp(`^(\\d{0,${MAX_RANGE_DIGITS}})-(\\d{0,${MAX_RANGE_DIGITS}})$`).exec(
|
||||
spec,
|
||||
);
|
||||
if (!match || size <= 0) return "unsatisfiable";
|
||||
const [, first = "", last = ""] = match;
|
||||
if (first === "" && last === "") return "unsatisfiable";
|
||||
if (first === "") {
|
||||
const suffix = Number(last);
|
||||
if (suffix <= 0) return "unsatisfiable";
|
||||
return { start: Math.max(0, size - suffix), end: size - 1 };
|
||||
}
|
||||
const start = Number(first);
|
||||
const end = last === "" ? size - 1 : Number(last);
|
||||
if (start >= size || end < start) return "unsatisfiable";
|
||||
return { start, end: Math.min(end, size - 1) };
|
||||
}
|
||||
|
||||
/** `inline` or `attachment` with an ASCII fallback and the exact UTF-8 name (RFC 6266). */
|
||||
export function contentDisposition(name: string, inline: boolean): string {
|
||||
const cleaned =
|
||||
Array.from(name, (char) => {
|
||||
const code = char.codePointAt(0) ?? 0;
|
||||
return code < 0x20 || code === 0x7f || char === "/" || char === "\\" ? "_" : char;
|
||||
})
|
||||
.join("")
|
||||
.trim() || "download";
|
||||
const ascii = cleaned.replace(/[^ -~]/g, "_").replace(/["%;]/g, "_");
|
||||
const encoded = encodeURIComponent(cleaned).replace(
|
||||
/['()*]/g,
|
||||
(char) => `%${char.charCodeAt(0).toString(16).toUpperCase()}`,
|
||||
);
|
||||
return `${inline ? "inline" : "attachment"}; filename="${ascii}"; filename*=UTF-8''${encoded}`;
|
||||
}
|
||||
|
||||
function adapterContext(actor: Actor, operationId: string, signal: AbortSignal): AdapterContext {
|
||||
return {
|
||||
operationId,
|
||||
traceId: operationId,
|
||||
spaceId: actor.spaceId,
|
||||
userId: actor.userId,
|
||||
signal,
|
||||
};
|
||||
}
|
||||
|
||||
function notFound(c: Context) {
|
||||
return c.json({ error: "Not found" }, 404);
|
||||
}
|
||||
|
||||
function artifactDto(row: {
|
||||
id: string;
|
||||
botId: string | null;
|
||||
groupId: string | null;
|
||||
runId: string | null;
|
||||
name: string;
|
||||
mimeType: string;
|
||||
size: number;
|
||||
createdAt: Date;
|
||||
}) {
|
||||
return {
|
||||
id: row.id,
|
||||
botId: row.botId,
|
||||
groupId: row.groupId,
|
||||
runId: row.runId,
|
||||
name: row.name,
|
||||
mimeType: row.mimeType,
|
||||
size: row.size,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Streams artifacts over HTTP so media can play with seeking and large files never travel as
|
||||
* base64 RPC payloads. Authorization matches `artifacts.get` and `artifacts.create`.
|
||||
*/
|
||||
export function mountArtifactHttpRoutes(app: Hono, deps: ArtifactHttpDeps) {
|
||||
const repos = createRepos(deps.prisma);
|
||||
const groupRepos = createGroupRepos(deps.prisma);
|
||||
|
||||
/** Same rule as artifacts.get: the owner's artifact of an active bot thread or group. */
|
||||
async function readableArtifact(
|
||||
userId: string,
|
||||
requestedSpaceId: string | undefined,
|
||||
id: string,
|
||||
) {
|
||||
if (!ARTIFACT_ID.test(id)) return null;
|
||||
const row = await deps.prisma.artifact.findUnique({ where: { id } });
|
||||
if (!row) return null;
|
||||
// <video> and <img> cannot send the space header, so the artifact's space is the default;
|
||||
// membership is still required and an explicit header must name the same space.
|
||||
const actor = await requireMembership(
|
||||
deps.prisma,
|
||||
userId,
|
||||
requestedSpaceId ?? row.spaceId,
|
||||
).catch(() => null);
|
||||
if (!actor || actor.spaceId !== row.spaceId || actor.userId !== row.userId) return null;
|
||||
try {
|
||||
if (row.groupId) await groupRepos.getGroupTarget(actor, row.groupId);
|
||||
else if (row.botId) await repos.getBot(actor, row.botId);
|
||||
else return null;
|
||||
} catch (error) {
|
||||
if (error instanceof IsolationError) return null;
|
||||
throw error;
|
||||
}
|
||||
return { row, actor };
|
||||
}
|
||||
|
||||
app.get(`${ARTIFACT_HTTP_PATH}/:id`, async (c) => {
|
||||
const userId = await deps.sessionUserId(c.req.raw);
|
||||
if (!userId) return c.json({ error: "Unauthorized" }, 401);
|
||||
const found = await readableArtifact(
|
||||
userId,
|
||||
c.req.header("x-rakazo-space-id"),
|
||||
c.req.param("id"),
|
||||
);
|
||||
if (!found) return notFound(c);
|
||||
const { row, actor } = found;
|
||||
const context = adapterContext(actor, `artifact-http-get:${row.id}`, c.req.raw.signal);
|
||||
let size: number;
|
||||
try {
|
||||
({ size } = await deps.artifacts.stat(row.storageKey, context));
|
||||
} catch {
|
||||
return notFound(c);
|
||||
}
|
||||
|
||||
const mimeType = isAllowedAttachmentMimeType(row.mimeType)
|
||||
? row.mimeType
|
||||
: "application/octet-stream";
|
||||
const inline = attachmentKindOf(mimeType) !== "file" && c.req.query("download") === undefined;
|
||||
const etag = /^[A-Za-z0-9]{16,128}$/.test(row.hash) ? `"${row.hash}"` : undefined;
|
||||
const headers = new Headers({
|
||||
"accept-ranges": "bytes",
|
||||
// Revalidate every time so a revoked thread stops serving from the browser cache.
|
||||
"cache-control": "private, no-cache",
|
||||
"content-disposition": contentDisposition(row.name, inline),
|
||||
"content-type": mimeType,
|
||||
"cross-origin-resource-policy": "same-origin",
|
||||
"x-content-type-options": "nosniff",
|
||||
});
|
||||
if (etag) headers.set("etag", etag);
|
||||
if (etag && c.req.header("if-none-match") === etag) {
|
||||
return new Response(null, { status: 304, headers });
|
||||
}
|
||||
|
||||
const ifRange = c.req.header("if-range");
|
||||
const range =
|
||||
ifRange === undefined || ifRange === etag
|
||||
? parseByteRange(c.req.header("range"), size)
|
||||
: null;
|
||||
if (range === "unsatisfiable") {
|
||||
headers.set("content-range", `bytes */${size}`);
|
||||
return new Response(null, { status: 416, headers });
|
||||
}
|
||||
const status = range ? 206 : 200;
|
||||
headers.set("content-length", String(range ? range.end - range.start + 1 : size));
|
||||
if (range) headers.set("content-range", `bytes ${range.start}-${range.end}/${size}`);
|
||||
// Hono answers HEAD from this handler and drops the body; never open a stream for it.
|
||||
if (c.req.method === "HEAD") return new Response(null, { status, headers });
|
||||
|
||||
let body: ReadableStream<Uint8Array>;
|
||||
try {
|
||||
body = await deps.artifacts.createReadStream(row.storageKey, context, range ?? undefined);
|
||||
} catch {
|
||||
return notFound(c);
|
||||
}
|
||||
return new Response(body, { status, headers });
|
||||
});
|
||||
|
||||
app.post(ARTIFACT_HTTP_PATH, async (c) => {
|
||||
const request = c.req.raw;
|
||||
const reject = (status: 400 | 401 | 404 | 413 | 415, error: string) => {
|
||||
cancelBody(request.body);
|
||||
return c.json({ error }, status);
|
||||
};
|
||||
const userId = await deps.sessionUserId(request);
|
||||
if (!userId) return reject(401, "Unauthorized");
|
||||
const actor = await requireMembership(
|
||||
deps.prisma,
|
||||
userId,
|
||||
c.req.header("x-rakazo-space-id"),
|
||||
).catch(() => null);
|
||||
if (!actor) return reject(401, "Unauthorized");
|
||||
|
||||
const botId = c.req.query("botId") || undefined;
|
||||
const groupId = c.req.query("groupId") || undefined;
|
||||
const name = c.req.query("name") ?? "";
|
||||
if (Boolean(botId) === Boolean(groupId)) {
|
||||
return reject(400, "Provide exactly one of botId or groupId");
|
||||
}
|
||||
if (name.length < 1 || name.length > 255) return reject(400, "Invalid file name");
|
||||
const mimeType = (c.req.header("content-type") ?? "").split(";", 1)[0]!.trim().toLowerCase();
|
||||
if (!isAllowedAttachmentMimeType(mimeType)) {
|
||||
return reject(415, "Unsupported attachment type");
|
||||
}
|
||||
const maxBytes = artifactMaxBytes(mimeType);
|
||||
const declared = c.req.header("content-length");
|
||||
if (declared !== undefined) {
|
||||
if (!/^\d{1,16}$/.test(declared)) return reject(400, "Invalid content length");
|
||||
if (Number(declared) > maxBytes) return reject(413, "File is too large.");
|
||||
if (Number(declared) === 0) return reject(400, "File is empty.");
|
||||
}
|
||||
if (!request.body) return reject(400, "File is empty.");
|
||||
|
||||
// Same targets as artifacts.create: an own active bot, or an active group of own bots.
|
||||
let ownerBotId: string | undefined;
|
||||
try {
|
||||
ownerBotId = botId
|
||||
? (await repos.getBot(actor, botId)).id
|
||||
: (await groupRepos.getGroupTarget(actor, groupId!)).members[0]?.bot.id;
|
||||
} catch (error) {
|
||||
if (!(error instanceof IsolationError)) throw error;
|
||||
}
|
||||
if (!ownerBotId) return reject(404, "Not found");
|
||||
|
||||
let row: Awaited<ReturnType<typeof storeArtifactStream>>;
|
||||
try {
|
||||
row = await storeArtifactStream(deps, {
|
||||
spaceId: actor.spaceId,
|
||||
userId: actor.userId,
|
||||
botId: ownerBotId,
|
||||
groupId: groupId ?? null,
|
||||
runId: null,
|
||||
name,
|
||||
mimeType,
|
||||
stream: request.body,
|
||||
maxBytes,
|
||||
signal: request.signal,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof ArtifactTooLargeError) return reject(413, "File is too large.");
|
||||
throw error;
|
||||
}
|
||||
if (row.size === 0) {
|
||||
await deps.prisma.artifact.delete({ where: { id: row.id } });
|
||||
await deps.artifacts
|
||||
.remove(
|
||||
row.storageKey,
|
||||
adapterContext(actor, `artifact-http-post:${row.id}`, request.signal),
|
||||
)
|
||||
.catch(() => undefined);
|
||||
return c.json({ error: "File is empty." }, 400);
|
||||
}
|
||||
return c.json(artifactDto(row), 201);
|
||||
});
|
||||
}
|
||||
@ -55,7 +55,11 @@ export function requestBodyLimit(maxSize: number): MiddlewareHandler {
|
||||
};
|
||||
}
|
||||
|
||||
/** Install limits only on framework-parsed JSON surfaces with known payload contracts. */
|
||||
/**
|
||||
* Install limits only on framework-parsed JSON surfaces with known payload contracts. Streaming
|
||||
* artifact uploads (`POST /api/artifacts`) are not buffered: the route enforces the per-kind cap
|
||||
* from Content-Length and again while writing.
|
||||
*/
|
||||
export function mountApiRequestBodyLimits(app: Hono): void {
|
||||
app.use("/api/auth/*", requestBodyLimit(MAX_AUTH_REQUEST_BYTES));
|
||||
app.use(`${LOCAL_SETTINGS_RPC}/*`, requestBodyLimit(MAX_RPC_REQUEST_BYTES));
|
||||
|
||||
38
apps/web/src/lib/artifact-url.ts
Normal file
38
apps/web/src/lib/artifact-url.ts
Normal file
@ -0,0 +1,38 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { ArtifactSchema } from "@rakazo/contracts";
|
||||
import type { ArtifactTarget } from "./artifact-open";
|
||||
import { withSpaceHeaders } from "./rpc";
|
||||
|
||||
const ARTIFACT_PATH = "/api/artifacts";
|
||||
|
||||
/**
|
||||
* Same-origin URL that streams an artifact with Range support, for `<video>`, `<audio>`, `<img>`
|
||||
* and downloads. The session cookie authorizes it; `download` forces a save instead of inline.
|
||||
*/
|
||||
export function artifactUrl(artifactId: string, options: { download?: boolean } = {}): string {
|
||||
const path = `${ARTIFACT_PATH}/${encodeURIComponent(artifactId)}`;
|
||||
return options.download ? `${path}?download=1` : path;
|
||||
}
|
||||
|
||||
/** Uploads a file as a raw streamed body, for media and files above the inline RPC cap. */
|
||||
export async function uploadArtifactFile(
|
||||
target: ArtifactTarget,
|
||||
file: Blob,
|
||||
name: string,
|
||||
mimeType: string,
|
||||
): Promise<ReturnType<typeof ArtifactSchema.parse>> {
|
||||
const query = new URLSearchParams(
|
||||
"botId" in target ? { botId: target.botId, name } : { groupId: target.groupId, name },
|
||||
);
|
||||
const response = await fetch(`${ARTIFACT_PATH}?${query}`, {
|
||||
method: "POST",
|
||||
headers: withSpaceHeaders({ "content-type": mimeType }),
|
||||
credentials: "include",
|
||||
body: file,
|
||||
});
|
||||
const body: unknown = await response.json().catch(() => null);
|
||||
const artifact = ArtifactSchema.safeParse(body);
|
||||
if (response.ok && artifact.success) return artifact.data;
|
||||
const error = (body as { error?: unknown } | null)?.error;
|
||||
throw new Error(typeof error === "string" ? error : t`Could not upload ${name}. Try again.`);
|
||||
}
|
||||
@ -33,6 +33,7 @@ const runTriggers = new Set<Run["trigger"]>([
|
||||
"messaging",
|
||||
"cloud_agent",
|
||||
"delegation",
|
||||
"media",
|
||||
]);
|
||||
|
||||
function runFromStartedEvent(event: ProductEvent, previous: Run | undefined): Run {
|
||||
|
||||
@ -10,6 +10,8 @@ import {
|
||||
createCloudAgentConnection,
|
||||
createConnectorStack,
|
||||
createJobReconciler,
|
||||
createMediaOperations,
|
||||
createMediaRuntime,
|
||||
createMessagingContextLoader,
|
||||
createPostgresReconciliationLeadership,
|
||||
createProfileConsolidationScheduler,
|
||||
@ -29,6 +31,7 @@ import {
|
||||
isPipedreamEnabled,
|
||||
LocalAgentHomeStore,
|
||||
LocalArtifactStore,
|
||||
loadMediaSettings,
|
||||
McpConnector,
|
||||
McpOAuthBroker,
|
||||
messagingEnvFromProcess,
|
||||
@ -164,6 +167,15 @@ async function main() {
|
||||
});
|
||||
// One provider instance so emulator launches and polls share the same Map.
|
||||
const cloudAgent = createCloudAgentConnection();
|
||||
const media = createMediaRuntime({
|
||||
prisma,
|
||||
events,
|
||||
jobs,
|
||||
artifacts,
|
||||
registrations: createMediaOperations({
|
||||
loadSettings: () => loadMediaSettings({ prisma, secrets }),
|
||||
}),
|
||||
});
|
||||
const executor = createRunExecutor({
|
||||
prisma,
|
||||
runtime,
|
||||
@ -199,6 +211,7 @@ async function main() {
|
||||
messaging: messaging ? createMessagingContextLoader(prisma) : undefined,
|
||||
web: createWebProvider(),
|
||||
cloudAgent,
|
||||
media,
|
||||
});
|
||||
|
||||
const jobHandlers = createBackgroundJobHandlers({
|
||||
@ -215,6 +228,7 @@ async function main() {
|
||||
deploymentModelKey,
|
||||
messaging,
|
||||
cloudAgent,
|
||||
media,
|
||||
});
|
||||
// graphile-worker run() connects through the shared pool. createPool already
|
||||
// retries connect() on 53300 a finite number of times. Keep retrying start
|
||||
@ -244,6 +258,7 @@ async function main() {
|
||||
reconcileComputerUpdates: () => reconcileComputerUpdates({ prisma, jobs }),
|
||||
reconcileProfileConsolidation: createProfileConsolidationScheduler({ prisma, jobs }),
|
||||
reconcileDelegatedTasks: () => reconcileDelegatedTasks({ prisma, jobs, events }),
|
||||
reconcileMediaJobs: () => media.reconcile(),
|
||||
});
|
||||
reconciler.start();
|
||||
|
||||
|
||||
@ -3,6 +3,9 @@ import {
|
||||
dispatchBackgroundJob,
|
||||
historyCompactJob,
|
||||
historyCompactJobKey,
|
||||
MEDIA_JOB_QUEUE,
|
||||
mediaRunJob,
|
||||
mediaRunJobKey,
|
||||
messagingDeliverJob,
|
||||
parseBackgroundJob,
|
||||
profileConsolidateJob,
|
||||
@ -22,6 +25,7 @@ function handlers(): BackgroundJobHandlers {
|
||||
"messaging.deliver": vi.fn(async () => undefined),
|
||||
"cloud_agent.poll": vi.fn(async () => undefined),
|
||||
"profile.consolidate": vi.fn(async () => undefined),
|
||||
"media.run": vi.fn(async () => undefined),
|
||||
};
|
||||
}
|
||||
|
||||
@ -112,3 +116,21 @@ describe("profileConsolidateJob", () => {
|
||||
expect(profileConsolidateJobKey("user-1")).not.toBe(profileConsolidateJobKey("user-2"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("mediaRunJob", () => {
|
||||
it("validates, dispatches, and runs every media job on the shared media queue", async () => {
|
||||
const target = handlers();
|
||||
await dispatchBackgroundJob(target, "media.run", { jobId: "job-1" });
|
||||
expect(target["media.run"]).toHaveBeenCalledWith({ jobId: "job-1" });
|
||||
expect(() => parseBackgroundJob("media.run", { jobId: "" })).toThrow();
|
||||
expect(mediaRunJob("job-1")).toEqual({
|
||||
name: "media.run",
|
||||
payload: { jobId: "job-1" },
|
||||
replaceKey: mediaRunJobKey("job-1"),
|
||||
queue: MEDIA_JOB_QUEUE,
|
||||
});
|
||||
const at = new Date("2026-10-03T12:00:00.000Z");
|
||||
expect(mediaRunJob("job-1", at).availableAt).toBe(at);
|
||||
expect(mediaRunJobKey("job-1")).not.toBe(mediaRunJobKey("job-2"));
|
||||
});
|
||||
});
|
||||
|
||||
@ -23,6 +23,7 @@ const payloadSchemas = {
|
||||
"messaging.deliver": z.object({ runId: z.string().min(1).optional() }),
|
||||
"cloud_agent.poll": z.object({ agentId: z.string().min(1) }),
|
||||
"profile.consolidate": z.object({ userId: z.string().min(1) }),
|
||||
"media.run": z.object({ jobId: z.string().min(1) }),
|
||||
} satisfies { [Name in BackgroundJobName]: z.ZodType<BackgroundJobPayloads[Name]> };
|
||||
|
||||
export function parseBackgroundJob(name: string, payload: unknown): BackgroundJob {
|
||||
@ -160,3 +161,20 @@ export function profileConsolidateJob(userId: string): BackgroundJob {
|
||||
replaceKey: profileConsolidateJobKey(userId),
|
||||
};
|
||||
}
|
||||
|
||||
/** Media jobs share one queue so a single GPU never runs two generations at once. */
|
||||
export const MEDIA_JOB_QUEUE = "media";
|
||||
|
||||
export function mediaRunJobKey(jobId: string): string {
|
||||
return `media.run:${jobId}`;
|
||||
}
|
||||
|
||||
export function mediaRunJob(jobId: string, availableAt?: Date): BackgroundJob {
|
||||
return {
|
||||
name: "media.run",
|
||||
payload: { jobId },
|
||||
replaceKey: mediaRunJobKey(jobId),
|
||||
queue: MEDIA_JOB_QUEUE,
|
||||
...(availableAt ? { availableAt } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@ -4,7 +4,9 @@ import type {
|
||||
AgentRunRequest,
|
||||
AgentRuntimeCapabilities,
|
||||
AgentRuntimeEvent,
|
||||
ArtifactByteRange,
|
||||
ArtifactPut,
|
||||
ArtifactStreamPut,
|
||||
BackgroundJob,
|
||||
BackgroundJobHandlers,
|
||||
BrowserActRequest,
|
||||
@ -63,6 +65,7 @@ import type {
|
||||
SemanticMemorySaveRequest,
|
||||
SnapshotRef,
|
||||
SpeechClip,
|
||||
StoredArtifactStream,
|
||||
TransactionalEmail,
|
||||
VoiceCapabilities,
|
||||
VoiceInfo,
|
||||
@ -283,11 +286,25 @@ export interface AgentHomeStore {
|
||||
): Promise<Array<{ path: string; kind: "file" | "dir"; size: number }>>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Artifact bytes, keyed per space (`context.spaceId`). `stream: true` means large media can be
|
||||
* stored and served without holding it in memory.
|
||||
*/
|
||||
export interface ArtifactStore {
|
||||
describe(): AdapterDescriptor<{ stream: boolean }>;
|
||||
put(artifact: ArtifactPut, context: AdapterContext): Promise<{ id: string; hash: string }>;
|
||||
get(id: string, context: AdapterContext): Promise<Uint8Array>;
|
||||
remove(id: string, context: AdapterContext): Promise<void>;
|
||||
stat(key: string, context: AdapterContext): Promise<{ size: number }>;
|
||||
/** Rejects with a RangeError when the range does not fit the stored size. */
|
||||
createReadStream(
|
||||
key: string,
|
||||
context: AdapterContext,
|
||||
range?: ArtifactByteRange,
|
||||
): Promise<ReadableStream<Uint8Array>>;
|
||||
putStream(artifact: ArtifactStreamPut, context: AdapterContext): Promise<StoredArtifactStream>;
|
||||
/** A local file path for tools such as ffmpeg that read in place; only stores on local disk. */
|
||||
localPath?(key: string, context: AdapterContext): Promise<string>;
|
||||
}
|
||||
|
||||
export interface SecretStore {
|
||||
|
||||
@ -195,13 +195,19 @@ export interface StartMediaJobInput {
|
||||
operation: MediaOperation;
|
||||
/** Validated by the operation's input schema before the job is created. */
|
||||
input: unknown;
|
||||
title: string;
|
||||
/** Card label; the operation derives one from the input when omitted. */
|
||||
title?: string;
|
||||
spaceId: string;
|
||||
userId: string;
|
||||
/** Author of the media card; a job started from a card keeps that card's bot. */
|
||||
botId: string | null;
|
||||
groupId: string | null;
|
||||
threadId: string;
|
||||
runId: string | null;
|
||||
/** Wake the starting bot with the job's summary when the job ends. */
|
||||
continueAfter?: boolean;
|
||||
/** A replay with the same key returns the job created first (tool call idempotency). */
|
||||
operationKey?: string;
|
||||
}
|
||||
|
||||
export interface MediaJobSnapshot {
|
||||
|
||||
@ -524,6 +524,8 @@ export interface BackgroundJobPayloads {
|
||||
"cloud_agent.poll": { agentId: string };
|
||||
/** Learn durable user facts from conversations since the profile's last consolidation. */
|
||||
"profile.consolidate": { userId: string };
|
||||
/** Run one media job; scope, input and lease live on the job row. */
|
||||
"media.run": { jobId: string };
|
||||
}
|
||||
|
||||
export type BackgroundJobName = keyof BackgroundJobPayloads;
|
||||
@ -534,6 +536,8 @@ export type BackgroundJob = {
|
||||
payload: BackgroundJobPayloads[Name];
|
||||
availableAt?: Date;
|
||||
replaceKey?: string;
|
||||
/** Jobs that share a queue run one at a time, in order, across every worker process. */
|
||||
queue?: string;
|
||||
};
|
||||
}[BackgroundJobName];
|
||||
|
||||
@ -552,6 +556,34 @@ export interface ArtifactPut {
|
||||
bytes: Uint8Array;
|
||||
}
|
||||
|
||||
/** Inclusive byte offsets, as in an HTTP `Range: bytes=start-end` header. */
|
||||
export interface ArtifactByteRange {
|
||||
start: number;
|
||||
end: number;
|
||||
}
|
||||
|
||||
export interface ArtifactStreamPut {
|
||||
name: string;
|
||||
mimeType: string;
|
||||
stream: ReadableStream<Uint8Array> | AsyncIterable<Uint8Array>;
|
||||
/** The store stops reading and rejects with ArtifactTooLargeError beyond this many bytes. */
|
||||
maxBytes: number;
|
||||
}
|
||||
|
||||
export interface StoredArtifactStream {
|
||||
key: string;
|
||||
size: number;
|
||||
/** Hex SHA-256 of the stored bytes. */
|
||||
sha256: string;
|
||||
}
|
||||
|
||||
export class ArtifactTooLargeError extends Error {
|
||||
constructor(readonly maxBytes: number) {
|
||||
super(`Artifact exceeds ${maxBytes} bytes`);
|
||||
this.name = "ArtifactTooLargeError";
|
||||
}
|
||||
}
|
||||
|
||||
export interface NotificationMessage {
|
||||
kind: "completion" | "failure" | "help" | "takeover";
|
||||
title: string;
|
||||
|
||||
125
packages/adapters/src/artifact-files.test.ts
Normal file
125
packages/adapters/src/artifact-files.test.ts
Normal file
@ -0,0 +1,125 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { mkdtemp, readdir, rm, symlink, truncate, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { ArtifactTooLargeError } from "@rakazo/adapter-kit";
|
||||
import { ARTIFACT_MAX_BYTES_BY_KIND } from "@rakazo/contracts";
|
||||
import { AttachmentValidationError } from "@rakazo/core";
|
||||
import type { PrismaClient } from "@rakazo/db";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { storeArtifactFromFile } from "./artifact-files.js";
|
||||
import { LocalArtifactStore } from "./artifacts.js";
|
||||
|
||||
const dirs: string[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(dirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })));
|
||||
});
|
||||
|
||||
async function setup(
|
||||
create = vi.fn(async ({ data }: { data: object }) => ({ id: "art-1", ...data })),
|
||||
) {
|
||||
const root = await mkdtemp(path.join(tmpdir(), "rakazo-artifact-files-"));
|
||||
dirs.push(root);
|
||||
const store = new LocalArtifactStore(path.join(root, "data"));
|
||||
const prisma = { artifact: { create } } as unknown as Pick<PrismaClient, "artifact">;
|
||||
return { root, store, prisma, create };
|
||||
}
|
||||
|
||||
const owner = {
|
||||
spaceId: "space-1",
|
||||
userId: "user-1",
|
||||
botId: "bot-1",
|
||||
groupId: null,
|
||||
runId: "run-1",
|
||||
};
|
||||
|
||||
describe("storeArtifactFromFile", () => {
|
||||
it("streams the file into the store and records size, hash and media metadata", async () => {
|
||||
const { root, store, prisma, create } = await setup();
|
||||
const file = path.join(root, "out.mp4");
|
||||
await writeFile(file, "not really a video");
|
||||
|
||||
const row = await storeArtifactFromFile(
|
||||
{ prisma, artifacts: store },
|
||||
{
|
||||
...owner,
|
||||
path: file,
|
||||
name: "out.mp4",
|
||||
mimeType: "video/mp4",
|
||||
width: 1080,
|
||||
height: 1920,
|
||||
durationMs: 1500,
|
||||
parentArtifactId: "art-0",
|
||||
},
|
||||
);
|
||||
|
||||
expect(create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
...owner,
|
||||
name: "out.mp4",
|
||||
mimeType: "video/mp4",
|
||||
size: 18,
|
||||
hash: createHash("sha256").update("not really a video").digest("hex"),
|
||||
width: 1080,
|
||||
height: 1920,
|
||||
durationMs: 1500,
|
||||
parentArtifactId: "art-0",
|
||||
}),
|
||||
});
|
||||
const key = (row as unknown as { storageKey: string }).storageKey;
|
||||
const context = { spaceId: "space-1", signal: new AbortController().signal } as never;
|
||||
expect(Buffer.from(await store.get(key, context)).toString()).toBe("not really a video");
|
||||
});
|
||||
|
||||
it("rejects disallowed types, oversized files and symlinks before storing anything", async () => {
|
||||
const { root, store, prisma, create } = await setup();
|
||||
const html = path.join(root, "page.html");
|
||||
await writeFile(html, "<script></script>");
|
||||
await expect(
|
||||
storeArtifactFromFile(
|
||||
{ prisma, artifacts: store },
|
||||
{ ...owner, path: html, name: "page.html", mimeType: "text/html" },
|
||||
),
|
||||
).rejects.toBeInstanceOf(AttachmentValidationError);
|
||||
|
||||
const huge = path.join(root, "huge.png");
|
||||
await writeFile(huge, "");
|
||||
await truncate(huge, ARTIFACT_MAX_BYTES_BY_KIND.image + 1);
|
||||
await expect(
|
||||
storeArtifactFromFile(
|
||||
{ prisma, artifacts: store },
|
||||
{ ...owner, path: huge, name: "huge.png", mimeType: "image/png" },
|
||||
),
|
||||
).rejects.toBeInstanceOf(ArtifactTooLargeError);
|
||||
|
||||
const link = path.join(root, "link.txt");
|
||||
await writeFile(path.join(root, "secret.txt"), "secret");
|
||||
await symlink(path.join(root, "secret.txt"), link);
|
||||
await expect(
|
||||
storeArtifactFromFile(
|
||||
{ prisma, artifacts: store },
|
||||
{ ...owner, path: link, name: "link.txt", mimeType: "text/plain" },
|
||||
),
|
||||
).rejects.toThrow();
|
||||
|
||||
expect(create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("removes the stored blob when the artifact row cannot be written", async () => {
|
||||
const { root, store, prisma } = await setup(
|
||||
vi.fn(async () => {
|
||||
throw new Error("db down");
|
||||
}),
|
||||
);
|
||||
const file = path.join(root, "notes.txt");
|
||||
await writeFile(file, "hello");
|
||||
await expect(
|
||||
storeArtifactFromFile(
|
||||
{ prisma, artifacts: store },
|
||||
{ ...owner, path: file, name: "notes.txt", mimeType: "text/plain" },
|
||||
),
|
||||
).rejects.toThrow("db down");
|
||||
expect(await readdir(path.join(root, "data", "artifacts", "space-1"))).toEqual([]);
|
||||
});
|
||||
});
|
||||
116
packages/adapters/src/artifact-files.ts
Normal file
116
packages/adapters/src/artifact-files.ts
Normal file
@ -0,0 +1,116 @@
|
||||
import { constants } from "node:fs";
|
||||
import { open } from "node:fs/promises";
|
||||
import type { AdapterContext, ArtifactStore, ArtifactStreamPut } from "@rakazo/adapter-kit";
|
||||
import { ArtifactTooLargeError } from "@rakazo/adapter-kit";
|
||||
import { ARTIFACT_MAX_BYTES_BY_KIND, attachmentKindOf } from "@rakazo/contracts";
|
||||
import { validateAttachmentMimeType } from "@rakazo/core";
|
||||
import type { PrismaClient } from "@rakazo/db";
|
||||
|
||||
const O_NOFOLLOW = constants.O_NOFOLLOW ?? 0;
|
||||
|
||||
export type StoreArtifactOwner = {
|
||||
spaceId: string;
|
||||
userId: string;
|
||||
botId: string | null;
|
||||
groupId: string | null;
|
||||
runId: string | null;
|
||||
};
|
||||
|
||||
export type StoreArtifactMetadata = {
|
||||
name: string;
|
||||
mimeType: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
durationMs?: number;
|
||||
parentArtifactId?: string;
|
||||
};
|
||||
|
||||
type StoreArtifactDeps = {
|
||||
prisma: Pick<PrismaClient, "artifact">;
|
||||
artifacts: ArtifactStore;
|
||||
};
|
||||
|
||||
/** Largest artifact of this MIME type that may be streamed or generated. */
|
||||
export function artifactMaxBytes(mimeType: string): number {
|
||||
return ARTIFACT_MAX_BYTES_BY_KIND[attachmentKindOf(mimeType)];
|
||||
}
|
||||
|
||||
/**
|
||||
* Streams bytes into the artifact store and records the artifact row; the stored blob is removed
|
||||
* again when the row cannot be written. Rejects disallowed MIME types and anything above the
|
||||
* per-kind cap (or the smaller `maxBytes`) without buffering the whole file.
|
||||
*/
|
||||
export async function storeArtifactStream(
|
||||
deps: StoreArtifactDeps,
|
||||
input: StoreArtifactOwner &
|
||||
StoreArtifactMetadata & {
|
||||
stream: ArtifactStreamPut["stream"];
|
||||
maxBytes?: number;
|
||||
signal?: AbortSignal;
|
||||
},
|
||||
) {
|
||||
validateAttachmentMimeType(input.mimeType);
|
||||
const maxBytes = Math.min(
|
||||
input.maxBytes ?? Number.POSITIVE_INFINITY,
|
||||
artifactMaxBytes(input.mimeType),
|
||||
);
|
||||
const operationId = `artifact-store:${input.runId ?? input.botId ?? input.userId}`;
|
||||
const context: AdapterContext = {
|
||||
operationId,
|
||||
traceId: operationId,
|
||||
spaceId: input.spaceId,
|
||||
userId: input.userId,
|
||||
...(input.botId ? { botId: input.botId } : {}),
|
||||
...(input.runId ? { runId: input.runId } : {}),
|
||||
signal: input.signal ?? new AbortController().signal,
|
||||
};
|
||||
const stored = await deps.artifacts.putStream(
|
||||
{ name: input.name, mimeType: input.mimeType, stream: input.stream, maxBytes },
|
||||
context,
|
||||
);
|
||||
try {
|
||||
return await deps.prisma.artifact.create({
|
||||
data: {
|
||||
spaceId: input.spaceId,
|
||||
userId: input.userId,
|
||||
botId: input.botId,
|
||||
groupId: input.groupId,
|
||||
runId: input.runId,
|
||||
name: input.name,
|
||||
mimeType: input.mimeType,
|
||||
size: stored.size,
|
||||
hash: stored.sha256,
|
||||
storageKey: stored.key,
|
||||
width: input.width,
|
||||
height: input.height,
|
||||
durationMs: input.durationMs,
|
||||
parentArtifactId: input.parentArtifactId,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
await deps.artifacts.remove(stored.key, context).catch(() => undefined);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/** Stores a file a job produced on local disk (e.g. an ffmpeg output) as an artifact. */
|
||||
export async function storeArtifactFromFile(
|
||||
deps: StoreArtifactDeps,
|
||||
input: StoreArtifactOwner & StoreArtifactMetadata & { path: string; signal?: AbortSignal },
|
||||
) {
|
||||
validateAttachmentMimeType(input.mimeType);
|
||||
const maxBytes = artifactMaxBytes(input.mimeType);
|
||||
const handle = await open(input.path, constants.O_RDONLY | O_NOFOLLOW);
|
||||
try {
|
||||
const info = await handle.stat();
|
||||
if (!info.isFile()) throw new Error(`Not a file: ${input.name}`);
|
||||
if (info.size > maxBytes) throw new ArtifactTooLargeError(maxBytes);
|
||||
return await storeArtifactStream(deps, {
|
||||
...input,
|
||||
stream: handle.createReadStream({ autoClose: false }),
|
||||
maxBytes,
|
||||
});
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
}
|
||||
@ -1,11 +1,27 @@
|
||||
import { mkdtemp, rm, stat, symlink, writeFile } from "node:fs/promises";
|
||||
import { createHash } from "node:crypto";
|
||||
import { mkdtemp, readdir, rm, stat, symlink, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { type AdapterContext, ArtifactTooLargeError } from "@rakazo/adapter-kit";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { LocalArtifactStore } from "./artifacts.js";
|
||||
|
||||
const dirs: string[] = [];
|
||||
const context = { spaceId: "space-1" } as never;
|
||||
const context = { spaceId: "space-1", signal: new AbortController().signal } as AdapterContext;
|
||||
|
||||
async function tempStore() {
|
||||
const root = await mkdtemp(path.join(tmpdir(), "rakazo-artifacts-"));
|
||||
dirs.push(root);
|
||||
return { root, store: new LocalArtifactStore(root) };
|
||||
}
|
||||
|
||||
async function readAll(stream: ReadableStream<Uint8Array>) {
|
||||
return Buffer.from(await new Response(stream).arrayBuffer());
|
||||
}
|
||||
|
||||
async function* chunks(...parts: string[]) {
|
||||
for (const part of parts) yield new TextEncoder().encode(part);
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(dirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })));
|
||||
@ -42,4 +58,140 @@ describe("LocalArtifactStore", () => {
|
||||
|
||||
await expect(store.get(stored.id, context)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("streams uploads to disk with a real hash, size and owner-only mode", async () => {
|
||||
const { root, store } = await tempStore();
|
||||
const stored = await store.putStream(
|
||||
{
|
||||
name: "clip.mp4",
|
||||
mimeType: "video/mp4",
|
||||
stream: new Response("hello media world").body!,
|
||||
maxBytes: 1024,
|
||||
},
|
||||
context,
|
||||
);
|
||||
expect(stored.size).toBe(17);
|
||||
expect(stored.sha256).toBe(createHash("sha256").update("hello media world").digest("hex"));
|
||||
const info = await stat(path.join(root, "artifacts", "space-1", stored.key));
|
||||
expect(info.mode & 0o777).toBe(0o600);
|
||||
expect((await store.stat(stored.key, context)).size).toBe(17);
|
||||
expect(await store.localPath(stored.key, context)).toBe(
|
||||
path.join(root, "artifacts", "space-1", stored.key),
|
||||
);
|
||||
expect((await store.get(stored.key, context)).byteLength).toBe(17);
|
||||
});
|
||||
|
||||
it("accepts async iterables and rejects bodies over the cap without leaving a file", async () => {
|
||||
const { root, store } = await tempStore();
|
||||
const ok = await store.putStream(
|
||||
{ name: "a.txt", mimeType: "text/plain", stream: chunks("ab", "cd"), maxBytes: 4 },
|
||||
context,
|
||||
);
|
||||
expect(ok.size).toBe(4);
|
||||
await expect(
|
||||
store.putStream(
|
||||
{ name: "b.txt", mimeType: "text/plain", stream: chunks("abc", "de"), maxBytes: 4 },
|
||||
context,
|
||||
),
|
||||
).rejects.toBeInstanceOf(ArtifactTooLargeError);
|
||||
expect(await readdir(path.join(root, "artifacts", "space-1"))).toEqual([ok.key]);
|
||||
});
|
||||
|
||||
it("stops and cleans up when the run is cancelled mid-upload", async () => {
|
||||
const { root, store } = await tempStore();
|
||||
const controller = new AbortController();
|
||||
async function* aborting() {
|
||||
yield new TextEncoder().encode("first");
|
||||
controller.abort();
|
||||
yield new TextEncoder().encode("second");
|
||||
}
|
||||
await expect(
|
||||
store.putStream(
|
||||
{ name: "c.txt", mimeType: "text/plain", stream: aborting(), maxBytes: 100 },
|
||||
{ ...context, signal: controller.signal },
|
||||
),
|
||||
).rejects.toThrow();
|
||||
expect(await readdir(path.join(root, "artifacts", "space-1"))).toEqual([]);
|
||||
});
|
||||
|
||||
it("reads whole artifacts and inclusive byte ranges", async () => {
|
||||
const { store } = await tempStore();
|
||||
const stored = await store.putStream(
|
||||
{ name: "n.txt", mimeType: "text/plain", stream: chunks("0123456789"), maxBytes: 100 },
|
||||
context,
|
||||
);
|
||||
expect((await readAll(await store.createReadStream(stored.key, context))).toString()).toBe(
|
||||
"0123456789",
|
||||
);
|
||||
const middle = await store.createReadStream(stored.key, context, { start: 2, end: 5 });
|
||||
expect((await readAll(middle)).toString()).toBe("2345");
|
||||
const last = await store.createReadStream(stored.key, context, { start: 9, end: 9 });
|
||||
expect((await readAll(last)).toString()).toBe("9");
|
||||
for (const range of [
|
||||
{ start: 0, end: 10 },
|
||||
{ start: 5, end: 4 },
|
||||
{ start: -1, end: 3 },
|
||||
{ start: 1.5, end: 3 },
|
||||
]) {
|
||||
await expect(store.createReadStream(stored.key, context, range)).rejects.toBeInstanceOf(
|
||||
RangeError,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("streams large artifacts in bounded chunks", async () => {
|
||||
const { store } = await tempStore();
|
||||
const bytes = Buffer.alloc(700 * 1024, 7);
|
||||
const stored = await store.putStream(
|
||||
{
|
||||
name: "big.bin",
|
||||
mimeType: "text/plain",
|
||||
stream: new Response(bytes).body!,
|
||||
maxBytes: bytes.length,
|
||||
},
|
||||
context,
|
||||
);
|
||||
const reader = (await store.createReadStream(stored.key, context)).getReader();
|
||||
let total = 0;
|
||||
let largest = 0;
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
total += value.byteLength;
|
||||
largest = Math.max(largest, value.byteLength);
|
||||
}
|
||||
expect(total).toBe(bytes.length);
|
||||
expect(largest).toBeLessThanOrEqual(256 * 1024);
|
||||
});
|
||||
|
||||
it("rejects keys and spaces that could address another path", async () => {
|
||||
const { store } = await tempStore();
|
||||
for (const key of ["../space-2/x", "a/b", ".hidden", "", "x".repeat(200)]) {
|
||||
await expect(store.stat(key, context)).rejects.toThrow("Invalid artifact key");
|
||||
await expect(store.createReadStream(key, context)).rejects.toThrow("Invalid artifact key");
|
||||
await expect(store.remove(key, context)).rejects.toThrow("Invalid artifact key");
|
||||
}
|
||||
await expect(
|
||||
store.putStream(
|
||||
{ name: "x", mimeType: "text/plain", stream: chunks("x"), maxBytes: 10 },
|
||||
{ ...context, spaceId: "../escape" },
|
||||
),
|
||||
).rejects.toThrow("Invalid artifact space");
|
||||
});
|
||||
|
||||
it("does not follow a replacement symlink when streaming or resolving a local path", async () => {
|
||||
const { root, store } = await tempStore();
|
||||
const stored = await store.putStream(
|
||||
{ name: "p.txt", mimeType: "text/plain", stream: chunks("private"), maxBytes: 100 },
|
||||
context,
|
||||
);
|
||||
const file = path.join(root, "artifacts", "space-1", stored.key);
|
||||
const target = path.join(root, "outside.txt");
|
||||
await writeFile(target, "outside");
|
||||
await rm(file);
|
||||
await symlink(target, file);
|
||||
await expect(store.createReadStream(stored.key, context)).rejects.toThrow();
|
||||
await expect(store.stat(stored.key, context)).rejects.toThrow();
|
||||
await expect(store.localPath(stored.key, context)).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,16 +1,23 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import { constants } from "node:fs";
|
||||
import { mkdir, open, rm } from "node:fs/promises";
|
||||
import { type FileHandle, lstat, mkdir, open, rm } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import type {
|
||||
AdapterContext,
|
||||
ArtifactPut,
|
||||
ArtifactStore,
|
||||
NotificationMessage,
|
||||
NotificationProvider,
|
||||
import {
|
||||
type AdapterContext,
|
||||
type ArtifactByteRange,
|
||||
type ArtifactPut,
|
||||
type ArtifactStore,
|
||||
type ArtifactStreamPut,
|
||||
ArtifactTooLargeError,
|
||||
type NotificationMessage,
|
||||
type NotificationProvider,
|
||||
type StoredArtifactStream,
|
||||
} from "@rakazo/adapter-kit";
|
||||
|
||||
const O_NOFOLLOW = constants.O_NOFOLLOW ?? 0;
|
||||
const READ_CHUNK_BYTES = 256 * 1024;
|
||||
/** Space ids and artifact keys are generated ids; anything else could address another path. */
|
||||
const SAFE_SEGMENT = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/;
|
||||
|
||||
export class LocalArtifactStore implements ArtifactStore {
|
||||
constructor(private readonly root: string) {}
|
||||
@ -24,29 +31,75 @@ export class LocalArtifactStore implements ArtifactStore {
|
||||
};
|
||||
}
|
||||
|
||||
async put(artifact: ArtifactPut, context: AdapterContext) {
|
||||
const id = randomUUID();
|
||||
const dir = path.join(this.root, "artifacts", context.spaceId);
|
||||
private spaceDir(context: AdapterContext) {
|
||||
if (!SAFE_SEGMENT.test(context.spaceId)) throw new Error("Invalid artifact space");
|
||||
return path.join(this.root, "artifacts", context.spaceId);
|
||||
}
|
||||
|
||||
private file(key: string, context: AdapterContext) {
|
||||
if (!SAFE_SEGMENT.test(key)) throw new Error("Invalid artifact key");
|
||||
return path.join(this.spaceDir(context), key);
|
||||
}
|
||||
|
||||
/** Creates a new owner-only file under a fresh key; never follows or replaces an existing path. */
|
||||
private async create(context: AdapterContext) {
|
||||
const dir = this.spaceDir(context);
|
||||
await mkdir(dir, { recursive: true });
|
||||
const file = path.join(dir, id);
|
||||
const key = randomUUID();
|
||||
const file = path.join(dir, key);
|
||||
const handle = await open(
|
||||
file,
|
||||
constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | O_NOFOLLOW,
|
||||
0o600,
|
||||
);
|
||||
return { key, file, handle };
|
||||
}
|
||||
|
||||
private openForRead(key: string, context: AdapterContext) {
|
||||
return open(this.file(key, context), constants.O_RDONLY | O_NOFOLLOW);
|
||||
}
|
||||
|
||||
async put(artifact: ArtifactPut, context: AdapterContext) {
|
||||
const { key, handle } = await this.create(context);
|
||||
try {
|
||||
await handle.writeFile(artifact.bytes);
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
return { id, hash: String(artifact.bytes.byteLength) };
|
||||
return { id: key, hash: String(artifact.bytes.byteLength) };
|
||||
}
|
||||
|
||||
async putStream(
|
||||
artifact: ArtifactStreamPut,
|
||||
context: AdapterContext,
|
||||
): Promise<StoredArtifactStream> {
|
||||
if (!Number.isSafeInteger(artifact.maxBytes) || artifact.maxBytes < 0) {
|
||||
throw new Error("maxBytes must be a non-negative integer");
|
||||
}
|
||||
const { key, file, handle } = await this.create(context);
|
||||
const hash = createHash("sha256");
|
||||
let size = 0;
|
||||
try {
|
||||
try {
|
||||
for await (const chunk of byteChunks(artifact.stream)) {
|
||||
context.signal.throwIfAborted();
|
||||
size += chunk.byteLength;
|
||||
if (size > artifact.maxBytes) throw new ArtifactTooLargeError(artifact.maxBytes);
|
||||
hash.update(chunk);
|
||||
await writeFully(handle, chunk);
|
||||
}
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
} catch (error) {
|
||||
await rm(file, { force: true }).catch(() => undefined);
|
||||
throw error;
|
||||
}
|
||||
return { key, size, sha256: hash.digest("hex") };
|
||||
}
|
||||
|
||||
async get(id: string, context: AdapterContext) {
|
||||
const handle = await open(
|
||||
path.join(this.root, "artifacts", context.spaceId, id),
|
||||
constants.O_RDONLY | O_NOFOLLOW,
|
||||
);
|
||||
const handle = await this.openForRead(id, context);
|
||||
try {
|
||||
return new Uint8Array(await handle.readFile());
|
||||
} finally {
|
||||
@ -54,9 +107,116 @@ export class LocalArtifactStore implements ArtifactStore {
|
||||
}
|
||||
}
|
||||
|
||||
async remove(id: string, context: AdapterContext) {
|
||||
await rm(path.join(this.root, "artifacts", context.spaceId, id), { force: true });
|
||||
async stat(key: string, context: AdapterContext) {
|
||||
const handle = await this.openForRead(key, context);
|
||||
try {
|
||||
const info = await handle.stat();
|
||||
if (!info.isFile()) throw new Error("Artifact is not a file");
|
||||
return { size: info.size };
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
}
|
||||
|
||||
async createReadStream(key: string, context: AdapterContext, range?: ArtifactByteRange) {
|
||||
const handle = await this.openForRead(key, context);
|
||||
try {
|
||||
const info = await handle.stat();
|
||||
if (!info.isFile()) throw new Error("Artifact is not a file");
|
||||
const start = range?.start ?? 0;
|
||||
const end = range?.end ?? info.size - 1;
|
||||
if (
|
||||
range &&
|
||||
(!Number.isSafeInteger(start) ||
|
||||
!Number.isSafeInteger(end) ||
|
||||
start < 0 ||
|
||||
end < start ||
|
||||
end >= info.size)
|
||||
) {
|
||||
throw new RangeError("Range does not fit the artifact");
|
||||
}
|
||||
return fileByteStream(handle, start, end);
|
||||
} catch (error) {
|
||||
await handle.close();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async localPath(key: string, context: AdapterContext) {
|
||||
const file = this.file(key, context);
|
||||
const info = await lstat(file);
|
||||
if (!info.isFile()) throw new Error("Artifact is not a file");
|
||||
return file;
|
||||
}
|
||||
|
||||
async remove(id: string, context: AdapterContext) {
|
||||
await rm(this.file(id, context), { force: true });
|
||||
}
|
||||
}
|
||||
|
||||
async function* byteChunks(
|
||||
source: ReadableStream<Uint8Array> | AsyncIterable<Uint8Array>,
|
||||
): AsyncIterable<Uint8Array> {
|
||||
if (!("getReader" in source)) {
|
||||
yield* source;
|
||||
return;
|
||||
}
|
||||
const reader = source.getReader();
|
||||
try {
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) return;
|
||||
yield value;
|
||||
}
|
||||
} finally {
|
||||
// Stop the producer (e.g. an upload body) when the store gives up early.
|
||||
await reader.cancel().catch(() => undefined);
|
||||
reader.releaseLock();
|
||||
}
|
||||
}
|
||||
|
||||
async function writeFully(handle: FileHandle, chunk: Uint8Array) {
|
||||
let offset = 0;
|
||||
while (offset < chunk.byteLength) {
|
||||
const { bytesWritten } = await handle.write(chunk, offset, chunk.byteLength - offset);
|
||||
offset += bytesWritten;
|
||||
}
|
||||
}
|
||||
|
||||
/** Streams [start, end] of an open file and closes it when done, failed or cancelled. */
|
||||
function fileByteStream(handle: FileHandle, start: number, end: number) {
|
||||
let position = start;
|
||||
let closed = false;
|
||||
const close = async () => {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
await handle.close().catch(() => undefined);
|
||||
};
|
||||
return new ReadableStream<Uint8Array>({
|
||||
async pull(controller) {
|
||||
try {
|
||||
const length = Math.min(end - position + 1, READ_CHUNK_BYTES);
|
||||
if (length <= 0) {
|
||||
await close();
|
||||
controller.close();
|
||||
return;
|
||||
}
|
||||
const buffer = new Uint8Array(length);
|
||||
const { bytesRead } = await handle.read(buffer, 0, length, position);
|
||||
if (bytesRead === 0) throw new Error("Artifact ended before the requested range");
|
||||
position += bytesRead;
|
||||
controller.enqueue(bytesRead === length ? buffer : buffer.subarray(0, bytesRead));
|
||||
if (position > end) {
|
||||
await close();
|
||||
controller.close();
|
||||
}
|
||||
} catch (error) {
|
||||
await close();
|
||||
controller.error(error);
|
||||
}
|
||||
},
|
||||
cancel: close,
|
||||
});
|
||||
}
|
||||
|
||||
export class CapturingNotificationProvider implements NotificationProvider {
|
||||
|
||||
@ -16,6 +16,7 @@ import { scheduleComputerSleep, sleepComputerIfIdle } from "./computer-idle.js";
|
||||
import { performComputerUpdate } from "./computer-update.js";
|
||||
import type { createRunExecutor } from "./executor.js";
|
||||
import { compactHistory } from "./history-compaction.js";
|
||||
import type { MediaRuntimeHost } from "./media-runtime.js";
|
||||
import type { MemoryProviderResolver } from "./memory-provider-factory.js";
|
||||
import { deliverMessagingOutbound, mirrorMessagingOutbound } from "./messaging-delivery.js";
|
||||
import { consolidateProfile } from "./profile-consolidation.js";
|
||||
@ -36,6 +37,7 @@ export function createBackgroundJobHandlers(deps: {
|
||||
deploymentModelKey?: string;
|
||||
messaging?: MessagingSurface;
|
||||
cloudAgent?: CloudAgentConnection | null;
|
||||
media?: MediaRuntimeHost;
|
||||
}): BackgroundJobHandlers {
|
||||
const deliverMessaging = async (runId?: string) => {
|
||||
if (!deps.messaging) return;
|
||||
@ -112,6 +114,9 @@ export function createBackgroundJobHandlers(deps: {
|
||||
payload.threadId,
|
||||
);
|
||||
},
|
||||
"media.run": async (payload) => {
|
||||
await deps.media?.runJob(payload.jobId);
|
||||
},
|
||||
"profile.consolidate": async (payload) => {
|
||||
await consolidateProfile(
|
||||
{
|
||||
|
||||
@ -6,6 +6,7 @@ import type {
|
||||
ConnectorProvider,
|
||||
JobPublisher,
|
||||
ManagedConnectorProvider,
|
||||
MediaRuntime,
|
||||
MemoryStore,
|
||||
NotificationProvider,
|
||||
SandboxProvider,
|
||||
@ -45,6 +46,8 @@ export interface ExecutorDeps {
|
||||
secretHttp?: RemoteTransportDependencies;
|
||||
/** Remote cloud coding agents. Null/omit means tools stay uninjected. */
|
||||
cloudAgent?: CloudAgentConnection | null;
|
||||
/** Media jobs (generation, edits, transcription). Omit means media tools stay hidden. */
|
||||
media?: MediaRuntime;
|
||||
/** Aborted when createApp stop() begins so in-flight continueRun boot waits exit promptly. */
|
||||
shutdownSignal?: AbortSignal;
|
||||
}
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
export * from "./agent-connections.js";
|
||||
export * from "./agent-environment.js";
|
||||
export * from "./ai-consent.js";
|
||||
export * from "./artifact-files.js";
|
||||
export * from "./artifacts.js";
|
||||
export * from "./auto-review.js";
|
||||
export * from "./background-job-handlers.js";
|
||||
@ -78,6 +79,10 @@ export * from "./mcp-server-tool.js";
|
||||
export * from "./mcp-transport.js";
|
||||
export * from "./media/ffmpeg.js";
|
||||
export * from "./media/video-operations.js";
|
||||
export * from "./media-operations.js";
|
||||
export * from "./media-runtime.js";
|
||||
export * from "./media-settings.js";
|
||||
export * from "./media-tools.js";
|
||||
export * from "./memory-provider-factory.js";
|
||||
export * from "./messaging-context.js";
|
||||
export * from "./messaging-delivery.js";
|
||||
|
||||
@ -109,6 +109,7 @@ export function createJobReconciler(
|
||||
/** Enqueues daily profile consolidation; see createProfileConsolidationScheduler. */
|
||||
reconcileProfileConsolidation?: () => Promise<void>;
|
||||
reconcileDelegatedTasks?: () => Promise<void>;
|
||||
reconcileMediaJobs?: () => Promise<void>;
|
||||
},
|
||||
options: { intervalMs?: number; batchSize?: number } = {},
|
||||
) {
|
||||
@ -132,6 +133,7 @@ export function createJobReconciler(
|
||||
deps.reconcileComputerUpdates,
|
||||
deps.reconcileProfileConsolidation,
|
||||
deps.reconcileDelegatedTasks,
|
||||
deps.reconcileMediaJobs,
|
||||
].map(async (reconcile) => reconcile?.()),
|
||||
);
|
||||
for (const result of auxiliary) {
|
||||
|
||||
15
packages/adapters/src/media-operations.ts
Normal file
15
packages/adapters/src/media-operations.ts
Normal file
@ -0,0 +1,15 @@
|
||||
import type { MediaSettings } from "@rakazo/contracts";
|
||||
import type { MediaOperationRegistration } from "./media-runtime.js";
|
||||
|
||||
export interface MediaOperationDeps {
|
||||
/** Current deployment-wide media backends; read per call so edits apply without a restart. */
|
||||
loadSettings(): Promise<MediaSettings>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every media operation this deployment can run. The API and the worker build their runtime
|
||||
* from this one list, so both processes run the same operations.
|
||||
*/
|
||||
export function createMediaOperations(_deps: MediaOperationDeps): MediaOperationRegistration[] {
|
||||
return [];
|
||||
}
|
||||
1041
packages/adapters/src/media-runtime.ts
Normal file
1041
packages/adapters/src/media-runtime.ts
Normal file
File diff suppressed because it is too large
Load Diff
132
packages/adapters/src/media-settings.ts
Normal file
132
packages/adapters/src/media-settings.ts
Normal file
@ -0,0 +1,132 @@
|
||||
import {
|
||||
type MediaSettings,
|
||||
MediaSettingsSchema,
|
||||
type MediaSettingsUpdate,
|
||||
MediaSettingsUpdateSchema,
|
||||
type MediaSettingsView,
|
||||
} from "@rakazo/contracts";
|
||||
import type { PrismaClient } from "@rakazo/db";
|
||||
import {
|
||||
assertAllowedOpenAiCompatibleRequestUrl,
|
||||
assertAllowedOpenAiCompatibleUrl,
|
||||
assertHttpsForKeyedOpenAiCompatibleUrl,
|
||||
} from "./openai-compatible-url.js";
|
||||
import type { EncryptedSecretStore } from "./secrets.js";
|
||||
|
||||
/** The IntegrationProviderConfig row that stores the media backends, encrypted as one record. */
|
||||
export const MEDIA_SETTINGS_ID = "media";
|
||||
const RECORD_ID = `integration-provider:${MEDIA_SETTINGS_ID}`;
|
||||
|
||||
export interface MediaSettingsDeps {
|
||||
prisma: Pick<PrismaClient, "integrationProviderConfig">;
|
||||
secrets: Pick<EncryptedSecretStore, "load" | "put">;
|
||||
}
|
||||
|
||||
const NOT_CONFIGURED: MediaSettings = { comfyui: null, transcription: null };
|
||||
|
||||
// Decrypting derives a scrypt key; tool selection reads the settings on every run.
|
||||
let cached: { ciphertext: string; settings: MediaSettings } | undefined;
|
||||
|
||||
/** Deployment-wide media backends, read fresh from the database so every process sees edits. */
|
||||
export async function loadMediaSettings(deps: MediaSettingsDeps): Promise<MediaSettings> {
|
||||
const row = await deps.prisma.integrationProviderConfig.findUnique({
|
||||
where: { id: MEDIA_SETTINGS_ID },
|
||||
});
|
||||
if (!row) return NOT_CONFIGURED;
|
||||
if (cached?.ciphertext === row.ciphertext) return cached.settings;
|
||||
const settings = MediaSettingsSchema.parse(
|
||||
JSON.parse(deps.secrets.load(row.ciphertext, RECORD_ID)),
|
||||
);
|
||||
cached = { ciphertext: row.ciphertext, settings };
|
||||
return settings;
|
||||
}
|
||||
|
||||
export function mediaSettingsView(settings: MediaSettings): MediaSettingsView {
|
||||
return {
|
||||
comfyui: settings.comfyui ? withoutKey(settings.comfyui) : null,
|
||||
transcription: settings.transcription ? withoutKey(settings.transcription) : null,
|
||||
};
|
||||
}
|
||||
|
||||
function withoutKey<T extends { apiKey?: string }>({ apiKey, ...rest }: T) {
|
||||
return { ...rest, hasApiKey: Boolean(apiKey) };
|
||||
}
|
||||
|
||||
/** A ComfyUI root such as `http://127.0.0.1:8188`, without credentials, query or fragment. */
|
||||
export function normalizeComfyUiBaseUrl(raw: string): string {
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(raw.trim());
|
||||
} catch {
|
||||
throw new Error("Server URL must be an absolute HTTP(S) URL");
|
||||
}
|
||||
if (url.username || url.password) throw new Error("Server URL must not contain credentials");
|
||||
const normalized = `${url.origin}${url.pathname.replace(/\/+$/, "")}`;
|
||||
// Same address policy as OpenAI-compatible servers: private hosts pass, metadata hosts never.
|
||||
return assertAllowedOpenAiCompatibleRequestUrl(normalized).href.replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
/** An OpenAI-compatible API root ending in a version segment, such as `…/v1`. */
|
||||
export function normalizeTranscriptionBaseUrl(raw: string): string {
|
||||
return assertAllowedOpenAiCompatibleUrl(raw).href.replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
function keptKey(
|
||||
next: string | undefined,
|
||||
previous: { baseUrl: string; apiKey?: string } | null,
|
||||
baseUrl: string,
|
||||
): string | undefined {
|
||||
if (next !== undefined) return next || undefined;
|
||||
// A saved key never follows the settings to another server.
|
||||
if (!previous?.apiKey) return undefined;
|
||||
return new URL(previous.baseUrl).origin === new URL(baseUrl).origin ? previous.apiKey : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates and stores the media backends (deployment owner only; the caller authorizes).
|
||||
* Throws an Error with a user-facing message when a URL is not allowed.
|
||||
*/
|
||||
export async function saveMediaSettings(
|
||||
deps: MediaSettingsDeps,
|
||||
update: MediaSettingsUpdate,
|
||||
): Promise<MediaSettingsView> {
|
||||
const parsed = MediaSettingsUpdateSchema.parse(update);
|
||||
const previous = await loadMediaSettings(deps);
|
||||
let comfyui: MediaSettings["comfyui"] = null;
|
||||
if (parsed.comfyui) {
|
||||
const baseUrl = normalizeComfyUiBaseUrl(parsed.comfyui.baseUrl);
|
||||
const apiKey = keptKey(parsed.comfyui.apiKey, previous.comfyui, baseUrl);
|
||||
assertHttpsForKeyedOpenAiCompatibleUrl(new URL(baseUrl), apiKey);
|
||||
comfyui = { ...parsed.comfyui, baseUrl, apiKey };
|
||||
}
|
||||
let transcription: MediaSettings["transcription"] = null;
|
||||
if (parsed.transcription) {
|
||||
const baseUrl = normalizeTranscriptionBaseUrl(parsed.transcription.baseUrl);
|
||||
const apiKey = keptKey(parsed.transcription.apiKey, previous.transcription, baseUrl);
|
||||
assertHttpsForKeyedOpenAiCompatibleUrl(new URL(baseUrl), apiKey);
|
||||
transcription = { ...parsed.transcription, baseUrl, apiKey };
|
||||
}
|
||||
const settings = MediaSettingsSchema.parse({ comfyui, transcription });
|
||||
if (!settings.comfyui && !settings.transcription) {
|
||||
await deps.prisma.integrationProviderConfig.deleteMany({ where: { id: MEDIA_SETTINGS_ID } });
|
||||
return mediaSettingsView(settings);
|
||||
}
|
||||
const stored = await deps.secrets.put(
|
||||
JSON.stringify(settings),
|
||||
{
|
||||
operationId: "media-settings",
|
||||
traceId: "media-settings",
|
||||
spaceId: "",
|
||||
userId: "",
|
||||
signal: new AbortController().signal,
|
||||
},
|
||||
RECORD_ID,
|
||||
);
|
||||
await deps.prisma.integrationProviderConfig.upsert({
|
||||
where: { id: MEDIA_SETTINGS_ID },
|
||||
create: { id: MEDIA_SETTINGS_ID, ciphertext: stored.ciphertext },
|
||||
update: { ciphertext: stored.ciphertext },
|
||||
});
|
||||
cached = { ciphertext: stored.ciphertext, settings };
|
||||
return mediaSettingsView(settings);
|
||||
}
|
||||
78
packages/adapters/src/media-tools.ts
Normal file
78
packages/adapters/src/media-tools.ts
Normal file
@ -0,0 +1,78 @@
|
||||
import type { ConnectorTool, MediaCapabilities, MediaRuntime } from "@rakazo/adapter-kit";
|
||||
import { type MediaOperation, MediaOperationSchema } from "@rakazo/contracts";
|
||||
import { MediaJobRejectedError } from "./media-runtime.js";
|
||||
|
||||
/** Media tools are named exactly like their operations and dispatched by one module. */
|
||||
const MEDIA_TOOL_NAMES: ReadonlySet<string> = new Set(MediaOperationSchema.options);
|
||||
|
||||
export function isMediaToolName(name: string): name is MediaOperation {
|
||||
return MEDIA_TOOL_NAMES.has(name);
|
||||
}
|
||||
|
||||
/** The shared `continue_after` property every media tool definition accepts. */
|
||||
export const MEDIA_CONTINUE_AFTER_PROPERTY = {
|
||||
continue_after: {
|
||||
type: "boolean",
|
||||
description:
|
||||
"Wake me with the result when the job ends so I can continue a multi-step task. Default false: the media card shows the result to the user.",
|
||||
},
|
||||
} as const;
|
||||
|
||||
/** A media tool's JSON schema: its own properties plus `continue_after`. */
|
||||
export function mediaToolInputSchema(schema: {
|
||||
properties: Record<string, unknown>;
|
||||
required?: string[];
|
||||
}): ConnectorTool["inputSchema"] {
|
||||
return {
|
||||
type: "object",
|
||||
properties: { ...schema.properties, ...MEDIA_CONTINUE_AFTER_PROPERTY },
|
||||
...(schema.required?.length ? { required: schema.required } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/** Drops media tools whose operation the space cannot run right now. */
|
||||
export function selectMediaTools<T extends { name: string }>(
|
||||
tools: T[],
|
||||
capabilities: MediaCapabilities | undefined,
|
||||
): T[] {
|
||||
return tools.filter((tool) => !isMediaToolName(tool.name) || capabilities?.[tool.name] === true);
|
||||
}
|
||||
|
||||
/** Starts the job behind a media tool call; the turn never waits for the job itself. */
|
||||
export async function executeMediaTool(
|
||||
media: MediaRuntime | undefined,
|
||||
scope: {
|
||||
spaceId: string;
|
||||
userId: string;
|
||||
botId: string;
|
||||
groupId: string | null;
|
||||
threadId: string;
|
||||
runId: string;
|
||||
/** The tool call's effect key, so a replayed call returns the job it started. */
|
||||
operationKey: string;
|
||||
},
|
||||
operation: MediaOperation,
|
||||
args: Record<string, unknown>,
|
||||
) {
|
||||
if (!media) return { error: `${operation} is not available.` };
|
||||
const { continue_after: continueAfter, ...input } = args;
|
||||
try {
|
||||
const started = await media.start({
|
||||
operation,
|
||||
input,
|
||||
...scope,
|
||||
continueAfter: continueAfter === true,
|
||||
});
|
||||
return {
|
||||
jobId: started.jobId,
|
||||
status: "queued",
|
||||
note:
|
||||
continueAfter === true
|
||||
? "The job runs in the background. You are woken with its result when it ends; do not poll or wait."
|
||||
: "The job runs in the background and its card shows progress and the result to the user. Do not poll or wait.",
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof MediaJobRejectedError) return { error: error.message };
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@ -24,6 +24,7 @@ function handlers(): BackgroundJobHandlers {
|
||||
"messaging.deliver": vi.fn(async () => undefined),
|
||||
"cloud_agent.poll": vi.fn(async () => undefined),
|
||||
"profile.consolidate": vi.fn(async () => undefined),
|
||||
"media.run": vi.fn(async () => undefined),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -29,6 +29,7 @@ function handlers(overrides: Partial<BackgroundJobHandlers> = {}): BackgroundJob
|
||||
"messaging.deliver": vi.fn(async () => undefined),
|
||||
"cloud_agent.poll": vi.fn(async () => undefined),
|
||||
"profile.consolidate": vi.fn(async () => undefined),
|
||||
"media.run": vi.fn(async () => undefined),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
@ -141,6 +142,39 @@ describePostgres("Graphile background jobs (PostgreSQL contract)", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("runs jobs that share a queue one at a time across workers", async () => {
|
||||
const publisher = new GraphileJobPublisher(testPool());
|
||||
const first = new GraphileJobWorkerHost(testPool(), { concurrency: 2, pollInterval: 25 });
|
||||
const second = new GraphileJobWorkerHost(testPool(), { concurrency: 2, pollInterval: 25 });
|
||||
const queue = `contract-queue-${Date.now()}`;
|
||||
let active = 0;
|
||||
let maxActive = 0;
|
||||
const done: string[] = [];
|
||||
const target = handlers({
|
||||
"media.run": async ({ jobId }) => {
|
||||
active += 1;
|
||||
maxActive = Math.max(maxActive, active);
|
||||
await new Promise((resolve) => setTimeout(resolve, 150));
|
||||
active -= 1;
|
||||
done.push(jobId);
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
await first.start(target);
|
||||
await second.start(target);
|
||||
for (const jobId of ["q1", "q2", "q3"]) {
|
||||
await publisher.enqueue({ name: "media.run", payload: { jobId }, queue });
|
||||
}
|
||||
await waitFor(() => expect(done).toHaveLength(3));
|
||||
expect(maxActive).toBe(1);
|
||||
} finally {
|
||||
await first.stop();
|
||||
await second.stop();
|
||||
await publisher.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("persists a handler failure and retries the job successfully", async () => {
|
||||
const publisher = new GraphileJobPublisher(testPool());
|
||||
const host = new GraphileJobWorkerHost(testPool(), { concurrency: 1, pollInterval: 25 });
|
||||
|
||||
@ -14,6 +14,7 @@ function handlers(): BackgroundJobHandlers {
|
||||
"messaging.deliver": vi.fn(async () => undefined),
|
||||
"cloud_agent.poll": vi.fn(async () => undefined),
|
||||
"profile.consolidate": vi.fn(async () => undefined),
|
||||
"media.run": vi.fn(async () => undefined),
|
||||
};
|
||||
}
|
||||
|
||||
@ -198,6 +199,59 @@ describe("InMemoryJobQueue", () => {
|
||||
await expect(enqueue).rejects.toThrow("Background job publisher is stopped");
|
||||
});
|
||||
|
||||
it("runs jobs of one named queue one at a time, in order", async () => {
|
||||
const queue = new InMemoryJobQueue();
|
||||
const order: string[] = [];
|
||||
let active = 0;
|
||||
let maxActive = 0;
|
||||
const target = handlers();
|
||||
target["media.run"] = async ({ jobId }) => {
|
||||
active += 1;
|
||||
maxActive = Math.max(maxActive, active);
|
||||
order.push(`start:${jobId}`);
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
order.push(`end:${jobId}`);
|
||||
active -= 1;
|
||||
};
|
||||
target["computer.sleep"] = async () => {
|
||||
order.push("unqueued");
|
||||
};
|
||||
await queue.start(target);
|
||||
|
||||
for (const jobId of ["a", "b", "c"]) {
|
||||
await queue.enqueue({ name: "media.run", payload: { jobId }, queue: "media" });
|
||||
}
|
||||
await queue.enqueue({ name: "computer.sleep", payload: { computerId: "computer-1" } });
|
||||
await queue.close();
|
||||
|
||||
expect(maxActive).toBe(1);
|
||||
expect(order.filter((entry) => entry !== "unqueued")).toEqual([
|
||||
"start:a",
|
||||
"end:a",
|
||||
"start:b",
|
||||
"end:b",
|
||||
"start:c",
|
||||
"end:c",
|
||||
]);
|
||||
// Jobs outside the queue do not wait for it.
|
||||
expect(order.indexOf("unqueued")).toBeLessThan(order.indexOf("end:a"));
|
||||
});
|
||||
|
||||
it("keeps a named queue moving after a job fails", async () => {
|
||||
const queue = new InMemoryJobQueue();
|
||||
const ran: string[] = [];
|
||||
const target = handlers();
|
||||
target["media.run"] = async ({ jobId }) => {
|
||||
ran.push(jobId);
|
||||
if (jobId === "a") throw new Error("boom");
|
||||
};
|
||||
await queue.start(target);
|
||||
await queue.enqueue({ name: "media.run", payload: { jobId: "a" }, queue: "media" });
|
||||
await queue.enqueue({ name: "media.run", payload: { jobId: "b" }, queue: "media" });
|
||||
await queue.close();
|
||||
expect(ran).toEqual(["a", "b"]);
|
||||
});
|
||||
|
||||
it("drains an accepted immediate timer during close", async () => {
|
||||
const queue = new InMemoryJobQueue();
|
||||
const target = handlers();
|
||||
|
||||
@ -25,6 +25,7 @@ export class GraphileJobPublisher implements JobPublisher {
|
||||
await utils.addJob(job.name, wrapJobPayload(job.payload), {
|
||||
runAt: job.availableAt,
|
||||
jobKey: job.replaceKey,
|
||||
queueName: job.queue,
|
||||
});
|
||||
}
|
||||
|
||||
@ -180,6 +181,7 @@ interface QueuedJob {
|
||||
payload: unknown;
|
||||
availableAt?: Date;
|
||||
replaceKey?: string;
|
||||
queue?: string;
|
||||
}
|
||||
|
||||
function toQueuedJob(job: BackgroundJob): QueuedJob {
|
||||
@ -188,6 +190,7 @@ function toQueuedJob(job: BackgroundJob): QueuedJob {
|
||||
payload: wrapJobPayload(job.payload),
|
||||
availableAt: job.availableAt,
|
||||
replaceKey: job.replaceKey,
|
||||
queue: job.queue,
|
||||
};
|
||||
}
|
||||
|
||||
@ -197,6 +200,8 @@ export class InMemoryJobQueue implements JobPublisher, JobWorkerHost {
|
||||
private readonly scheduled = new Map<ReturnType<typeof setTimeout>, QueuedJob>();
|
||||
private readonly keyed = new Map<string, ReturnType<typeof setTimeout>>();
|
||||
private readonly active = new Set<Promise<void>>();
|
||||
/** Last job of each named queue; the next one starts after it settles, like Graphile queues. */
|
||||
private readonly queueTails = new Map<string, Promise<void>>();
|
||||
private readonly closingJobs: QueuedJob[] = [];
|
||||
private draining: Promise<void> | undefined;
|
||||
private closed = false;
|
||||
@ -260,12 +265,22 @@ export class InMemoryJobQueue implements JobPublisher, JobWorkerHost {
|
||||
|
||||
private dispatch(handlers: BackgroundJobHandlers, job: QueuedJob): Promise<void> {
|
||||
const unpacked = unwrapJobPayload(job.payload);
|
||||
const active = runCorrelatedJob({
|
||||
name: job.name,
|
||||
payload: unpacked.payload,
|
||||
correlation: unpacked.correlation,
|
||||
run: () => dispatchBackgroundJob(handlers, job.name, unpacked.payload),
|
||||
}).catch(() => undefined);
|
||||
const run = () =>
|
||||
runCorrelatedJob({
|
||||
name: job.name,
|
||||
payload: unpacked.payload,
|
||||
correlation: unpacked.correlation,
|
||||
run: () => dispatchBackgroundJob(handlers, job.name, unpacked.payload),
|
||||
});
|
||||
const queue = job.queue;
|
||||
const tail = queue ? this.queueTails.get(queue) : undefined;
|
||||
const active = (tail ? tail.then(run) : run()).catch(() => undefined);
|
||||
if (queue) {
|
||||
this.queueTails.set(queue, active);
|
||||
void active.finally(() => {
|
||||
if (this.queueTails.get(queue) === active) this.queueTails.delete(queue);
|
||||
});
|
||||
}
|
||||
this.active.add(active);
|
||||
void active.finally(() => this.active.delete(active));
|
||||
return active;
|
||||
|
||||
@ -922,6 +922,7 @@ export const RunSchema = z.object({
|
||||
"messaging",
|
||||
"cloud_agent",
|
||||
"delegation",
|
||||
"media",
|
||||
]),
|
||||
routineId: Id.nullable(),
|
||||
modelProvider: z.string().nullable(),
|
||||
|
||||
@ -10,6 +10,7 @@ export * from "./integration-settings.js";
|
||||
export * from "./local-settings.js";
|
||||
export * from "./mcp.js";
|
||||
export * from "./media.js";
|
||||
export * from "./media-settings.js";
|
||||
export * from "./media-video.js";
|
||||
export * from "./model-tiers.js";
|
||||
export * from "./openai-compatible-ui.js";
|
||||
|
||||
78
packages/contracts/src/media-settings.ts
Normal file
78
packages/contracts/src/media-settings.ts
Normal file
@ -0,0 +1,78 @@
|
||||
import * as z from "zod";
|
||||
|
||||
/** Cap for a custom ComfyUI workflow (API-format JSON). */
|
||||
export const MEDIA_WORKFLOW_MAX_LENGTH = 512 * 1024;
|
||||
|
||||
/** Empty form fields mean "not set". */
|
||||
function optionalText(max: number) {
|
||||
return z
|
||||
.string()
|
||||
.trim()
|
||||
.max(max)
|
||||
.optional()
|
||||
.transform((value) => value || undefined);
|
||||
}
|
||||
|
||||
function isJsonObject(value: string | undefined): boolean {
|
||||
if (value === undefined) return true;
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(value);
|
||||
return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const workflow = optionalText(MEDIA_WORKFLOW_MAX_LENGTH).refine(isJsonObject, {
|
||||
message: "Workflow must be a JSON object",
|
||||
});
|
||||
|
||||
/** Image and video generation on the deployment owner's ComfyUI server. */
|
||||
export const ComfyUiSettingsSchema = z.object({
|
||||
baseUrl: z.string().trim().min(1).max(2048),
|
||||
apiKey: optionalText(4096),
|
||||
/** Checkpoint name; the first installed checkpoint when unset. */
|
||||
imageModel: optionalText(512),
|
||||
/** Custom API-format workflows that replace the built-in defaults. */
|
||||
imageWorkflow: workflow,
|
||||
videoWorkflow: workflow,
|
||||
});
|
||||
export type ComfyUiSettings = z.infer<typeof ComfyUiSettingsSchema>;
|
||||
|
||||
/** Speech to text on an OpenAI-compatible server (`POST /v1/audio/transcriptions`). */
|
||||
export const TranscriptionSettingsSchema = z.object({
|
||||
baseUrl: z.string().trim().min(1).max(2048),
|
||||
model: optionalText(512),
|
||||
apiKey: optionalText(4096),
|
||||
});
|
||||
export type TranscriptionSettings = z.infer<typeof TranscriptionSettingsSchema>;
|
||||
|
||||
/** Deployment-wide media backends; null means the backend is not configured. */
|
||||
export const MediaSettingsSchema = z.object({
|
||||
comfyui: ComfyUiSettingsSchema.nullable(),
|
||||
transcription: TranscriptionSettingsSchema.nullable(),
|
||||
});
|
||||
export type MediaSettings = z.infer<typeof MediaSettingsSchema>;
|
||||
|
||||
/** What the settings screen reads: saved keys are never returned, only whether one exists. */
|
||||
export const MediaSettingsViewSchema = z.object({
|
||||
comfyui: ComfyUiSettingsSchema.omit({ apiKey: true })
|
||||
.extend({ hasApiKey: z.boolean() })
|
||||
.nullable(),
|
||||
transcription: TranscriptionSettingsSchema.omit({ apiKey: true })
|
||||
.extend({ hasApiKey: z.boolean() })
|
||||
.nullable(),
|
||||
});
|
||||
export type MediaSettingsView = z.infer<typeof MediaSettingsViewSchema>;
|
||||
|
||||
const keyUpdate = z.string().trim().max(4096).optional();
|
||||
|
||||
/**
|
||||
* Saving replaces both backends. An omitted `apiKey` keeps the saved key while the server's
|
||||
* origin stays the same; an empty one removes it.
|
||||
*/
|
||||
export const MediaSettingsUpdateSchema = z.object({
|
||||
comfyui: ComfyUiSettingsSchema.extend({ apiKey: keyUpdate }).nullable(),
|
||||
transcription: TranscriptionSettingsSchema.extend({ apiKey: keyUpdate }).nullable(),
|
||||
});
|
||||
export type MediaSettingsUpdate = z.input<typeof MediaSettingsUpdateSchema>;
|
||||
@ -22,6 +22,7 @@ export const RunActivityRowSchema = z.object({
|
||||
"messaging",
|
||||
"cloud_agent",
|
||||
"delegation",
|
||||
"media",
|
||||
]),
|
||||
notificationsEnabled: z.boolean(),
|
||||
promptSnippet: z.string(),
|
||||
|
||||
@ -0,0 +1,12 @@
|
||||
-- Media jobs run on whichever process claims them first: a fenced lease keeps two processes from
|
||||
-- running one job, deferrals bound how long a job waits for the GPU, an operation key makes a
|
||||
-- replayed tool call return the job it started, and continueAfter wakes the bot when it ends.
|
||||
ALTER TABLE "media_jobs" ADD COLUMN "deferrals" INTEGER NOT NULL DEFAULT 0;
|
||||
ALTER TABLE "media_jobs" ADD COLUMN "continueAfter" BOOLEAN NOT NULL DEFAULT false;
|
||||
ALTER TABLE "media_jobs" ADD COLUMN "operationKey" TEXT;
|
||||
ALTER TABLE "media_jobs" ADD COLUMN "leaseToken" TEXT;
|
||||
ALTER TABLE "media_jobs" ADD COLUMN "leaseExpiresAt" TIMESTAMP(3);
|
||||
ALTER TABLE "media_jobs" ADD COLUMN "nextAttemptAt" TIMESTAMP(3);
|
||||
|
||||
CREATE UNIQUE INDEX "media_jobs_operationKey_key" ON "media_jobs"("operationKey");
|
||||
CREATE INDEX "media_jobs_status_updatedAt_idx" ON "media_jobs"("status", "updatedAt");
|
||||
@ -1102,14 +1102,27 @@ model MediaJob {
|
||||
progress Float?
|
||||
error String?
|
||||
outputArtifactIds String[] @default([])
|
||||
/// Times a runner claimed the job; with deferrals this bounds crash recovery.
|
||||
attempts Int @default(0)
|
||||
/// Times the job waited for the GPU or its backend instead of running.
|
||||
deferrals Int @default(0)
|
||||
cancelRequested Boolean @default(false)
|
||||
/// Wake the starting bot with the job's summary when it ends.
|
||||
continueAfter Boolean @default(false)
|
||||
/// Tool-call effect key; a replay returns the job created first.
|
||||
operationKey String? @unique
|
||||
/// Fenced runner lease so two processes never run one job.
|
||||
leaseToken String?
|
||||
leaseExpiresAt DateTime?
|
||||
/// When a waiting job may run again.
|
||||
nextAttemptAt DateTime?
|
||||
startedAt DateTime?
|
||||
finishedAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([spaceId, status])
|
||||
@@index([status, updatedAt])
|
||||
@@index([threadId])
|
||||
@@map("media_jobs")
|
||||
}
|
||||
|
||||
361
packages/testkit/src/artifact-routes.test.ts
Normal file
361
packages/testkit/src/artifact-routes.test.ts
Normal file
@ -0,0 +1,361 @@
|
||||
import { mkdtempSync, readdirSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import type { PrismaClient } from "@rakazo/db";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { sessionCookieHeader } from "./index.js";
|
||||
|
||||
type App = { request: (input: string | Request, init?: RequestInit) => Promise<Response> };
|
||||
|
||||
process.env.WAKEUP_DRIVER = "memory";
|
||||
process.env.SANDBOX_PROVIDER = "fake";
|
||||
process.env.AGENT_RUNTIME = "scripted";
|
||||
|
||||
const hasDb = process.env.VERIFY_DATABASE === "1" && Boolean(process.env.DATABASE_URL);
|
||||
const describeRoutes = hasDb ? describe : describe.skip;
|
||||
const ORIGIN = "http://127.0.0.1:5173";
|
||||
|
||||
describeRoutes("artifact HTTP routes", () => {
|
||||
let app: App;
|
||||
let prisma: PrismaClient;
|
||||
let stop: () => Promise<void>;
|
||||
const stamp = Date.now();
|
||||
const dataDir = mkdtempSync(path.join(tmpdir(), "rakazo-artifact-routes-"));
|
||||
const video = Buffer.from("0123456789abcdefghij");
|
||||
|
||||
beforeAll(async () => {
|
||||
const { createApp } = await import("../../../apps/api/src/app.ts");
|
||||
const handles = await createApp({
|
||||
databaseUrl: process.env.DATABASE_URL!,
|
||||
dataDir,
|
||||
sandboxProvider: "fake",
|
||||
agentRuntime: "scripted",
|
||||
});
|
||||
app = handles.app;
|
||||
prisma = handles.prisma;
|
||||
stop = handles.stop;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await stop?.();
|
||||
});
|
||||
|
||||
it("uploads by stream and serves full, ranged, cached and download responses", async () => {
|
||||
const owner = await signup(app, `artifact-route-${stamp}@rakazo.test`, "Route Owner");
|
||||
const bot = await rpc<{ id: string }>(app, owner, "bots/create", botInput("Streamer"));
|
||||
|
||||
const uploaded = await upload(app, owner, { botId: bot.id }, "clip.mp4", "video/mp4", video);
|
||||
expect(uploaded.status).toBe(201);
|
||||
const artifact = (await uploaded.json()) as { id: string; size: number; groupId: null };
|
||||
expect(artifact).toMatchObject({ size: video.length, groupId: null, mimeType: "video/mp4" });
|
||||
const row = await prisma.artifact.findUniqueOrThrow({ where: { id: artifact.id } });
|
||||
expect(row).toMatchObject({ botId: bot.id, groupId: null, runId: null, name: "clip.mp4" });
|
||||
expect(row.hash).toMatch(/^[0-9a-f]{64}$/);
|
||||
|
||||
const full = await get(app, owner, artifact.id);
|
||||
expect(full.status).toBe(200);
|
||||
expect(full.headers.get("content-type")).toBe("video/mp4");
|
||||
expect(full.headers.get("accept-ranges")).toBe("bytes");
|
||||
expect(full.headers.get("content-length")).toBe(String(video.length));
|
||||
expect(full.headers.get("content-disposition")).toMatch(/^inline; filename="clip\.mp4"/);
|
||||
expect(full.headers.get("x-content-type-options")).toBe("nosniff");
|
||||
expect(full.headers.get("content-security-policy")).toContain("media-src 'self' blob:");
|
||||
expect(Buffer.from(await full.arrayBuffer())).toEqual(video);
|
||||
|
||||
const ranged = await get(app, owner, artifact.id, { range: "bytes=2-5" });
|
||||
expect(ranged.status).toBe(206);
|
||||
expect(ranged.headers.get("content-range")).toBe(`bytes 2-5/${video.length}`);
|
||||
expect(ranged.headers.get("content-length")).toBe("4");
|
||||
expect(await ranged.text()).toBe("2345");
|
||||
|
||||
const suffix = await get(app, owner, artifact.id, { range: "bytes=-3" });
|
||||
expect(suffix.status).toBe(206);
|
||||
expect(await suffix.text()).toBe("hij");
|
||||
|
||||
for (const range of [`bytes=${video.length}-`, "bytes=0-1,4-5", "bytes=x-y"]) {
|
||||
const rejected = await get(app, owner, artifact.id, { range });
|
||||
expect(rejected.status, range).toBe(416);
|
||||
expect(rejected.headers.get("content-range")).toBe(`bytes */${video.length}`);
|
||||
}
|
||||
|
||||
const etag = full.headers.get("etag")!;
|
||||
expect(etag).toBe(`"${row.hash}"`);
|
||||
expect((await get(app, owner, artifact.id, { "if-none-match": etag })).status).toBe(304);
|
||||
const staleIfRange = await get(app, owner, artifact.id, {
|
||||
range: "bytes=0-1",
|
||||
"if-range": '"other"',
|
||||
});
|
||||
expect(staleIfRange.status).toBe(200);
|
||||
expect(Buffer.from(await staleIfRange.arrayBuffer())).toEqual(video);
|
||||
|
||||
const head = await app.request(`/api/artifacts/${artifact.id}`, {
|
||||
method: "HEAD",
|
||||
headers: { cookie: owner },
|
||||
});
|
||||
expect(head.status).toBe(200);
|
||||
expect(head.headers.get("content-length")).toBe(String(video.length));
|
||||
|
||||
const download = await app.request(`/api/artifacts/${artifact.id}?download=1`, {
|
||||
headers: { cookie: owner },
|
||||
});
|
||||
expect(download.headers.get("content-disposition")).toMatch(/^attachment; /);
|
||||
await download.body?.cancel();
|
||||
|
||||
// Artifacts created through the inline RPC are served the same way; files download.
|
||||
const notes = await rpc<{ id: string }>(app, owner, "artifacts/create", {
|
||||
botId: bot.id,
|
||||
name: "notes.txt",
|
||||
mimeType: "text/plain",
|
||||
contentBase64: Buffer.from("hello notes").toString("base64"),
|
||||
});
|
||||
const notesResponse = await get(app, owner, notes.id);
|
||||
expect(notesResponse.headers.get("content-disposition")).toMatch(/^attachment; /);
|
||||
expect(await notesResponse.text()).toBe("hello notes");
|
||||
});
|
||||
|
||||
it("denies other users, other spaces, archived threads and signed-out requests", async () => {
|
||||
const owner = await signup(app, `artifact-owner-${stamp}@rakazo.test`, "Artifact Owner");
|
||||
const intruder = await signup(app, `artifact-intruder-${stamp}@rakazo.test`, "Intruder");
|
||||
const bot = await rpc<{ id: string }>(app, owner, "bots/create", botInput("Owner Bot"));
|
||||
const bot2 = await rpc<{ id: string }>(app, owner, "bots/create", botInput("Owner Bot 2"));
|
||||
const group = await rpc<{ id: string }>(app, owner, "groups/create", {
|
||||
name: "Owner Group",
|
||||
botIds: [bot.id, bot2.id],
|
||||
});
|
||||
const botArtifact = (await (
|
||||
await upload(app, owner, { botId: bot.id }, "a.mp3", "audio/mpeg", video)
|
||||
).json()) as { id: string };
|
||||
const groupUpload = await upload(
|
||||
app,
|
||||
owner,
|
||||
{ groupId: group.id },
|
||||
"g.png",
|
||||
"image/png",
|
||||
video,
|
||||
);
|
||||
expect(groupUpload.status).toBe(201);
|
||||
const groupArtifact = (await groupUpload.json()) as { id: string; groupId: string };
|
||||
expect(groupArtifact.groupId).toBe(group.id);
|
||||
expect((await get(app, owner, groupArtifact.id)).status).toBe(200);
|
||||
|
||||
// Another user: reads and uploads into the owner's threads look like missing resources.
|
||||
expect((await get(app, intruder, botArtifact.id)).status).toBe(404);
|
||||
expect((await get(app, intruder, groupArtifact.id)).status).toBe(404);
|
||||
expect(
|
||||
(await upload(app, intruder, { botId: bot.id }, "x.mp4", "video/mp4", video)).status,
|
||||
).toBe(404);
|
||||
expect(
|
||||
(await upload(app, intruder, { groupId: group.id }, "x.mp4", "video/mp4", video)).status,
|
||||
).toBe(404);
|
||||
|
||||
// Signed out.
|
||||
expect((await app.request(`/api/artifacts/${botArtifact.id}`)).status).toBe(401);
|
||||
expect(
|
||||
(
|
||||
await app.request(`/api/artifacts?botId=${bot.id}&name=x.mp4`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "video/mp4" },
|
||||
body: video,
|
||||
})
|
||||
).status,
|
||||
).toBe(401);
|
||||
|
||||
// An explicit space header must name the artifact's space, like the RPC.
|
||||
const other = await rpc<{ id: string }>(app, owner, "spaces/create", { name: "Other space" });
|
||||
expect((await get(app, owner, botArtifact.id, { "x-rakazo-space-id": other.id })).status).toBe(
|
||||
404,
|
||||
);
|
||||
expect(
|
||||
(
|
||||
await upload(app, owner, { botId: bot.id }, "x.mp4", "video/mp4", video, {
|
||||
"x-rakazo-space-id": other.id,
|
||||
})
|
||||
).status,
|
||||
).toBe(404);
|
||||
|
||||
// Archived threads stop serving their artifacts, as artifacts.get does.
|
||||
await rpc(app, owner, "groups/archive", { groupId: group.id });
|
||||
expect((await get(app, owner, groupArtifact.id)).status).toBe(404);
|
||||
await rpc(app, owner, "bots/archive", { botId: bot.id });
|
||||
expect((await get(app, owner, botArtifact.id)).status).toBe(404);
|
||||
});
|
||||
|
||||
it("keeps a shared-space member out of another member's artifacts", async () => {
|
||||
const owner = await signup(app, `artifact-shared-owner-${stamp}@rakazo.test`, "Shared Owner");
|
||||
const member = await signup(app, `artifact-shared-member-${stamp}@rakazo.test`, "Member");
|
||||
const shared = await rpc<{ id: string }>(app, owner, "spaces/create", { name: "Shared" });
|
||||
const sharedRow = await prisma.space.findUniqueOrThrow({
|
||||
where: { id: shared.id },
|
||||
select: { organizationId: true },
|
||||
});
|
||||
const memberActor = await rpc<{ userId: string }>(app, member, "me");
|
||||
await prisma.member.deleteMany({ where: { userId: memberActor.userId } });
|
||||
await prisma.member.create({
|
||||
data: {
|
||||
id: `artifact-shared-org-${stamp}`,
|
||||
organizationId: sharedRow.organizationId,
|
||||
userId: memberActor.userId,
|
||||
role: "member",
|
||||
createdAt: new Date(),
|
||||
},
|
||||
});
|
||||
await prisma.spaceMember.create({
|
||||
data: {
|
||||
id: `artifact-shared-member-${stamp}`,
|
||||
spaceId: shared.id,
|
||||
organizationId: sharedRow.organizationId,
|
||||
userId: memberActor.userId,
|
||||
role: "member",
|
||||
createdAt: new Date(),
|
||||
},
|
||||
});
|
||||
const ownerBot = await rpc<{ id: string }>(
|
||||
app,
|
||||
owner,
|
||||
"bots/create",
|
||||
botInput("Shared Owner Bot"),
|
||||
shared.id,
|
||||
);
|
||||
const artifact = (await (
|
||||
await upload(app, owner, { botId: ownerBot.id }, "s.webm", "video/webm", video, {
|
||||
"x-rakazo-space-id": shared.id,
|
||||
})
|
||||
).json()) as { id: string };
|
||||
expect((await get(app, owner, artifact.id)).status).toBe(200);
|
||||
expect((await get(app, member, artifact.id)).status).toBe(404);
|
||||
expect((await get(app, member, artifact.id, { "x-rakazo-space-id": shared.id })).status).toBe(
|
||||
404,
|
||||
);
|
||||
expect(
|
||||
(
|
||||
await upload(app, member, { botId: ownerBot.id }, "x.mp4", "video/mp4", video, {
|
||||
"x-rakazo-space-id": shared.id,
|
||||
})
|
||||
).status,
|
||||
).toBe(404);
|
||||
});
|
||||
|
||||
it("enforces type and size limits on uploads, including chunked bodies", async () => {
|
||||
const owner = await signup(app, `artifact-limits-${stamp}@rakazo.test`, "Limits");
|
||||
const bot = await rpc<{ id: string }>(app, owner, "bots/create", botInput("Limits Bot"));
|
||||
const before = await prisma.artifact.count({ where: { botId: bot.id } });
|
||||
|
||||
expect((await upload(app, owner, { botId: bot.id }, "a.html", "text/html", video)).status).toBe(
|
||||
415,
|
||||
);
|
||||
expect((await upload(app, owner, { botId: bot.id }, "", "video/mp4", video)).status).toBe(400);
|
||||
expect(
|
||||
(await upload(app, owner, { botId: bot.id }, "e.mp4", "video/mp4", Buffer.alloc(0))).status,
|
||||
).toBe(400);
|
||||
const declared = await app.request(
|
||||
new Request(`${ORIGIN}/api/artifacts?botId=${bot.id}&name=big.mp4`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
cookie: owner,
|
||||
"content-type": "video/mp4",
|
||||
"content-length": String(500 * 1024 * 1024 + 1),
|
||||
},
|
||||
body: new ReadableStream({ start: (controller) => controller.close() }),
|
||||
duplex: "half",
|
||||
} as RequestInit & { duplex: "half" }),
|
||||
);
|
||||
expect(declared.status).toBe(413);
|
||||
|
||||
// No Content-Length: the 25 MiB file cap is enforced while streaming to disk.
|
||||
const mib = new Uint8Array(1024 * 1024);
|
||||
let sent = 0;
|
||||
const chunked = await app.request(
|
||||
new Request(`${ORIGIN}/api/artifacts?botId=${bot.id}&name=big.txt`, {
|
||||
method: "POST",
|
||||
headers: { cookie: owner, "content-type": "text/plain" },
|
||||
body: new ReadableStream({
|
||||
pull(controller) {
|
||||
if (sent++ > 26) controller.close();
|
||||
else controller.enqueue(mib);
|
||||
},
|
||||
}),
|
||||
duplex: "half",
|
||||
} as RequestInit & { duplex: "half" }),
|
||||
);
|
||||
expect(chunked.status).toBe(413);
|
||||
|
||||
expect(await prisma.artifact.count({ where: { botId: bot.id } })).toBe(before);
|
||||
const me = await rpc<{ spaceId: string }>(app, owner, "me");
|
||||
const spaceDir = path.join(dataDir, "artifacts", me.spaceId);
|
||||
expect(readdirSafe(spaceDir)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
function readdirSafe(dir: string) {
|
||||
try {
|
||||
return readdirSync(dir);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function botInput(name: string) {
|
||||
return {
|
||||
name,
|
||||
title: name,
|
||||
description: "artifact routes",
|
||||
instructions: "",
|
||||
notifyOnFinish: true,
|
||||
};
|
||||
}
|
||||
|
||||
async function upload(
|
||||
app: App,
|
||||
cookie: string,
|
||||
target: { botId: string } | { groupId: string },
|
||||
name: string,
|
||||
mimeType: string,
|
||||
bytes: Buffer,
|
||||
headers: Record<string, string> = {},
|
||||
) {
|
||||
const query = new URLSearchParams({ ...target, name });
|
||||
return app.request(`/api/artifacts?${query}`, {
|
||||
method: "POST",
|
||||
headers: { cookie, origin: ORIGIN, "content-type": mimeType, ...headers },
|
||||
body: bytes,
|
||||
});
|
||||
}
|
||||
|
||||
function get(app: App, cookie: string, id: string, headers: Record<string, string> = {}) {
|
||||
return app.request(`/api/artifacts/${id}`, { headers: { cookie, ...headers } });
|
||||
}
|
||||
|
||||
async function signup(app: App, email: string, name: string) {
|
||||
const response = await app.request("/api/auth/sign-up/email", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json", origin: ORIGIN },
|
||||
body: JSON.stringify({ email, password: "test-password-123", name }),
|
||||
});
|
||||
expect(response.status).toBeLessThan(400);
|
||||
return sessionCookieHeader(response);
|
||||
}
|
||||
|
||||
async function rpc<T>(
|
||||
app: App,
|
||||
cookie: string,
|
||||
procedure: string,
|
||||
body: unknown = {},
|
||||
spaceId?: string,
|
||||
): Promise<T> {
|
||||
const response = await app.request(`/rpc/${procedure}`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
cookie,
|
||||
origin: ORIGIN,
|
||||
...(spaceId ? { "x-rakazo-space-id": spaceId } : {}),
|
||||
},
|
||||
body: JSON.stringify({ json: body }),
|
||||
});
|
||||
const text = await response.text();
|
||||
const parsed = JSON.parse(text) as { json?: T; error?: { message?: string } };
|
||||
if (response.status >= 400 || parsed.error) {
|
||||
throw new Error(`${procedure} ${response.status}: ${parsed.error?.message ?? text}`);
|
||||
}
|
||||
return parsed.json as T;
|
||||
}
|
||||
@ -98,6 +98,7 @@ async function main() {
|
||||
"packages/testkit/src/journeys.test.ts",
|
||||
"packages/testkit/src/authorization.test.ts",
|
||||
"packages/testkit/src/attachments.test.ts",
|
||||
"packages/testkit/src/artifact-routes.test.ts",
|
||||
"packages/testkit/src/voice.test.ts",
|
||||
"packages/testkit/src/search.test.ts",
|
||||
"packages/testkit/src/inbox.test.ts",
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user