Phase2.5: Skill SetUp Process
All checks were successful
Build and Push / build (push) Successful in 1m39s
All checks were successful
Build and Push / build (push) Successful in 1m39s
This commit is contained in:
@@ -3,7 +3,12 @@ import { getSessionUser, canMutate } from "@/lib/session";
|
||||
import { canUserSeeTenant } from "@/lib/visibility";
|
||||
import { getTenant, patchTenantSpec } from "@/lib/k8s";
|
||||
import { getPackageDef } from "@/lib/packages";
|
||||
import { recordSkillEvents } from "@/lib/db";
|
||||
import {
|
||||
createSkillActivationRequest,
|
||||
getOrgBilling,
|
||||
recordSkillEvents,
|
||||
} from "@/lib/db";
|
||||
import { sendSkillActivationAdminNotification } from "@/lib/email";
|
||||
import { safeError } from "@/lib/errors";
|
||||
|
||||
const ALLOWED_WORKSPACE_FILES = ["SOUL.md", "AGENTS.md", "TOOLS.md"];
|
||||
@@ -69,6 +74,17 @@ export async function PATCH(
|
||||
|
||||
const specPatch: Record<string, any> = {};
|
||||
|
||||
// Track manual-setup gate activations created during this PATCH.
|
||||
// We push to the K8s spec only the non-gated skills; the gated
|
||||
// ones live in skill_activation_requests until admin approves
|
||||
// and adds them via the admin endpoint. Platform admins bypass
|
||||
// the gate (direct enable from /admin still applies immediately).
|
||||
let gatedRequests: Array<{
|
||||
skillId: string;
|
||||
requestId: string;
|
||||
skillName: string;
|
||||
}> = [];
|
||||
|
||||
// ── Validate packages against catalog ──
|
||||
if (body.packages !== undefined) {
|
||||
if (!Array.isArray(body.packages) || body.packages.length > 10) {
|
||||
@@ -85,7 +101,63 @@ export async function PATCH(
|
||||
);
|
||||
}
|
||||
}
|
||||
specPatch.packages = body.packages;
|
||||
// Compute the to-be-added set against the existing spec.
|
||||
const existingPackages = new Set<string>(existing.spec.packages ?? []);
|
||||
const desiredPackages: string[] = body.packages;
|
||||
const newlyAdded = desiredPackages.filter(
|
||||
(p) => !existingPackages.has(p)
|
||||
);
|
||||
// Manual-setup gate. Customer adds get routed to the queue;
|
||||
// platform admins go straight through.
|
||||
if (!user.isPlatform && newlyAdded.length > 0) {
|
||||
const orgIdForGate =
|
||||
existing.metadata.labels?.["pieced.ch/zitadel-org-id"];
|
||||
if (!orgIdForGate) {
|
||||
// Defensive: every customer-visible tenant should have the
|
||||
// org label. Without it we can't attribute the request.
|
||||
return NextResponse.json(
|
||||
{ error: "Tenant missing org binding; contact support." },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
const gatedSet = new Set<string>();
|
||||
for (const skillId of newlyAdded) {
|
||||
const def = getPackageDef(skillId);
|
||||
if (!def?.requiresManualSetup) continue;
|
||||
gatedSet.add(skillId);
|
||||
try {
|
||||
const req = await createSkillActivationRequest({
|
||||
tenantName: name,
|
||||
zitadelOrgId: orgIdForGate,
|
||||
zitadelUserId: user.id,
|
||||
skillId,
|
||||
});
|
||||
gatedRequests.push({
|
||||
skillId,
|
||||
requestId: req.id,
|
||||
skillName: def.name,
|
||||
});
|
||||
} catch (e: any) {
|
||||
if (e?.code === "REQUEST_ALREADY_PENDING") {
|
||||
// Idempotent: a pending row already exists; just keep
|
||||
// the skill out of the K8s spec and surface it as
|
||||
// gated without creating a duplicate.
|
||||
gatedRequests.push({
|
||||
skillId,
|
||||
requestId: "",
|
||||
skillName: def.name,
|
||||
});
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Strip gated skills from the desired spec — they must not
|
||||
// reach K8s until approved.
|
||||
specPatch.packages = desiredPackages.filter((p) => !gatedSet.has(p));
|
||||
} else {
|
||||
specPatch.packages = desiredPackages;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Validate workspaceFiles ──
|
||||
@@ -232,7 +304,49 @@ export async function PATCH(
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json(updated);
|
||||
// Phase 2.5: notify admin of newly created activation requests.
|
||||
// Best-effort — email failure must not poison the PATCH response.
|
||||
// requestId === "" means an existing-pending row was reused, so
|
||||
// skip the email in that case (admin already knows).
|
||||
if (gatedRequests.length > 0) {
|
||||
const orgIdForEmail =
|
||||
existing.metadata.labels?.["pieced.ch/zitadel-org-id"] ?? null;
|
||||
const companyName = orgIdForEmail
|
||||
? await getOrgBilling(orgIdForEmail)
|
||||
.then((b) => b?.companyName ?? null)
|
||||
.catch(() => null)
|
||||
: null;
|
||||
for (const g of gatedRequests) {
|
||||
if (!g.requestId) continue;
|
||||
try {
|
||||
await sendSkillActivationAdminNotification({
|
||||
tenantName: name,
|
||||
skillId: g.skillId,
|
||||
skillName: g.skillName,
|
||||
requesterEmail: user.email,
|
||||
requesterName: user.name,
|
||||
companyName,
|
||||
});
|
||||
} catch (e) {
|
||||
console.error(
|
||||
`Failed to send admin notification for skill activation request:`,
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
...updated,
|
||||
// Phase 2.5: tells the client which requested-to-enable skills
|
||||
// didn't actually land in the spec because they're awaiting
|
||||
// admin approval. UI uses this to render the "pending review"
|
||||
// state on those skill cards.
|
||||
pendingActivationRequests: gatedRequests.map((g) => ({
|
||||
skillId: g.skillId,
|
||||
skillName: g.skillName,
|
||||
})),
|
||||
});
|
||||
} catch (e: any) {
|
||||
return NextResponse.json(
|
||||
{ error: safeError(e, "Failed to update tenant") },
|
||||
|
||||
Reference in New Issue
Block a user