Files
multica/server/internal/handler/runtime_local_skills.go
Tom Qiao 1c91c2a3b2 security(db): scope DELETE/UpdateIssueStatus by workspace_id (defense-in-depth) (#3027)
* fix(security): scope DELETE/UpdateIssueStatus by workspace_id

Add workspace_id to the WHERE clause of DeleteIssue, DeleteComment,
DeleteProject, DeleteSkill, DeleteChatSession, and UpdateIssueStatus
as SQL-layer defense-in-depth.

Handler loaders (loadIssueForUser / loadSkillForUser / etc.) already
enforce workspace membership today, so this is not patching a known
live vuln. But the tenant invariant is currently a handler-layer
guarantee — a future loader bypass or a new caller skipping the
loader would be silently catastrophic. Making workspace_id part of
the SQL identity collapses the trust surface to the schema itself:
forging a sibling-workspace UUID becomes ErrNoRows instead of a
cross-tenant write.

Reference: incident #1661 (util.ParseUUID silent zero UUID returning
204 on a DELETE that matched zero rows) — same class of failure,
prevented at a different layer.

Scope:
- 5 DELETE queries: issue, comment, project, skill, chat_session
- 1 simple UPDATE: UpdateIssueStatus (2 narg, no SET ordering risk)
- All callers updated (handlers, service, runtime sweeper fallback)

Multi-narg UPDATE queries (UpdateIssue, UpdateProject, UpdateSkill,
UpdateComment, UpdateChatSession*) are deferred to a follow-up to
keep this change reviewable: each needs its narg pinning shifted
and per-caller verification.

sqlc was regenerated by hand (no local sqlc toolchain); CI's
backend job is the authoritative compile check.

* test(security): add workspace_scope_guard regression test

Locks in the SQL-layer tenant guard added in this PR. For each of the 6
scoped queries (DeleteIssue, DeleteComment, DeleteProject, DeleteSkill,
DeleteChatSession, UpdateIssueStatus), creates the resource in workspace
A, invokes the query with a foreign workspace UUID, and asserts the row
is untouched (0 rows affected with no error for :exec; pgx.ErrNoRows for
:one). A future refactor that drops the workspace_id arg from any of
these queries will now fail loudly instead of silently regressing.

Includes a sanity sub-test that the in-workspace path still mutates, so
a buggy guard that returns no-op for every call would not pass.

Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>

---------

Co-authored-by: Tom Qiao <tomqiaozc@users.noreply.github.com>
Co-authored-by: Claude Opus 4 <noreply@anthropic.com>
2026-05-22 14:39:47 +08:00

