evOo-local/packages/adapters/src/artifact-files.ts
cynarAI 797906dde2
Allow repository ZIP uploads up to 5 GiB (#194)
* Allow ZIP uploads up to 5 GiB and stream them onto Docker computers

Repository archives could not be picked at all: application/zip was not an
allowed attachment type. ZIPs are now allowed with their own 5 GiB cap,
upload as a stream, and reach the bot's Docker computer through a new
streamed supervisor write that commits only after the body ended cleanly.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tgvrj34NMnaqvtgvruYQZZ

* Use a disallowed type for the invalid-attachment check now that ZIP is allowed

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tgvrj34NMnaqvtgvruYQZZ

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-10 12:35:32 +02:00

114 lines
3.5 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 { artifactMaxBytes } 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;
};
/**
* 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();
}
}