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:
Claude 2026-10-03 13:35:46 +00:00
commit d8c324a7af
No known key found for this signature in database
36 changed files with 3038 additions and 30 deletions

View File

@ -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;

View 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();
});
});

View 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);
});
}

View File

@ -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));

View 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.`);
}

View File

@ -33,6 +33,7 @@ const runTriggers = new Set<Run["trigger"]>([
"messaging",
"cloud_agent",
"delegation",
"media",
]);
function runFromStartedEvent(event: ProductEvent, previous: Run | undefined): Run {

View File

@ -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();

View File

@ -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"));
});
});

View File

@ -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 } : {}),
};
}

View File

@ -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 {

View File

@ -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 {

View File

@ -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;

View 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([]);
});
});

View 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();
}
}

View File

@ -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();
});
});

View File

@ -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 {

View File

@ -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(
{

View File

@ -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;
}

View File

@ -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";

View File

@ -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) {

View 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 [];
}

File diff suppressed because it is too large Load Diff

View 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);
}

View 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;
}
}

View File

@ -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),
};
}

View File

@ -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 });

View File

@ -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();

View File

@ -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;

View File

@ -922,6 +922,7 @@ export const RunSchema = z.object({
"messaging",
"cloud_agent",
"delegation",
"media",
]),
routineId: Id.nullable(),
modelProvider: z.string().nullable(),

View File

@ -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";

View 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>;

View File

@ -22,6 +22,7 @@ export const RunActivityRowSchema = z.object({
"messaging",
"cloud_agent",
"delegation",
"media",
]),
notificationsEnabled: z.boolean(),
promptSnippet: z.string(),

View File

@ -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");

View File

@ -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")
}

View 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;
}

View File

@ -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",