The image E2E now runs in the main conversation, which is a group thread, so the browser test covers group uploads and reads from the artifact route as well as bot threads. Bot file attachments keep their run's operation id for tracing, and the non-streaming attach cap is the inline cap directly. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
119 lines
3.7 KiB
TypeScript
119 lines
3.7 KiB
TypeScript
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;
|
|
operationId?: string;
|
|
},
|
|
) {
|
|
validateAttachmentMimeType(input.mimeType);
|
|
const maxBytes = Math.min(
|
|
input.maxBytes ?? Number.POSITIVE_INFINITY,
|
|
artifactMaxBytes(input.mimeType),
|
|
);
|
|
const operationId =
|
|
input.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();
|
|
}
|
|
}
|