mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-17 03:38:32 +02:00
* feat(server): funnel/community/commercial business metrics + PostHog pairing (MUL-2949) PR3 of the Grafana board metrics split (parent MUL-2328). Adds 23 new Prometheus counter/histogram families to the PR2 BusinessMetrics collector covering the activation/community/commercial funnels, and binds every PostHog event emission to a matching metric increment so the two sides cannot drift. Funnel: signup, workspace_created, team_invite_sent/accepted, onboarding_*, cloud_waitlist_joined. Content: issue_created, chat_message_sent, agent_created, squad_created, autopilot_created, issue_executed. Runtime: runtime_registered/ready/failed/offline + ready_seconds histogram, daemon_ws_message_received_total. Autopilot: autopilot_run_started/terminal/skipped. Webhook/GitHub: webhook_delivery_total, github_event_received_total, github_pr_review_total, github_pr_merge_seconds histogram. CloudRuntime: cloudruntime_request_total + duration histogram, wired through a small RequestRecorder interface so the cloudruntime package stays decoupled from metrics. Commercial: feedback_submitted, contact_sales_submitted. The pairing helper metrics.RecordEvent(client, m, ev) emits the PostHog event AND increments the matching counter via IncForEvent dispatch, reading labels from the analytics event Properties. Every existing h.Analytics.Capture(analytics.X(...)) call site has been migrated to the helper across handler/, service/, and cmd/server/runtime_sweeper.go. Lint enforcement (server/internal/metrics/business_pairing_test.go): - TestEveryAnalyticsEventHasPrometheusCounter: every Event* constant in analytics/events.go either dispatches via IncForEvent or is in the taskMetricEvents allow-list (PR2 typed RecordTask* methods). - TestNoNakedAnalyticsCaptureInHandlersOrServices: AST-walks handler/ service/cmd-server for direct Analytics.Capture(...) calls — only service/task.go's captureTaskEvent helper is allow-listed. - TestEveryAnalyticsRecordEventTakesAnalyticsHelper: validates the third arg of every metrics.RecordEvent call is built from analytics.*. Cardinality protection: all new label values pass through fixed allow-lists in labels_pr3.go; unknown values collapse to 'other'/'unknown'/'error'. Refs: - Spec MUL-2328 / MUL-2949. - Builds on PR2 (MUL-2948) — collectors registered through the same BusinessMetrics struct, no separate Registry. - Uses PR1's taskfailure.Reason (MUL-2946) for runtime_failed's failure_reason label via NormalizeFailureReason. Out of scope: Sampler-class metrics (PR4 / MUL-2947), pr_review_total emission point (no review event handler exists yet — counter is defined, TODO to wire up when /api/webhooks/github grows pull_request_review handling). Co-authored-by: multica-agent <github@multica.ai> * fix(server): tighten PR3 review items — signup_source bucket, fill platform/kind/form_source enums, onboarding_started server emission, lint scope (MUL-2949) Addresses 张大彪's review on #3698: 1. signup_source: NormalizeSignupSource added to labels_pr3.go with a fixed allow-list bucket (direct/google/twitter/linkedin/.../other). Parses JSON cookie payload for utm_source/source/referrer fields, strips URL schemes, maps well-known hostnames to channel buckets. PostHog event still ships the raw cookie value for analytics; only the Prometheus label is bucketed. 2. Filled the unknown/other label gaps: - analytics.IssueCreated and analytics.ChatMessageSent now take a platform parameter sourced from middleware.ClientMetadataFromContext (X-Client-Platform header) at the handler. Autopilot-originated issues stamp PlatformServer. - analytics.FeedbackSubmitted now takes a kind parameter; CreateFeedback reads req.Kind (default "general") so the picker selection lights up the metric's kind label instead of long-term "other". - analytics.ContactSalesSubmitted now takes a formSource (page / onboarding / agents_page); CreateContactSales reads req.Source. The metric reads ev.Properties["form_source"] so the analytics CoreProperties.Source ("marketing_contact_sales") stays backward-compat for PostHog dashboards. 3. analytics.OnboardingStarted helper added; server-side emission lives in PatchOnboarding, fired exactly once per user on the first PATCH that carries a non-empty questionnaire payload (firstTouch logic compares prior bytes against {} / null). Frontend onboarding_started keeps firing on page open; the server emission is what guarantees the Prometheus counter exists so Grafana can be cross-checked against the PostHog funnel without depending on the SDK roundtrip. 4. business_pairing_test.go tightened: - TestNoNakedAnalyticsCaptureInHandlersOrServices now allow-lists at function granularity (just captureTaskEvent in service/task.go), not whole-file. Any future naked Capture in the same file fails CI. - TestEveryAnalyticsRecordEventTakesAnalyticsHelper now does def-use tracking inside the enclosing FuncDecl: when RecordEvent's third arg is an *ast.Ident, the test walks the function body for the assignment that defined it and confirms the RHS is an analytics.<Helper>(...) call. Bare local idents that didn't originate from analytics are now caught. 5. gofmt -w applied across the touched files; gofmt -l clean. Tests: go test ./internal/metrics/... ./internal/analytics/... pass. Pre-existing TestClaimTask_/TestWebhook_MergedPR/TestDeleteIssueByIdentifier failures on origin/main are DB-environment-dependent and not regressions from this change. Co-authored-by: multica-agent <github@multica.ai> * fix(server): normalise onboarding_started platform label + regression test (MUL-2949) Addresses 张大彪's last review nit: - IncForEvent's EventOnboardingStarted case now wraps the platform property with NormalizePlatform, matching every other platform-bearing metric. A misbehaving frontend can no longer leak a raw X-Client-Platform header value into the multica_onboarding_started_total{platform=...} series. - New labels_pr3_test.go covers every PR3 normalizer with both a happy-path value and an unknown value, asserting the unknown collapses to the documented fallback bucket. Includes a focused regression for onboarding_started: emits one event with an attacker-shaped platform string and asserts the metric only exposes web + unknown label values (no raw header bleed). - testutil.go gains a small GatherForTest helper so the regression test can pull the typed MetricFamily map without re-implementing the registry-walk dance. Co-authored-by: multica-agent <github@multica.ai> * fix(server): NormalizeTaskSource on workspace_created + document lint limitations (MUL-2949) Final review touch-ups before merge: - IncForEvent's EventWorkspaceCreated case wraps source through NormalizeTaskSource, matching the other source-bearing dispatches (issue_created, agent_created, issue_executed). Closes the last raw property leak in the dispatcher table. - business_pairing_test.go inline docstrings now spell out the two known limitations of the lint gate that 张大彪 / Eve flagged: analyticsBackedIdents matches by ident NAME (not SSA def-use, so a nested-scope shadow could pass) and isMetricsRecordEvent hard-codes the import alias set. PR description carries a Follow-ups section with the same two items so the work is visible after merge. Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: 魏和尚 <agent+wei@multica.ai> Co-authored-by: multica-agent <github@multica.ai>
664 lines
22 KiB
Go
664 lines
22 KiB
Go
package handler
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"net/http"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/jackc/pgx/v5"
|
|
"github.com/jackc/pgx/v5/pgconn"
|
|
"github.com/jackc/pgx/v5/pgtype"
|
|
|
|
"github.com/multica-ai/multica/server/internal/agenttmpl"
|
|
"github.com/multica-ai/multica/server/internal/analytics"
|
|
"github.com/multica-ai/multica/server/internal/logger"
|
|
obsmetrics "github.com/multica-ai/multica/server/internal/metrics"
|
|
"github.com/multica-ai/multica/server/internal/util"
|
|
db "github.com/multica-ai/multica/server/pkg/db/generated"
|
|
"github.com/multica-ai/multica/server/pkg/protocol"
|
|
)
|
|
|
|
// agentTemplates is the in-memory catalog loaded once at package init. We
|
|
// fail-fast here rather than at the first request: a malformed template ships
|
|
// in source, so it's a deploy-time defect, not a runtime one.
|
|
var agentTemplates *agenttmpl.Registry
|
|
|
|
func init() {
|
|
reg, err := agenttmpl.Load()
|
|
if err != nil {
|
|
panic("agenttmpl: failed to load templates at startup: " + err.Error())
|
|
}
|
|
agentTemplates = reg
|
|
}
|
|
|
|
// --- Response shapes ---
|
|
|
|
// AgentTemplateSkillResponse is the per-skill payload returned in the picker
|
|
// list and detail. CachedName/CachedDescription let the UI render without an
|
|
// HTTP round-trip to upstream — they reflect the template author's snapshot.
|
|
type AgentTemplateSkillResponse struct {
|
|
SourceURL string `json:"source_url"`
|
|
CachedName string `json:"cached_name"`
|
|
CachedDescription string `json:"cached_description"`
|
|
}
|
|
|
|
// AgentTemplateSummaryResponse is what `GET /api/agent-templates` returns
|
|
// per entry. Omits Instructions to keep the list payload small; the detail
|
|
// endpoint (or the create flow) loads the full template.
|
|
type AgentTemplateSummaryResponse struct {
|
|
Slug string `json:"slug"`
|
|
Name string `json:"name"`
|
|
Description string `json:"description"`
|
|
Category string `json:"category,omitempty"`
|
|
Icon string `json:"icon,omitempty"`
|
|
Accent string `json:"accent,omitempty"`
|
|
Skills []AgentTemplateSkillResponse `json:"skills"`
|
|
}
|
|
|
|
// AgentTemplateResponse is the detail variant — same as the summary plus the
|
|
// full Instructions block.
|
|
type AgentTemplateResponse struct {
|
|
AgentTemplateSummaryResponse
|
|
Instructions string `json:"instructions"`
|
|
}
|
|
|
|
func templateToSummary(t agenttmpl.Template) AgentTemplateSummaryResponse {
|
|
skills := make([]AgentTemplateSkillResponse, 0, len(t.Skills))
|
|
for _, s := range t.Skills {
|
|
skills = append(skills, AgentTemplateSkillResponse{
|
|
SourceURL: s.SourceURL,
|
|
CachedName: s.CachedName,
|
|
CachedDescription: s.CachedDescription,
|
|
})
|
|
}
|
|
return AgentTemplateSummaryResponse{
|
|
Slug: t.Slug,
|
|
Name: t.Name,
|
|
Description: t.Description,
|
|
Category: t.Category,
|
|
Icon: t.Icon,
|
|
Accent: t.Accent,
|
|
Skills: skills,
|
|
}
|
|
}
|
|
|
|
func templateToDetail(t agenttmpl.Template) AgentTemplateResponse {
|
|
return AgentTemplateResponse{
|
|
AgentTemplateSummaryResponse: templateToSummary(t),
|
|
Instructions: t.Instructions,
|
|
}
|
|
}
|
|
|
|
// --- List + Get handlers ---
|
|
|
|
func (h *Handler) ListAgentTemplates(w http.ResponseWriter, r *http.Request) {
|
|
tmpls := agentTemplates.List()
|
|
resp := make([]AgentTemplateSummaryResponse, 0, len(tmpls))
|
|
for _, t := range tmpls {
|
|
resp = append(resp, templateToSummary(t))
|
|
}
|
|
writeJSON(w, http.StatusOK, resp)
|
|
}
|
|
|
|
func (h *Handler) GetAgentTemplate(w http.ResponseWriter, r *http.Request) {
|
|
slug := chi.URLParam(r, "slug")
|
|
t, ok := agentTemplates.Get(slug)
|
|
if !ok {
|
|
writeError(w, http.StatusNotFound, "template not found")
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, templateToDetail(t))
|
|
}
|
|
|
|
// --- Create-from-template handler ---
|
|
|
|
type CreateAgentFromTemplateRequest struct {
|
|
TemplateSlug string `json:"template_slug"`
|
|
Name string `json:"name"`
|
|
RuntimeID string `json:"runtime_id"`
|
|
Model string `json:"model,omitempty"`
|
|
Visibility string `json:"visibility,omitempty"`
|
|
MaxConcurrentTasks int32 `json:"max_concurrent_tasks,omitempty"`
|
|
// Optional overrides — let the picker UI customise the template before
|
|
// creation without forcing a second round-trip to the detail page.
|
|
// When nil/empty, the template's own values are used.
|
|
Description *string `json:"description,omitempty"`
|
|
Instructions *string `json:"instructions,omitempty"`
|
|
AvatarURL *string `json:"avatar_url,omitempty"`
|
|
// Workspace skill IDs to attach **in addition to** the template's
|
|
// skills. The merge dedupes against template skills automatically
|
|
// (agent_skill INSERT uses ON CONFLICT DO NOTHING).
|
|
ExtraSkillIDs []string `json:"extra_skill_ids,omitempty"`
|
|
}
|
|
|
|
type CreateAgentFromTemplateResponse struct {
|
|
Agent AgentResponse `json:"agent"`
|
|
ImportedSkillIDs []string `json:"imported_skill_ids"`
|
|
ReusedSkillIDs []string `json:"reused_skill_ids"`
|
|
}
|
|
|
|
type fetchFailureResponse struct {
|
|
Error string `json:"error"`
|
|
FailedURLs []string `json:"failed_urls"`
|
|
}
|
|
|
|
func (h *Handler) CreateAgentFromTemplate(w http.ResponseWriter, r *http.Request) {
|
|
workspaceID := h.resolveWorkspaceID(r)
|
|
|
|
ownerID, ok := requireUserID(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
var req CreateAgentFromTemplateRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid request body")
|
|
return
|
|
}
|
|
|
|
if req.Name == "" {
|
|
writeError(w, http.StatusBadRequest, "name is required")
|
|
return
|
|
}
|
|
if req.RuntimeID == "" {
|
|
writeError(w, http.StatusBadRequest, "runtime_id is required")
|
|
return
|
|
}
|
|
if req.Visibility == "" {
|
|
req.Visibility = "private"
|
|
}
|
|
if req.MaxConcurrentTasks == 0 {
|
|
req.MaxConcurrentTasks = 6
|
|
}
|
|
|
|
tmpl, found := agentTemplates.Get(req.TemplateSlug)
|
|
if !found {
|
|
writeError(w, http.StatusBadRequest, "template not found: "+req.TemplateSlug)
|
|
return
|
|
}
|
|
|
|
wsUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace id")
|
|
if !ok {
|
|
return
|
|
}
|
|
runtimeUUID, ok := parseUUIDOrBadRequest(w, req.RuntimeID, "runtime_id")
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
// Runtime validation reproduces the gating done by CreateAgent
|
|
// (handler/agent.go) — keep the two paths in sync. Done before fetch so
|
|
// we don't waste GitHub API calls for a request that's going to 403.
|
|
runtime, err := h.Queries.GetAgentRuntimeForWorkspace(r.Context(), db.GetAgentRuntimeForWorkspaceParams{
|
|
ID: runtimeUUID,
|
|
WorkspaceID: wsUUID,
|
|
})
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid runtime_id")
|
|
return
|
|
}
|
|
member, ok := h.workspaceMember(w, r, workspaceID)
|
|
if !ok {
|
|
return
|
|
}
|
|
if !canUseRuntimeForAgent(member, runtime) {
|
|
writeError(w, http.StatusForbidden, "this runtime is private; only its owner or a workspace admin can create agents on it")
|
|
return
|
|
}
|
|
|
|
slog.Info("agent-template create: request received",
|
|
append(logger.RequestAttrs(r),
|
|
"template_slug", tmpl.Slug,
|
|
"workspace_id", workspaceID,
|
|
"skill_url_count", len(tmpl.Skills),
|
|
)...)
|
|
|
|
// Pre-flight dedupe: each skill that already exists in the workspace
|
|
// by `cached_name` can be reused WITHOUT fetching. This is the big win:
|
|
// on the second create-from-the-same-template, fetch_count drops to 0
|
|
// and the whole operation completes in <100ms instead of 20+ seconds.
|
|
//
|
|
// `cached_name` MUST match the upstream SKILL.md frontmatter `name`
|
|
// (see template authoring docs in agenttmpl/types.go). When it doesn't,
|
|
// the pre-flight misses and we fall back to the in-TX find-or-create
|
|
// below — slower (one wasted fetch) but still correct.
|
|
preReused := make(map[int]db.Skill, len(tmpl.Skills))
|
|
toFetchRefs := make([]agenttmpl.TemplateSkillRef, 0, len(tmpl.Skills))
|
|
toFetchOrigIdx := make([]int, 0, len(tmpl.Skills))
|
|
for i, ref := range tmpl.Skills {
|
|
if ref.CachedName == "" {
|
|
toFetchRefs = append(toFetchRefs, ref)
|
|
toFetchOrigIdx = append(toFetchOrigIdx, i)
|
|
continue
|
|
}
|
|
existing, err := h.Queries.GetSkillByWorkspaceAndName(r.Context(), db.GetSkillByWorkspaceAndNameParams{
|
|
WorkspaceID: wsUUID,
|
|
Name: ref.CachedName,
|
|
})
|
|
if err == nil {
|
|
preReused[i] = existing
|
|
slog.Info("agent-template create: pre-reuse hit (skipped fetch)",
|
|
append(logger.RequestAttrs(r),
|
|
"index", i,
|
|
"cached_name", ref.CachedName,
|
|
"existing_skill_id", uuidToString(existing.ID),
|
|
)...)
|
|
continue
|
|
}
|
|
toFetchRefs = append(toFetchRefs, ref)
|
|
toFetchOrigIdx = append(toFetchOrigIdx, i)
|
|
}
|
|
|
|
// Fetch only the skills that aren't already in the workspace. fetched[j]
|
|
// corresponds to toFetchRefs[j], whose original index is toFetchOrigIdx[j].
|
|
httpClient := &http.Client{Timeout: 30 * time.Second}
|
|
fetchStart := time.Now()
|
|
var fetched []*importedSkill
|
|
var failedURLs []string
|
|
if len(toFetchRefs) > 0 {
|
|
fetched, failedURLs = fetchTemplateSkillsParallel(httpClient, toFetchRefs)
|
|
}
|
|
slog.Info("agent-template create: fetch phase done",
|
|
append(logger.RequestAttrs(r),
|
|
"template_slug", tmpl.Slug,
|
|
"fetch_duration_ms", time.Since(fetchStart).Milliseconds(),
|
|
"pre_reused_count", len(preReused),
|
|
"fetched_count", len(toFetchRefs)-len(failedURLs),
|
|
"fail_count", len(failedURLs),
|
|
"failed_urls", failedURLs,
|
|
)...)
|
|
if len(failedURLs) > 0 {
|
|
writeJSON(w, http.StatusUnprocessableEntity, fetchFailureResponse{
|
|
Error: "one or more skill sources are unavailable",
|
|
FailedURLs: failedURLs,
|
|
})
|
|
return
|
|
}
|
|
|
|
// Build a per-original-index lookup so we can iterate tmpl.Skills in
|
|
// order below without fiddling with toFetch* slices.
|
|
fetchedByOrigIdx := make(map[int]*importedSkill, len(fetched))
|
|
for j, imp := range fetched {
|
|
fetchedByOrigIdx[toFetchOrigIdx[j]] = imp
|
|
}
|
|
|
|
creatorUUID := parseUUID(ownerID)
|
|
isFirstAgent := false
|
|
if existing, listErr := h.Queries.ListAgents(r.Context(), wsUUID); listErr == nil {
|
|
isFirstAgent = len(existing) == 0
|
|
}
|
|
|
|
tx, err := h.TxStarter.Begin(r.Context())
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to begin tx: "+err.Error())
|
|
return
|
|
}
|
|
defer tx.Rollback(r.Context())
|
|
qtx := h.Queries.WithTx(tx)
|
|
|
|
importedIDs := make([]string, 0, len(tmpl.Skills))
|
|
reusedIDs := make([]string, 0, len(tmpl.Skills))
|
|
allSkillIDs := make([]pgtype.UUID, 0, len(tmpl.Skills))
|
|
|
|
for i, ref := range tmpl.Skills {
|
|
// Pre-flight hit: reuse the workspace's existing skill ID without
|
|
// any further fetch or DB work.
|
|
if existing, ok := preReused[i]; ok {
|
|
allSkillIDs = append(allSkillIDs, existing.ID)
|
|
reusedIDs = append(reusedIDs, uuidToString(existing.ID))
|
|
continue
|
|
}
|
|
|
|
imp := fetchedByOrigIdx[i]
|
|
if imp == nil {
|
|
// Defensive — shouldn't happen since we already checked failedURLs.
|
|
writeError(w, http.StatusInternalServerError, fmt.Sprintf("internal: missing fetch result for skill index %d", i))
|
|
return
|
|
}
|
|
|
|
// Second-chance dedupe by ACTUAL frontmatter name. Catches the case
|
|
// where the template author's cached_name drifted from the upstream
|
|
// frontmatter `name` — pre-flight misses but the workspace still has
|
|
// the skill under its real name, and we want to reuse not duplicate.
|
|
existing, err := qtx.GetSkillByWorkspaceAndName(r.Context(), db.GetSkillByWorkspaceAndNameParams{
|
|
WorkspaceID: wsUUID,
|
|
Name: imp.name,
|
|
})
|
|
if err == nil {
|
|
slog.Info("agent-template create: reusing existing skill (frontmatter-name match, cached_name drifted)",
|
|
append(logger.RequestAttrs(r),
|
|
"index", i,
|
|
"frontmatter_name", imp.name,
|
|
"cached_name", ref.CachedName,
|
|
"existing_skill_id", uuidToString(existing.ID),
|
|
)...)
|
|
allSkillIDs = append(allSkillIDs, existing.ID)
|
|
reusedIDs = append(reusedIDs, uuidToString(existing.ID))
|
|
continue
|
|
}
|
|
if !errors.Is(err, pgx.ErrNoRows) {
|
|
slog.Error("agent-template create: lookup existing skill failed",
|
|
append(logger.RequestAttrs(r),
|
|
"index", i,
|
|
"name", imp.name,
|
|
"error", err,
|
|
)...)
|
|
writeError(w, http.StatusInternalServerError, "lookup existing skill failed: "+err.Error())
|
|
return
|
|
}
|
|
|
|
slog.Info("agent-template create: inserting new skill",
|
|
append(logger.RequestAttrs(r),
|
|
"index", i,
|
|
"name", imp.name,
|
|
"file_count", len(imp.files),
|
|
)...)
|
|
|
|
files := make([]CreateSkillFileRequest, 0, len(imp.files))
|
|
for _, f := range imp.files {
|
|
if !validateFilePath(f.path) {
|
|
continue
|
|
}
|
|
files = append(files, CreateSkillFileRequest{Path: f.path, Content: f.content})
|
|
}
|
|
|
|
// Record provenance: which template seeded this skill, plus the
|
|
// upstream URL. Mirrors handler/skill.go:ImportSkill's origin block
|
|
// so the skill detail page renders a consistent "imported from …"
|
|
// chip regardless of entry point.
|
|
origin := map[string]any{
|
|
"type": "agent_template",
|
|
"template_slug": tmpl.Slug,
|
|
"source_url": ref.SourceURL,
|
|
}
|
|
// Preserve the upstream-specific origin fields (owner/repo/...) when
|
|
// the fetcher returned them — useful for a "Open on GitHub" link.
|
|
if imp.origin != nil {
|
|
for k, v := range imp.origin {
|
|
if _, exists := origin[k]; !exists {
|
|
origin[k] = v
|
|
}
|
|
}
|
|
}
|
|
|
|
created, err := createSkillWithFilesInTx(r.Context(), qtx, skillCreateInput{
|
|
WorkspaceID: wsUUID,
|
|
CreatorID: creatorUUID,
|
|
Name: imp.name,
|
|
Description: imp.description,
|
|
Content: imp.content,
|
|
Config: map[string]any{"origin": origin},
|
|
Files: files,
|
|
})
|
|
if err != nil {
|
|
// Full PG error in the log so we can tell unique-constraint from
|
|
// other failures without guessing.
|
|
slog.Error("agent-template create: failed to create skill",
|
|
append(logger.RequestAttrs(r),
|
|
"index", i,
|
|
"name", imp.name,
|
|
"workspace_id", workspaceID,
|
|
"error", err,
|
|
"is_unique_violation", isUniqueViolation(err),
|
|
)...)
|
|
writeError(w, http.StatusInternalServerError, "failed to create skill: "+err.Error())
|
|
return
|
|
}
|
|
allSkillIDs = append(allSkillIDs, parseUUID(created.ID))
|
|
importedIDs = append(importedIDs, created.ID)
|
|
}
|
|
|
|
rc, _ := json.Marshal(map[string]any{})
|
|
ce, _ := json.Marshal(map[string]string{})
|
|
ca, _ := json.Marshal([]string{})
|
|
|
|
// Apply optional overrides — nil means "use template default".
|
|
description := tmpl.Description
|
|
if req.Description != nil {
|
|
description = *req.Description
|
|
}
|
|
instructions := tmpl.Instructions
|
|
if req.Instructions != nil {
|
|
instructions = *req.Instructions
|
|
}
|
|
avatarURL := pgtype.Text{}
|
|
if req.AvatarURL != nil && *req.AvatarURL != "" {
|
|
avatarURL = pgtype.Text{String: *req.AvatarURL, Valid: true}
|
|
}
|
|
|
|
agent, err := qtx.CreateAgent(r.Context(), db.CreateAgentParams{
|
|
WorkspaceID: wsUUID,
|
|
Name: req.Name,
|
|
Description: description,
|
|
Instructions: instructions,
|
|
AvatarUrl: avatarURL,
|
|
RuntimeMode: runtime.RuntimeMode,
|
|
RuntimeConfig: rc,
|
|
RuntimeID: runtime.ID,
|
|
Visibility: req.Visibility,
|
|
MaxConcurrentTasks: req.MaxConcurrentTasks,
|
|
OwnerID: creatorUUID,
|
|
CustomEnv: ce,
|
|
CustomArgs: ca,
|
|
McpConfig: nil,
|
|
Model: pgtype.Text{String: req.Model, Valid: req.Model != ""},
|
|
})
|
|
if err != nil {
|
|
// Mirror handler/agent.go:CreateAgent: when the duplicate is the
|
|
// agent name UNIQUE in this workspace, return 409 with a clear
|
|
// message instead of leaking the raw PG error as 500. Frontend
|
|
// already knows how to render 409 from the manual create path.
|
|
var pgErr *pgconn.PgError
|
|
if errors.As(err, &pgErr) && pgErr.Code == "23505" && pgErr.ConstraintName == "agent_workspace_name_unique" {
|
|
slog.Info("agent-template create: agent name conflict",
|
|
append(logger.RequestAttrs(r),
|
|
"agent_name", req.Name,
|
|
"workspace_id", workspaceID,
|
|
)...)
|
|
writeError(w, http.StatusConflict, fmt.Sprintf("an agent named %q already exists in this workspace", req.Name))
|
|
return
|
|
}
|
|
slog.Error("agent-template create: failed to create agent",
|
|
append(logger.RequestAttrs(r),
|
|
"agent_name", req.Name,
|
|
"workspace_id", workspaceID,
|
|
"error", err,
|
|
"is_unique_violation", isUniqueViolation(err),
|
|
)...)
|
|
writeError(w, http.StatusInternalServerError, "failed to create agent: "+err.Error())
|
|
return
|
|
}
|
|
|
|
for idx, skillID := range allSkillIDs {
|
|
if err := qtx.AddAgentSkill(r.Context(), db.AddAgentSkillParams{
|
|
AgentID: agent.ID,
|
|
SkillID: skillID,
|
|
}); err != nil {
|
|
slog.Error("agent-template create: failed to attach skill",
|
|
append(logger.RequestAttrs(r),
|
|
"agent_id", uuidToString(agent.ID),
|
|
"skill_id", uuidToString(skillID),
|
|
"skill_index", idx,
|
|
"error", err,
|
|
)...)
|
|
writeError(w, http.StatusInternalServerError, "failed to attach skill: "+err.Error())
|
|
return
|
|
}
|
|
}
|
|
|
|
// Attach user-supplied extra skills (selected in the create dialog
|
|
// alongside the template). AddAgentSkill uses ON CONFLICT DO NOTHING,
|
|
// so duplicates with template-imported skills are harmless.
|
|
for _, raw := range req.ExtraSkillIDs {
|
|
extraUUID, perr := util.ParseUUID(raw)
|
|
if perr != nil {
|
|
// Skip malformed IDs but don't fail the whole create — the agent
|
|
// is otherwise valid. Logged so the bad ID can be traced.
|
|
slog.Warn("agent-template create: skipping malformed extra_skill_id",
|
|
append(logger.RequestAttrs(r), "raw", raw, "error", perr)...)
|
|
continue
|
|
}
|
|
// Verify the skill belongs to this workspace before attaching;
|
|
// otherwise a malicious client could attach a skill from another
|
|
// workspace by guessing UUIDs.
|
|
owned, qerr := qtx.GetSkillInWorkspace(r.Context(), db.GetSkillInWorkspaceParams{
|
|
ID: extraUUID, WorkspaceID: wsUUID,
|
|
})
|
|
if qerr != nil {
|
|
slog.Warn("agent-template create: skipping cross-workspace extra_skill_id",
|
|
append(logger.RequestAttrs(r), "skill_id", raw, "error", qerr)...)
|
|
continue
|
|
}
|
|
if err := qtx.AddAgentSkill(r.Context(), db.AddAgentSkillParams{
|
|
AgentID: agent.ID,
|
|
SkillID: owned.ID,
|
|
}); err != nil {
|
|
slog.Error("agent-template create: failed to attach extra skill",
|
|
append(logger.RequestAttrs(r), "skill_id", raw, "error", err)...)
|
|
writeError(w, http.StatusInternalServerError, "failed to attach skill: "+err.Error())
|
|
return
|
|
}
|
|
}
|
|
|
|
if err := tx.Commit(r.Context()); err != nil {
|
|
slog.Error("agent-template create: commit failed",
|
|
append(logger.RequestAttrs(r),
|
|
"agent_id", uuidToString(agent.ID),
|
|
"error", err,
|
|
)...)
|
|
writeError(w, http.StatusInternalServerError, "commit failed: "+err.Error())
|
|
return
|
|
}
|
|
|
|
if runtime.Status == "online" {
|
|
h.TaskService.ReconcileAgentStatus(r.Context(), agent.ID)
|
|
agent, _ = h.Queries.GetAgent(r.Context(), agent.ID)
|
|
}
|
|
|
|
resp := agentToResponse(agent)
|
|
// Templates attach skills via AddAgentSkill above, so the freshly built
|
|
// AgentResponse must reload them — otherwise the create response (and
|
|
// the agent:created broadcast) would tell clients the agent has no
|
|
// skills despite the template having just imported them (#3459).
|
|
if err := h.attachAgentSkills(r.Context(), &resp, agent.ID); err != nil {
|
|
slog.Warn("load agent skills after template create failed",
|
|
append(logger.RequestAttrs(r), "error", err, "agent_id", uuidToString(agent.ID))...)
|
|
writeError(w, http.StatusInternalServerError, "failed to load agent skills")
|
|
return
|
|
}
|
|
actorType, actorID := h.resolveActor(r, ownerID, workspaceID)
|
|
h.publish(protocol.EventAgentCreated, workspaceID, actorType, actorID, map[string]any{"agent": resp})
|
|
|
|
obsmetrics.RecordEvent(h.Analytics, h.Metrics, analytics.AgentCreated(
|
|
ownerID,
|
|
workspaceID,
|
|
uuidToString(agent.ID),
|
|
runtime.Provider,
|
|
runtime.RuntimeMode,
|
|
tmpl.Slug, // template slug doubles as the analytics template field
|
|
isFirstAgent,
|
|
))
|
|
|
|
slog.Info("agent created from template",
|
|
append(logger.RequestAttrs(r),
|
|
"agent_id", uuidToString(agent.ID),
|
|
"template_slug", tmpl.Slug,
|
|
"imported_skill_count", len(importedIDs),
|
|
"reused_skill_count", len(reusedIDs),
|
|
)...)
|
|
|
|
writeJSON(w, http.StatusCreated, CreateAgentFromTemplateResponse{
|
|
Agent: resp,
|
|
ImportedSkillIDs: importedIDs,
|
|
ReusedSkillIDs: reusedIDs,
|
|
})
|
|
}
|
|
|
|
// --- Parallel skill fetch ---
|
|
|
|
type templateFetchResult struct {
|
|
index int
|
|
imported *importedSkill
|
|
url string
|
|
err error
|
|
}
|
|
|
|
// fetchTemplateSkillsParallel resolves every template skill ref into an
|
|
// importedSkill, in parallel. Returns the imports in input order; failed_urls
|
|
// is non-nil iff any fetch failed. Logs per-URL timing so we can spot which
|
|
// upstream is the long pole in a slow request.
|
|
func fetchTemplateSkillsParallel(client *http.Client, refs []agenttmpl.TemplateSkillRef) ([]*importedSkill, []string) {
|
|
results := make(chan templateFetchResult, len(refs))
|
|
var wg sync.WaitGroup
|
|
for i, ref := range refs {
|
|
wg.Add(1)
|
|
go func(i int, ref agenttmpl.TemplateSkillRef) {
|
|
defer wg.Done()
|
|
start := time.Now()
|
|
slog.Info("agent-template fetch: start", "index", i, "source_url", ref.SourceURL)
|
|
imp, err := fetchSkillFromURL(client, ref.SourceURL)
|
|
elapsedMs := time.Since(start).Milliseconds()
|
|
if err != nil {
|
|
slog.Warn("agent-template fetch: failed",
|
|
"index", i,
|
|
"source_url", ref.SourceURL,
|
|
"duration_ms", elapsedMs,
|
|
"error", err,
|
|
)
|
|
} else {
|
|
resolvedName := ""
|
|
fileCount := 0
|
|
if imp != nil {
|
|
resolvedName = imp.name
|
|
fileCount = len(imp.files)
|
|
}
|
|
slog.Info("agent-template fetch: done",
|
|
"index", i,
|
|
"source_url", ref.SourceURL,
|
|
"duration_ms", elapsedMs,
|
|
"resolved_name", resolvedName,
|
|
"file_count", fileCount,
|
|
)
|
|
}
|
|
results <- templateFetchResult{index: i, imported: imp, url: ref.SourceURL, err: err}
|
|
}(i, ref)
|
|
}
|
|
wg.Wait()
|
|
close(results)
|
|
|
|
imports := make([]*importedSkill, len(refs))
|
|
var failed []string
|
|
for r := range results {
|
|
if r.err != nil {
|
|
failed = append(failed, r.url)
|
|
continue
|
|
}
|
|
imports[r.index] = r.imported
|
|
}
|
|
return imports, failed
|
|
}
|
|
|
|
// fetchSkillFromURL dispatches to the right upstream fetcher based on URL.
|
|
// Mirrors the switch inside ImportSkill (skill.go:1566) so both entry points
|
|
// stay in sync.
|
|
func fetchSkillFromURL(client *http.Client, rawURL string) (*importedSkill, error) {
|
|
source, normalized, err := detectImportSource(rawURL)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
switch source {
|
|
case sourceClawHub:
|
|
return fetchFromClawHub(client, normalized)
|
|
case sourceSkillsSh:
|
|
return fetchFromSkillsSh(client, normalized)
|
|
case sourceGitHub:
|
|
return fetchFromGitHub(client, normalized)
|
|
}
|
|
return nil, fmt.Errorf("unknown import source for %s", rawURL)
|
|
}
|