import { WorkspaceArtifactHtml, WorkspaceArtifactRequestedTools, WorkspaceArtifactSourceBundle, type ToolGatewayIdentity, type WorkspaceArtifactSourceBundle as WorkspaceArtifactSourceBundleValue, type WorkspaceArtifactVersion, } from "@opengeni/contracts"; import type { ObjectStorageDependency } from "@opengeni/core"; import { retryWhileMissing } from "@opengeni/storage"; const encoder = new TextEncoder(); const decoder = new TextDecoder("utf-8", { fatal: true }); export type PreparedWorkspaceArtifactContent = { contentKey: string; contentSha256: null; sizeBytes: number; sourceKey: string | null; sourceSha256: null; sourceSizeBytes: number | null; requestedTools?: ToolGatewayIdentity[]; persistContent: () => Promise; discardContent: () => Promise; }; export function prepareWorkspaceArtifactContent( objectStorage: NonNullable, workspaceId: string, input: { html: string; source?: WorkspaceArtifactSourceBundleValue; requestedTools?: ToolGatewayIdentity[]; }, ): PreparedWorkspaceArtifactContent { const html = WorkspaceArtifactHtml.parse(input.html); const source = input.source ? WorkspaceArtifactSourceBundle.parse(input.source) : null; const requestedTools = input.requestedTools === undefined ? undefined : WorkspaceArtifactRequestedTools.parse(input.requestedTools); const contentBytes = encoder.encode(html); const sourceBytes = source ? encoder.encode(JSON.stringify(source)) : null; const contentSha256 = null; const sourceSha256 = null; const storageGroupId = crypto.randomUUID(); const contentKey = `workspaces/${workspaceId}/workspace-artifacts/blobs/${storageGroupId}.html`; const sourceKey = `workspaces/${workspaceId}/workspace-artifacts/sources/${storageGroupId}.json`; const discardContent = async (): Promise => { await Promise.allSettled([ objectStorage.deleteObject(contentKey), ...(source ? [objectStorage.deleteObject(sourceKey)] : []), ]); }; return { contentKey, contentSha256, sizeBytes: contentBytes.byteLength, sourceKey: source ? sourceKey : null, sourceSha256, sourceSizeBytes: sourceBytes?.byteLength ?? null, ...(requestedTools === undefined ? {} : { requestedTools }), discardContent, persistContent: async () => { const writes = await Promise.allSettled([ objectStorage.putObject({ key: contentKey, contentType: "text/html; charset=utf-8", body: contentBytes, }), sourceBytes ? objectStorage.putObject({ key: sourceKey, contentType: "application/json; charset=utf-8", body: sourceBytes, }) : Promise.resolve(), ]); const failed = writes.find((write) => write.status === "rejected"); if (!failed) return; await Promise.allSettled( writes.flatMap((write, index) => write.status === "fulfilled" ? [objectStorage.deleteObject(index === 0 ? contentKey : sourceKey)] : [], ), ); throw failed.reason; }, }; } export async function readWorkspaceArtifactContent( objectStorage: NonNullable, input: { contentKey: string; sourceKey: string | null; version: WorkspaceArtifactVersion; }, ): Promise<{ html: string; source: WorkspaceArtifactSourceBundleValue; requestedTools: ToolGatewayIdentity[]; }> { const [contentObject, sourceObject] = await Promise.all([ retryWhileMissing(async () => await objectStorage.getObjectBytes(input.contentKey)), input.sourceKey ? retryWhileMissing(async () => await objectStorage.getObjectBytes(input.sourceKey!)) : Promise.resolve(null), ]); if (!contentObject) throw new Error("Artifact content is unavailable"); const html = decode(contentObject.bytes, "Artifact content is not valid UTF-8"); let source = sourceBundleFromHtml(html); if (input.sourceKey) { if (!sourceObject) throw new Error("Artifact source is unavailable"); try { source = WorkspaceArtifactSourceBundle.parse( JSON.parse(decode(sourceObject.bytes, "Artifact source is not valid UTF-8")), ); } catch (error) { if (error instanceof Error && error.message === "Artifact source is not valid UTF-8") { throw error; } throw new Error("Artifact source is invalid", { cause: error }); } } return { html, source, requestedTools: WorkspaceArtifactRequestedTools.parse(input.version.requestedTools), }; } export function sourceBundleFromHtml(html: string): WorkspaceArtifactSourceBundleValue { return { entrypoint: "index.html", files: [{ path: "index.html", content: html }] }; } function decode(bytes: Uint8Array, message: string): string { try { return decoder.decode(bytes); } catch (error) { throw new Error(message, { cause: error }); } }