769 lines
25 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package handler
import (
"context"
"encoding/json"
"log/slog"
"net/http"
"sort"
"strings"
"sync"
"time"
"github.com/go-chi/chi/v5"
"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"
)
type RuntimeLocalSkillRequestStatus string
const (
RuntimeLocalSkillPending RuntimeLocalSkillRequestStatus = "pending"
RuntimeLocalSkillRunning RuntimeLocalSkillRequestStatus = "running"
RuntimeLocalSkillCompleted RuntimeLocalSkillRequestStatus = "completed"
RuntimeLocalSkillFailed RuntimeLocalSkillRequestStatus = "failed"
RuntimeLocalSkillTimeout RuntimeLocalSkillRequestStatus = "timeout"
)
const (
// runtimeLocalSkillPendingTimeout bounds how long a request can sit in
// pending before the server marks it timed out. The value must accommodate
// old daemons that don't support batch import: they pop only one import
// per heartbeat cycle (~15s). With maxLocalSkillImportBatch=10, the 10th
// queued import waits up to 10×15s = 150s before being claimed. 3 minutes
// gives a comfortable margin.
//
// Timeout invariant: IMPORT_CONCURRENCY (views/.../runtime-local-skill-import-panel.tsx)
// × heartbeat period (~15s) ≤ runtimeLocalSkillPendingTimeout, and
// IMPORT_POLL_TIMEOUT_MS (core/runtimes/local-skills.ts) must exceed
// runtimeLocalSkillPendingTimeout + runtimeLocalSkillRunningTimeout.
// See also maxLocalSkillImportBatch in daemon.go.
runtimeLocalSkillPendingTimeout = 3 * time.Minute
runtimeLocalSkillRunningTimeout = 60 * time.Second
runtimeLocalSkillStoreRetention = 5 * time.Minute
)
// LocalSkillListStore tracks pending / running / completed runtime-local-skill
// inventory requests. The server MUST stay stateless — any state that needs to
// outlive a single request has to live in shared storage so multi-node deploys
// can have POST, heartbeat and poll land on different nodes and still agree
// on the request's state.
type LocalSkillListStore interface {
Create(ctx context.Context, runtimeID string) (*RuntimeLocalSkillListRequest, error)
Get(ctx context.Context, id string) (*RuntimeLocalSkillListRequest, error)
// HasPending is a cheap read-only probe that reports whether the runtime
// has at least one pending request. Callers on the hot path (e.g. the
// heartbeat handler) use it to gate the side-effecting PopPending so they
// never start a claim they might have to abort.
HasPending(ctx context.Context, runtimeID string) (bool, error)
PopPending(ctx context.Context, runtimeID string) (*RuntimeLocalSkillListRequest, error)
Complete(ctx context.Context, id string, skills []RuntimeLocalSkillSummary, supported bool) error
Fail(ctx context.Context, id string, errMsg string) error
}
// LocalSkillImportStore is the same contract as LocalSkillListStore but for
// runtime-local-skill import requests. Kept as a separate interface because the
// Create signature carries import-specific fields (skill_key, optional rename).
type LocalSkillImportStore interface {
Create(ctx context.Context, runtimeID, creatorID, skillKey string, name, description *string) (*RuntimeLocalSkillImportRequest, error)
Get(ctx context.Context, id string) (*RuntimeLocalSkillImportRequest, error)
HasPending(ctx context.Context, runtimeID string) (bool, error)
PopPending(ctx context.Context, runtimeID string) (*RuntimeLocalSkillImportRequest, error)
// PopPendingBatch claims up to limit pending requests atomically and
// transitions them to running. Used by the heartbeat handler to deliver
// multiple imports per heartbeat cycle.
PopPendingBatch(ctx context.Context, runtimeID string, limit int) ([]*RuntimeLocalSkillImportRequest, error)
Complete(ctx context.Context, id string, skill SkillResponse) error
Fail(ctx context.Context, id string, errMsg string) error
}
// applyLocalSkillListTimeout transitions a request into the timeout terminal
// state if it has been pending / running past the configured thresholds.
// Returns true when the record was modified so callers can persist the change.
func applyLocalSkillListTimeout(req *RuntimeLocalSkillListRequest, now time.Time) bool {
switch req.Status {
case RuntimeLocalSkillPending:
if now.Sub(req.CreatedAt) > runtimeLocalSkillPendingTimeout {
req.Status = RuntimeLocalSkillTimeout
req.Error = "daemon did not respond within 3 minutes"
req.UpdatedAt = now
return true
}
case RuntimeLocalSkillRunning:
if req.RunStartedAt != nil && now.Sub(*req.RunStartedAt) > runtimeLocalSkillRunningTimeout {
req.Status = RuntimeLocalSkillTimeout
req.Error = "daemon did not finish within 60 seconds"
req.UpdatedAt = now
return true
}
}
return false
}
func applyLocalSkillImportTimeout(req *RuntimeLocalSkillImportRequest, now time.Time) bool {
switch req.Status {
case RuntimeLocalSkillPending:
if now.Sub(req.CreatedAt) > runtimeLocalSkillPendingTimeout {
req.Status = RuntimeLocalSkillTimeout
req.Error = "daemon did not respond within 3 minutes"
req.UpdatedAt = now
return true
}
case RuntimeLocalSkillRunning:
if req.RunStartedAt != nil && now.Sub(*req.RunStartedAt) > runtimeLocalSkillRunningTimeout {
req.Status = RuntimeLocalSkillTimeout
req.Error = "daemon did not finish within 60 seconds"
req.UpdatedAt = now
return true
}
}
return false
}
type RuntimeLocalSkillSummary struct {
Key string `json:"key"`
Name string `json:"name"`
Description string `json:"description,omitempty"`
SourcePath string `json:"source_path"`
Provider string `json:"provider"`
FileCount int `json:"file_count"`
}
type RuntimeLocalSkillListRequest struct {
ID string `json:"id"`
RuntimeID string `json:"runtime_id"`
Status RuntimeLocalSkillRequestStatus `json:"status"`
Skills []RuntimeLocalSkillSummary `json:"skills,omitempty"`
Supported bool `json:"supported"`
Error string `json:"error,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
RunStartedAt *time.Time `json:"-"`
}
type RuntimeLocalSkillImportRequest struct {
ID string `json:"id"`
RuntimeID string `json:"runtime_id"`
SkillKey string `json:"skill_key"`
Name *string `json:"name,omitempty"`
Description *string `json:"description,omitempty"`
Status RuntimeLocalSkillRequestStatus `json:"status"`
Skill *SkillResponse `json:"skill,omitempty"`
Error string `json:"error,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
CreatorID string `json:"-"`
RunStartedAt *time.Time `json:"-"`
}
// InMemoryLocalSkillListStore is the single-node implementation — good enough
// for local dev and the in-process test suite. Production (multi-node) must
// use RedisLocalSkillListStore so every API node agrees on the same pending
// set.
type InMemoryLocalSkillListStore struct {
mu sync.Mutex
requests map[string]*RuntimeLocalSkillListRequest
}
func NewInMemoryLocalSkillListStore() *InMemoryLocalSkillListStore {
return &InMemoryLocalSkillListStore{requests: make(map[string]*RuntimeLocalSkillListRequest)}
}
func (s *InMemoryLocalSkillListStore) Create(_ context.Context, runtimeID string) (*RuntimeLocalSkillListRequest, error) {
s.mu.Lock()
defer s.mu.Unlock()
for id, req := range s.requests {
if time.Since(req.CreatedAt) > runtimeLocalSkillStoreRetention {
delete(s.requests, id)
}
}
req := &RuntimeLocalSkillListRequest{
ID: randomID(),
RuntimeID: runtimeID,
Status: RuntimeLocalSkillPending,
Supported: true,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
s.requests[req.ID] = req
return req, nil
}
func (s *InMemoryLocalSkillListStore) Get(_ context.Context, id string) (*RuntimeLocalSkillListRequest, error) {
s.mu.Lock()
defer s.mu.Unlock()
req, ok := s.requests[id]
if !ok {
return nil, nil
}
applyLocalSkillListTimeout(req, time.Now())
return req, nil
}
func (s *InMemoryLocalSkillListStore) HasPending(_ context.Context, runtimeID string) (bool, error) {
s.mu.Lock()
defer s.mu.Unlock()
now := time.Now()
for _, req := range s.requests {
applyLocalSkillListTimeout(req, now)
if req.RuntimeID == runtimeID && req.Status == RuntimeLocalSkillPending {
return true, nil
}
}
return false, nil
}
func (s *InMemoryLocalSkillListStore) PopPending(_ context.Context, runtimeID string) (*RuntimeLocalSkillListRequest, error) {
s.mu.Lock()
defer s.mu.Unlock()
var oldest *RuntimeLocalSkillListRequest
now := time.Now()
for _, req := range s.requests {
applyLocalSkillListTimeout(req, now)
if req.RuntimeID == runtimeID && req.Status == RuntimeLocalSkillPending {
if oldest == nil || req.CreatedAt.Before(oldest.CreatedAt) {
oldest = req
}
}
}
if oldest != nil {
oldest.Status = RuntimeLocalSkillRunning
startedAt := now
oldest.RunStartedAt = &startedAt
oldest.UpdatedAt = now
}
return oldest, nil
}
func (s *InMemoryLocalSkillListStore) Complete(_ context.Context, id string, skills []RuntimeLocalSkillSummary, supported bool) error {
s.mu.Lock()
defer s.mu.Unlock()
if req, ok := s.requests[id]; ok {
req.Status = RuntimeLocalSkillCompleted
req.Skills = skills
req.Supported = supported
req.UpdatedAt = time.Now()
}
return nil
}
func (s *InMemoryLocalSkillListStore) Fail(_ context.Context, id string, errMsg string) error {
s.mu.Lock()
defer s.mu.Unlock()
if req, ok := s.requests[id]; ok {
req.Status = RuntimeLocalSkillFailed
req.Error = errMsg
req.UpdatedAt = time.Now()
}
return nil
}
// InMemoryLocalSkillImportStore mirrors InMemoryLocalSkillListStore for import
// requests. Same single-node vs. multi-node caveat.
type InMemoryLocalSkillImportStore struct {
mu sync.Mutex
requests map[string]*RuntimeLocalSkillImportRequest
}
func NewInMemoryLocalSkillImportStore() *InMemoryLocalSkillImportStore {
return &InMemoryLocalSkillImportStore{requests: make(map[string]*RuntimeLocalSkillImportRequest)}
}
func (s *InMemoryLocalSkillImportStore) Create(_ context.Context, runtimeID, creatorID, skillKey string, name, description *string) (*RuntimeLocalSkillImportRequest, error) {
s.mu.Lock()
defer s.mu.Unlock()
for id, req := range s.requests {
if time.Since(req.CreatedAt) > runtimeLocalSkillStoreRetention {
delete(s.requests, id)
}
}
req := &RuntimeLocalSkillImportRequest{
ID: randomID(),
RuntimeID: runtimeID,
SkillKey: skillKey,
Name: name,
Description: description,
Status: RuntimeLocalSkillPending,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
CreatorID: creatorID,
}
s.requests[req.ID] = req
return req, nil
}
func (s *InMemoryLocalSkillImportStore) Get(_ context.Context, id string) (*RuntimeLocalSkillImportRequest, error) {
s.mu.Lock()
defer s.mu.Unlock()
req, ok := s.requests[id]
if !ok {
return nil, nil
}
applyLocalSkillImportTimeout(req, time.Now())
return req, nil
}
func (s *InMemoryLocalSkillImportStore) HasPending(_ context.Context, runtimeID string) (bool, error) {
s.mu.Lock()
defer s.mu.Unlock()
now := time.Now()
for _, req := range s.requests {
applyLocalSkillImportTimeout(req, now)
if req.RuntimeID == runtimeID && req.Status == RuntimeLocalSkillPending {
return true, nil
}
}
return false, nil
}
func (s *InMemoryLocalSkillImportStore) PopPending(_ context.Context, runtimeID string) (*RuntimeLocalSkillImportRequest, error) {
s.mu.Lock()
defer s.mu.Unlock()
var oldest *RuntimeLocalSkillImportRequest
now := time.Now()
for _, req := range s.requests {
applyLocalSkillImportTimeout(req, now)
if req.RuntimeID == runtimeID && req.Status == RuntimeLocalSkillPending {
if oldest == nil || req.CreatedAt.Before(oldest.CreatedAt) {
oldest = req
}
}
}
if oldest != nil {
oldest.Status = RuntimeLocalSkillRunning
startedAt := now
oldest.RunStartedAt = &startedAt
oldest.UpdatedAt = now
}
return oldest, nil
}
func (s *InMemoryLocalSkillImportStore) PopPendingBatch(_ context.Context, runtimeID string, limit int) ([]*RuntimeLocalSkillImportRequest, error) {
s.mu.Lock()
defer s.mu.Unlock()
now := time.Now()
// Collect all pending requests for this runtime, sorted by creation time.
var pending []*RuntimeLocalSkillImportRequest
for _, req := range s.requests {
applyLocalSkillImportTimeout(req, now)
if req.RuntimeID == runtimeID && req.Status == RuntimeLocalSkillPending {
pending = append(pending, req)
}
}
sort.Slice(pending, func(i, j int) bool {
return pending[i].CreatedAt.Before(pending[j].CreatedAt)
})
if limit > len(pending) {
limit = len(pending)
}
result := make([]*RuntimeLocalSkillImportRequest, 0, limit)
for _, req := range pending[:limit] {
req.Status = RuntimeLocalSkillRunning
startedAt := now
req.RunStartedAt = &startedAt
req.UpdatedAt = now
result = append(result, req)
}
return result, nil
}
func (s *InMemoryLocalSkillImportStore) Complete(_ context.Context, id string, skill SkillResponse) error {
s.mu.Lock()
defer s.mu.Unlock()
if req, ok := s.requests[id]; ok {
req.Status = RuntimeLocalSkillCompleted
req.Skill = &skill
req.UpdatedAt = time.Now()
}
return nil
}
func (s *InMemoryLocalSkillImportStore) Fail(_ context.Context, id string, errMsg string) error {
s.mu.Lock()
defer s.mu.Unlock()
if req, ok := s.requests[id]; ok {
req.Status = RuntimeLocalSkillFailed
req.Error = errMsg
req.UpdatedAt = time.Now()
}
return nil
}
type CreateRuntimeLocalSkillImportRequest struct {
SkillKey string `json:"skill_key"`
Name *string `json:"name,omitempty"`
Description *string `json:"description,omitempty"`
}
type reportedRuntimeLocalSkill struct {
Name string `json:"name"`
Description string `json:"description"`
Content string `json:"content"`
SourcePath string `json:"source_path"`
Provider string `json:"provider"`
Files []CreateSkillFileRequest `json:"files,omitempty"`
}
func cleanOptionalString(value *string) *string {
if value == nil {
return nil
}
trimmed := strings.TrimSpace(*value)
if trimmed == "" {
return nil
}
return &trimmed
}
func runtimeLocalSkillRequestTerminal(status RuntimeLocalSkillRequestStatus) bool {
return status == RuntimeLocalSkillCompleted || status == RuntimeLocalSkillFailed || status == RuntimeLocalSkillTimeout
}
func (h *Handler) requireRuntimeLocalSkillAccess(w http.ResponseWriter, r *http.Request, runtimeID string) (runtimeIDAndWorkspace, bool) {
runtimeUUID, ok := parseUUIDOrBadRequest(w, runtimeID, "runtime_id")
if !ok {
return runtimeIDAndWorkspace{}, false
}
rt, err := h.Queries.GetAgentRuntime(r.Context(), runtimeUUID)
if err != nil {
writeError(w, http.StatusNotFound, "runtime not found")
return runtimeIDAndWorkspace{}, false
}
wsID := uuidToString(rt.WorkspaceID)
member, ok := h.requireWorkspaceMember(w, r, wsID, "runtime not found")
if !ok {
return runtimeIDAndWorkspace{}, false
}
if rt.OwnerID.Valid && uuidToString(rt.OwnerID) == uuidToString(member.UserID) {
return runtimeIDAndWorkspace{
runtimeID: uuidToString(rt.ID),
workspaceID: wsID,
provider: rt.Provider,
status: rt.Status,
}, true
}
writeError(w, http.StatusForbidden, "insufficient permissions")
return runtimeIDAndWorkspace{}, false
}
type runtimeIDAndWorkspace struct {
runtimeID string
workspaceID string
provider string
status string
}
func (h *Handler) InitiateListLocalSkills(w http.ResponseWriter, r *http.Request) {
runtimeID := chi.URLParam(r, "runtimeId")
rt, ok := h.requireRuntimeLocalSkillAccess(w, r, runtimeID)
if !ok {
return
}
if rt.status != "online" {
writeError(w, http.StatusServiceUnavailable, "runtime is offline")
return
}
req, err := h.LocalSkillListStore.Create(r.Context(), rt.runtimeID)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to enqueue local skills request: "+err.Error())
return
}
writeJSON(w, http.StatusOK, req)
}
func (h *Handler) GetLocalSkillListRequest(w http.ResponseWriter, r *http.Request) {
runtimeID := chi.URLParam(r, "runtimeId")
rt, ok := h.requireRuntimeLocalSkillAccess(w, r, runtimeID)
if !ok {
return
}
requestID := chi.URLParam(r, "requestId")
req, err := h.LocalSkillListStore.Get(r.Context(), requestID)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to load request: "+err.Error())
return
}
if req == nil || req.RuntimeID != rt.runtimeID {
writeError(w, http.StatusNotFound, "request not found")
return
}
writeJSON(w, http.StatusOK, req)
}
func (h *Handler) InitiateImportLocalSkill(w http.ResponseWriter, r *http.Request) {
runtimeID := chi.URLParam(r, "runtimeId")
rt, ok := h.requireRuntimeLocalSkillAccess(w, r, runtimeID)
if !ok {
return
}
if rt.status != "online" {
writeError(w, http.StatusServiceUnavailable, "runtime is offline")
return
}
creatorID, ok := requireUserID(w, r)
if !ok {
return
}
var req CreateRuntimeLocalSkillImportRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
if strings.TrimSpace(req.SkillKey) == "" {
writeError(w, http.StatusBadRequest, "skill_key is required")
return
}
importReq, err := h.LocalSkillImportStore.Create(
r.Context(),
rt.runtimeID,
creatorID,
strings.TrimSpace(req.SkillKey),
cleanOptionalString(req.Name),
cleanOptionalString(req.Description),
)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to enqueue local skill import: "+err.Error())
return
}
writeJSON(w, http.StatusOK, importReq)
}
func (h *Handler) GetLocalSkillImportRequest(w http.ResponseWriter, r *http.Request) {
runtimeID := chi.URLParam(r, "runtimeId")
rt, ok := h.requireRuntimeLocalSkillAccess(w, r, runtimeID)
if !ok {
return
}
requestID := chi.URLParam(r, "requestId")
req, err := h.LocalSkillImportStore.Get(r.Context(), requestID)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to load request: "+err.Error())
return
}
if req == nil || req.RuntimeID != rt.runtimeID {
writeError(w, http.StatusNotFound, "request not found")
return
}
writeJSON(w, http.StatusOK, req)
}
func (h *Handler) ReportLocalSkillListResult(w http.ResponseWriter, r *http.Request) {
runtimeID := chi.URLParam(r, "runtimeId")
if _, ok := h.requireDaemonRuntimeAccess(w, r, runtimeID); !ok {
return
}
requestID := chi.URLParam(r, "requestId")
req, err := h.LocalSkillListStore.Get(r.Context(), requestID)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to load request: "+err.Error())
return
}
if req == nil || req.RuntimeID != runtimeID {
writeError(w, http.StatusNotFound, "request not found")
return
}
if runtimeLocalSkillRequestTerminal(req.Status) {
slog.Debug("ignoring stale runtime local skills report", "runtime_id", runtimeID, "request_id", requestID, "status", req.Status)
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
return
}
var body struct {
Status string `json:"status"`
Skills []RuntimeLocalSkillSummary `json:"skills"`
Supported *bool `json:"supported"`
Error string `json:"error"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
if body.Status == "completed" {
supported := true
if body.Supported != nil {
supported = *body.Supported
}
if err := h.LocalSkillListStore.Complete(r.Context(), requestID, body.Skills, supported); err != nil {
// Surface the store failure as 5xx so the daemon can retry instead
// of swallowing the report (leaves the request stuck in running
// until the server-side timeout, which is exactly the "looks OK but
// nothing happens" class of bug we're trying to avoid).
slog.Error("local skills Complete failed", "error", err, "request_id", requestID)
writeError(w, http.StatusInternalServerError, "failed to persist completion")
return
}
} else {
if err := h.LocalSkillListStore.Fail(r.Context(), requestID, body.Error); err != nil {
slog.Error("local skills Fail failed", "error", err, "request_id", requestID)
writeError(w, http.StatusInternalServerError, "failed to persist failure")
return
}
}
slog.Debug("runtime local skills report", "runtime_id", runtimeID, "request_id", requestID, "status", body.Status, "count", len(body.Skills))
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
func (h *Handler) ReportLocalSkillImportResult(w http.ResponseWriter, r *http.Request) {
runtimeID := chi.URLParam(r, "runtimeId")
rt, ok := h.requireDaemonRuntimeAccess(w, r, runtimeID)
if !ok {
return
}
requestID := chi.URLParam(r, "requestId")
req, err := h.LocalSkillImportStore.Get(r.Context(), requestID)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to load request: "+err.Error())
return
}
if req == nil || req.RuntimeID != runtimeID {
writeError(w, http.StatusNotFound, "request not found")
return
}
if runtimeLocalSkillRequestTerminal(req.Status) {
slog.Debug("ignoring stale runtime local skill import report", "runtime_id", runtimeID, "request_id", requestID, "status", req.Status)
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
return
}
var body struct {
Status string `json:"status"`
Skill *reportedRuntimeLocalSkill `json:"skill"`
Error string `json:"error"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
if body.Status != "completed" {
if err := h.LocalSkillImportStore.Fail(r.Context(), requestID, body.Error); err != nil {
slog.Error("local skill import Fail failed", "error", err, "request_id", requestID)
writeError(w, http.StatusInternalServerError, "failed to persist failure")
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
return
}
if body.Skill == nil {
if err := h.LocalSkillImportStore.Fail(r.Context(), requestID, "daemon returned an empty skill bundle"); err != nil {
slog.Error("local skill import Fail failed", "error", err, "request_id", requestID)
writeError(w, http.StatusInternalServerError, "failed to persist failure")
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
return
}
creatorUUID, err := util.ParseUUID(req.CreatorID)
if err != nil {
failMsg := "stored local skill import creator_id is invalid"
if ferr := h.LocalSkillImportStore.Fail(r.Context(), requestID, failMsg); ferr != nil {
slog.Error("local skill import Fail failed", "error", ferr, "request_id", requestID)
}
writeError(w, http.StatusInternalServerError, failMsg)
return
}
name := body.Skill.Name
if req.Name != nil {
name = *req.Name
}
description := body.Skill.Description
if req.Description != nil {
description = *req.Description
}
files := make([]CreateSkillFileRequest, 0, len(body.Skill.Files))
for _, f := range body.Skill.Files {
if !validateFilePath(f.Path) {
continue
}
files = append(files, f)
}
resp, err := h.createSkillWithFiles(r.Context(), skillCreateInput{
WorkspaceID: rt.WorkspaceID,
CreatorID: creatorUUID,
Name: name,
Description: description,
Content: body.Skill.Content,
Config: map[string]any{
"origin": map[string]any{
"type": "runtime_local",
"runtime_id": runtimeID,
"provider": body.Skill.Provider,
"source_path": body.Skill.SourcePath,
},
},
Files: files,
})
if err != nil {
failMsg := err.Error()
if isUniqueViolation(err) {
failMsg = "a skill with this name already exists"
}
if ferr := h.LocalSkillImportStore.Fail(r.Context(), requestID, failMsg); ferr != nil {
slog.Error("local skill import Fail failed", "error", ferr, "request_id", requestID)
writeError(w, http.StatusInternalServerError, "failed to persist failure")
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
return
}
if err := h.LocalSkillImportStore.Complete(r.Context(), requestID, resp.SkillResponse); err != nil {
// We already wrote the Skill to Postgres. If the store-side Complete
// fails we can't leave that Skill orphaned: the daemon will retry on
// 5xx and re-create it, which blows up on the unique-name constraint
// and looks to the user like "import keeps failing". Roll back our
// side-effects so the retry lands on a clean slate.
slog.Error("local skill import Complete failed — rolling back created skill",
"error", err, "request_id", requestID, "skill_id", resp.ID)
if delErr := h.Queries.DeleteSkill(r.Context(), db.DeleteSkillParams{
ID: parseUUID(resp.ID),
WorkspaceID: rt.WorkspaceID,
}); delErr != nil {
slog.Warn("orphan skill rollback failed", "error", delErr, "skill_id", resp.ID)
}
writeError(w, http.StatusInternalServerError, "failed to persist import completion")
return
}
h.publish(protocol.EventSkillCreated, uuidToString(rt.WorkspaceID), "member", req.CreatorID, map[string]any{"skill": resp})
slog.Debug("runtime local skill imported", "runtime_id", runtimeID, "request_id", requestID, "skill_id", resp.ID)
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}