diff --git a/apps/web/package.json b/apps/web/package.json index 6cb2667..dad1978 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -25,6 +25,7 @@ "@vitejs/plugin-react": "^6.0.4", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "fflate": "^0.8.3", "i18next": "^26.3.4", "i18next-browser-languagedetector": "^8.2.1", "lucide-react": "^1.22.0", diff --git a/apps/web/src/pages/admin/capabilities/AddCapabilityVersionDialog.tsx b/apps/web/src/pages/admin/capabilities/AddCapabilityVersionDialog.tsx index add8e37..31b0529 100644 --- a/apps/web/src/pages/admin/capabilities/AddCapabilityVersionDialog.tsx +++ b/apps/web/src/pages/admin/capabilities/AddCapabilityVersionDialog.tsx @@ -8,13 +8,13 @@ * - When the previous version was imported, prefill rawText + format so the * user can tweak. inline_secret plaintexts CANNOT carry forward (server * only stores ciphertext). - * - Plugin / skill-zip kinds: if the user doesn't upload a new zip, the - * server reuses the previous version's OSS bytes (commit handler treats - * missing oss_key as "reuse latest"). UI shows the existing filename. + * - Plugin kinds can reuse the previous OSS bytes. Stored Skill zips are + * downloaded into the browser editor and uploaded again on save. * - Commits to .../capabilities/{id}/versions/import/commit (after an * optional PATCH for name/description). */ import { useEffect, useMemo, useState } from "react" +import { strFromU8, strToU8, unzipSync, zipSync } from "fflate" import { Loader2 } from "lucide-react" import { useTranslation } from "react-i18next" @@ -32,10 +32,16 @@ import { ApiError } from "../../../lib/api-client" import { useUpdateCapability } from "../../../lib/api-capabilities" import type { Capability, CapabilityVersion } from "../../../lib/api-types" -import { useImportCapabilityVersionMutation } from "./api" +import { + downloadStoredZip, + putToPresignedURL, + useImportCapabilityVersionMutation, + usePresignUploadMutation, +} from "./api" import { ImportMCPForm } from "./ImportMCPForm" import { ImportSkillForm } from "./ImportSkillForm" import { ImportPluginForm, type PluginUploadState } from "./ImportPluginForm" +import { SkillFileTree, type SkillFileTreeEntry } from "./SkillFileTree" import { isImportSpecReady } from "./importValidation" import type { CanonicalKind, @@ -56,6 +62,19 @@ interface Props { onCommitted: () => void } +const SKILL_ZIP_MAX_BYTES = 8 * 1024 * 1024 + +interface SkillArchiveEntry { + archivePath: string + path: string + bytes: Uint8Array + text: string | null + dirty: boolean + directory: boolean + hidden: boolean + kind: SkillFileTreeEntry["kind"] +} + export function AddCapabilityVersionDialog({ workspaceID, capability, @@ -67,6 +86,7 @@ export function AddCapabilityVersionDialog({ const { t } = useTranslation("admin") const commitMut = useImportCapabilityVersionMutation(workspaceID, capability.id) const updateMut = useUpdateCapability(workspaceID) + const presignMut = usePresignUploadMutation(workspaceID) const kind = capability.type as CanonicalKind @@ -87,8 +107,30 @@ export function AddCapabilityVersionDialog({ }) /** Skill zip ossKey from ImportSkillForm; null in paste mode. */ const [skillOssKey, setSkillOssKey] = useState(null) + const [skillArchive, setSkillArchive] = useState(null) + const [skillArchiveStatus, setSkillArchiveStatus] = useState< + "idle" | "loading" | "ready" | "error" + >("idle") + const [skillArchiveError, setSkillArchiveError] = useState(null) + const [skillLoadAttempt, setSkillLoadAttempt] = useState(0) + const [submitError, setSubmitError] = useState(null) + const [isSaving, setIsSaving] = useState(false) const prefill = usePrefillFromLatest(latestVersion) + const latestSpec = latestVersion?.canonical_spec as CanonicalSpec | undefined + const editsStoredSkillZip = kind === "skill" && !!latestVersion?.oss_key?.trim() + const skillTreeFiles = useMemo( + () => + (skillArchive ?? []) + .filter((file) => !file.directory && !file.hidden) + .map((file) => ({ + path: file.path, + content: file.text, + kind: file.kind, + size: file.bytes.byteLength, + })), + [skillArchive], + ) // For plugin / skill-zip rounds where the user keeps the previous OSS blob, // we display the existing filename (derived from the latest version's oss_key) // so the form feels like "edit", not "blank slate". @@ -111,29 +153,57 @@ export function AddCapabilityVersionDialog({ setSourceFormat(prefill.format) setPluginUpload({ ossKey: null, uploadSource: null, validation: null }) setSkillOssKey(null) + setSkillArchive(null) + setSkillArchiveStatus(editsStoredSkillZip ? "loading" : "idle") + setSkillArchiveError(null) + setSubmitError(null) + setIsSaving(false) commitMut.reset() updateMut.reset() + presignMut.reset() // intentionally only on the open transition // eslint-disable-next-line react-hooks/exhaustive-deps }, [open]) - const errMsg = commitMut.error instanceof ApiError - ? commitMut.error.envelope.message - : commitMut.error instanceof Error - ? commitMut.error.message - : updateMut.error instanceof ApiError - ? updateMut.error.envelope.message - : updateMut.error instanceof Error - ? updateMut.error.message - : null + useEffect(() => { + if (!open || !editsStoredSkillZip || !workspaceID || !latestVersion?.oss_key) return + let cancelled = false + void downloadStoredZip(workspaceID, latestVersion.oss_key) + .then((bytes) => unpackSkillArchive(bytes)) + .then((archive) => { + if (cancelled) return + setSkillArchive(archive) + setSkillArchiveStatus("ready") + }) + .catch((error: unknown) => { + if (cancelled) return + setSkillArchive(null) + setSkillArchiveStatus("error") + setSkillArchiveError(formatError(error)) + }) + return () => { + cancelled = true + } + }, [editsStoredSkillZip, latestVersion?.oss_key, open, skillLoadAttempt, workspaceID]) + + const errMsg = + submitError ?? + (commitMut.error instanceof ApiError + ? commitMut.error.envelope.message + : commitMut.error instanceof Error + ? commitMut.error.message + : updateMut.error instanceof ApiError + ? updateMut.error.envelope.message + : updateMut.error instanceof Error + ? updateMut.error.message + : null) const trimmedName = name.trim() - const nameError = - !trimmedName - ? t("capabilities.errors.nameRequired") - : trimmedName.length > 50 - ? t("capabilities.errors.nameTooLong") - : null + const nameError = !trimmedName + ? t("capabilities.errors.nameRequired") + : trimmedName.length > 50 + ? t("capabilities.errors.nameTooLong") + : null // For plugin / skill-zip kinds we accept "no new upload" and let the server // reuse the previous OSS blob. So the canSubmit guard relaxes when an // inherited blob exists. @@ -141,19 +211,27 @@ export function AddCapabilityVersionDialog({ kind !== "plugin" ? true : pluginUpload.ossKey - ? pluginUpload.validation?.valid ?? false + ? (pluginUpload.validation?.valid ?? false) : !!inheritedOssLabel const skillSpecReady = kind !== "skill" ? true - : !!skillOssKey || !!inheritedOssLabel || (!!spec && isImportSpecReady(kind, spec, inlineSecrets)) + : editsStoredSkillZip + ? skillArchiveStatus === "ready" && + skillTreeFiles.some((file) => file.path.toLowerCase() === "skill.md") + : !!skillOssKey || + !!inheritedOssLabel || + (!!spec && isImportSpecReady(kind, spec, inlineSecrets)) - const mcpSpecReady = kind !== "mcp" ? true : !!spec && isImportSpecReady(kind, spec, inlineSecrets) + const mcpSpecReady = + kind !== "mcp" ? true : !!spec && isImportSpecReady(kind, spec, inlineSecrets) const canSubmit = !commitMut.isPending && !updateMut.isPending && + !presignMut.isPending && + !isSaving && !!workspaceID && !nameError && pluginHasUsableArtifact && @@ -162,11 +240,30 @@ export function AddCapabilityVersionDialog({ const submit = async () => { if (!canSubmit) return - // PATCH name/description only when they actually changed. - const nextDesc = description.trim() - const nameChanged = trimmedName !== capability.name - const descChanged = nextDesc !== (capability.description ?? "").trim() + setSubmitError(null) + setIsSaving(true) try { + let editedSkillOssKey: string | undefined + if (editsStoredSkillZip) { + if (!workspaceID || !skillArchive) throw new Error("Skill archive is not ready") + const zipFile = buildSkillZipFile( + skillArchive, + `${safeZipBase(latestSpec?.skill?.slug ?? capability.name)}.zip`, + ) + if (zipFile.size > SKILL_ZIP_MAX_BYTES) { + throw new Error("Edited Skill zip exceeds the 8 MiB upload limit") + } + const presign = await presignMut.mutateAsync({ + filename: zipFile.name, + prefix: "skill", + }) + await putToPresignedURL(presign, zipFile) + editedSkillOssKey = presign.ossKey + } + + const nextDesc = description.trim() + const nameChanged = trimmedName !== capability.name + const descChanged = nextDesc !== (capability.description ?? "").trim() if (nameChanged || descChanged) { await updateMut.mutateAsync({ capabilityID: capability.id, @@ -176,52 +273,53 @@ export function AddCapabilityVersionDialog({ }, }) } - } catch { - // updateMut.error surfaces via errMsg; abort the version commit. - return - } - // For plugin / skill-zip without a new upload, the server falls back to - // the previous version's oss_key. We still need a canonical_spec on the - // wire (kind at minimum) so the backend's spec.Kind check passes. - const fallbackSpec: CanonicalSpec | null = spec - ? spec - : kind === "plugin" - ? ({ kind: "plugin" } as unknown as CanonicalSpec) - : kind === "skill" - ? ({ kind: "skill" } as unknown as CanonicalSpec) - : null - if (!fallbackSpec) return - - const ossKeyToSend = - kind === "plugin" - ? pluginUpload.ossKey ?? undefined - : kind === "skill" - ? skillOssKey ?? undefined - : undefined - const uploadSourceToSend = - kind === "plugin" - ? pluginUpload.uploadSource ?? undefined - : kind === "skill" && skillOssKey - ? "zip" - : undefined - - const payload: ImportCapabilityVersionCommitRequest = { - canonical_spec: fallbackSpec, - inline_secrets: kind === "plugin" || inlineSecrets.length === 0 ? undefined : inlineSecrets, - source_payload: rawText - ? { raw_text: rawText, source_format: sourceFormat } - : undefined, - // omit oss_key on plugin/skill-zip reuse — backend treats missing key - // as "carry forward the previous version's blob". - oss_key: ossKeyToSend, - upload_source: uploadSourceToSend, + + // The backend reparses Skill ZIP bytes and remains the canonical source. + const fallbackSpec: CanonicalSpec | null = + spec ?? + (editsStoredSkillZip ? (latestSpec ?? null) : null) ?? + (kind === "plugin" + ? ({ kind: "plugin" } as unknown as CanonicalSpec) + : kind === "skill" + ? ({ kind: "skill" } as unknown as CanonicalSpec) + : null) + if (!fallbackSpec) throw new Error("Capability content is not ready") + + const ossKeyToSend = + editedSkillOssKey ?? + (kind === "plugin" + ? (pluginUpload.ossKey ?? undefined) + : kind === "skill" + ? (skillOssKey ?? undefined) + : undefined) + const uploadSourceToSend = editsStoredSkillZip + ? "zip" + : kind === "plugin" + ? (pluginUpload.uploadSource ?? undefined) + : kind === "skill" && skillOssKey + ? "zip" + : undefined + + const payload: ImportCapabilityVersionCommitRequest = { + canonical_spec: fallbackSpec, + inline_secrets: kind === "plugin" || inlineSecrets.length === 0 ? undefined : inlineSecrets, + source_payload: + editsStoredSkillZip && latestVersion + ? editedSourcePayload(latestVersion) + : rawText + ? { raw_text: rawText, source_format: sourceFormat } + : undefined, + oss_key: ossKeyToSend, + upload_source: uploadSourceToSend, + } + await commitMut.mutateAsync(payload) + onOpenChange(false) + onCommitted() + } catch (error) { + setSubmitError(formatError(error)) + } finally { + setIsSaving(false) } - commitMut.mutate(payload, { - onSuccess: () => { - onOpenChange(false) - onCommitted() - }, - }) } // Inherited inline_secret env entries (server-allocated secret_id from @@ -236,41 +334,49 @@ export function AddCapabilityVersionDialog({ { - if (commitMut.isPending || updateMut.isPending) e.preventDefault() + if (isSaving || commitMut.isPending || updateMut.isPending) e.preventDefault() }} > {t("capabilities.versions.add.title", { name: capability.name })} - - {t("capabilities.versions.add.description")} - + {t("capabilities.versions.add.description")} {prefill.didPrefill && ( {t("capabilities.versions.add.prefillFromLatest", { version: latestVersion?.version ?? "", - defaultValue: "Pre-filled with the previous version ({{version}}). Edits will be submitted as a new version.", + defaultValue: + "Pre-filled with the previous version ({{version}}). Edits will be submitted as a new version.", + })} + + )} + {editsStoredSkillZip && inheritedOssLabel && ( + + {t("capabilities.versions.add.editStoredZip", { + filename: inheritedOssLabel, + defaultValue: + "Editing {{filename}}. Saving uploads the complete folder as a new version.", })} )} - {inheritedOssLabel && (kind === "plugin" || kind === "skill") && ( + {!editsStoredSkillZip && inheritedOssLabel && (kind === "plugin" || kind === "skill") && ( {t("capabilities.versions.add.reuseExistingZip", { filename: inheritedOssLabel, - defaultValue: "Current version package: {{filename}}. If you do not re-upload, the new version will reuse this package.", + defaultValue: + "Current version package: {{filename}}. If you do not re-upload, the new version will reuse this package.", })} )} {inheritedInlineSecrets.length > 0 && ( {t("capabilities.versions.add.inlineSecretLostWarning", { - keys: inheritedInlineSecrets - .map((e) => `${e.server}.${e.envKey}`) - .join(", "), - defaultValue: "Previous-version inline secrets ({{keys}}) are hidden. Re-enter them in plaintext to keep, or switch to managed credentials.", + keys: inheritedInlineSecrets.map((e) => `${e.server}.${e.envKey}`).join(", "), + defaultValue: + "Previous-version inline secrets ({{keys}}) are hidden. Re-enter them in plaintext to keep, or switch to managed credentials.", })} )} @@ -319,6 +425,46 @@ export function AddCapabilityVersionDialog({ initialRawText={prefill.rawText} initialFormat={prefill.format} /> + ) : kind === "skill" && editsStoredSkillZip ? ( +
+ {skillArchiveStatus === "loading" && ( +
+ + {t("capabilities.versions.add.loadingZip", "Loading the current Skill package…")} +
+ )} + {skillArchiveStatus === "error" && ( +
+

{skillArchiveError}

+ +
+ )} + {skillArchiveStatus === "ready" && ( + { + setSkillArchive( + (current) => + current?.map((file) => + file.path === path ? { ...file, text: content, dirty: true } : file, + ) ?? null, + ) + }} + /> + )} +
) : kind === "skill" ? ( onOpenChange(false)} > {t("capabilities.actions.cancel")}