package backendapp import ( "context" "errors" "fmt" "io" "net/http" "os" "path/filepath" "sort" "strings" "time" "github.com/gin-gonic/gin" "github.com/kandev/kandev/internal/authz" canvasservice "github.com/kandev/kandev/internal/canvas" "github.com/kandev/kandev/internal/common/constants" "github.com/kandev/kandev/internal/orchestrator" "github.com/kandev/kandev/internal/plugins" "github.com/kandev/kandev/internal/plugins/instances" "github.com/kandev/kandev/internal/plugins/webapp" "github.com/kandev/kandev/internal/task/models" taskservice "github.com/kandev/kandev/internal/task/service" userstore "github.com/kandev/kandev/internal/user/store" "github.com/kandev/kandev/internal/worktree/copyfiles" v1 "github.com/kandev/kandev/pkg/api/v1" ) // canvasHTTPHandler is the workspace-authorized browser API for canvas metadata // and host operations. Source transfer and publishing stay on the agent MCP // path; browsers receive only metadata and short-lived runtime capabilities. type canvasHTTPHandler struct { canvases *canvasservice.Service plugins *plugins.Service tasks *taskservice.Service editor *canvasEditService distribution *canvasservice.DistributionService } type canvasWorkspaceOwnerAccess interface { AuthorizeWorkspaceAccess(context.Context, string) error GetWorkspace(context.Context, string) (*models.Workspace, error) } type canvasEditTaskStore interface { AuthorizeWorkspaceAccess(context.Context, string) error GetWorkspace(context.Context, string) (*models.Workspace, error) CreateTask(context.Context, *taskservice.CreateTaskRequest) (taskservice.CreateTaskResult, error) DeleteTask(context.Context, string) error } type canvasEditCanvasStore interface { Get(context.Context, string) (*canvasservice.Canvas, error) } type canvasEditReleaseStore interface { GetRelease(context.Context, string) (instances.Release, error) } type canvasEditArtifactStore interface { ReadFiles(webapp.Artifact) (map[string][]byte, error) } type canvasEditSessionStore interface { SetSessionMetadataKey(context.Context, string, string, interface{}) error } type canvasEditSessionLauncher interface { LaunchSession(context.Context, *orchestrator.LaunchSessionRequest) (*orchestrator.LaunchSessionResponse, error) PromptTask(context.Context, string, string, string, string, bool, []v1.MessageAttachment, bool) (*orchestrator.PromptResult, error) } type canvasEditResult struct { TaskID string `json:"task_id"` SessionID string `json:"session_id"` CanvasID string `json:"canvas_id"` AgentExecutionID string `json:"agent_execution_id,omitempty"` } type canvasEditError struct { status int code string message string cause error } func (e *canvasEditError) Error() string { if e == nil { return "canvas edit failed" } if e.cause == nil { return e.message } return fmt.Sprintf("%s: %v", e.message, e.cause) } func newCanvasEditError(status int, code, message string, cause error) *canvasEditError { return &canvasEditError{status: status, code: code, message: message, cause: cause} } type canvasEditService struct { tasks canvasEditTaskStore canvases canvasEditCanvasStore releases canvasEditReleaseStore artifacts canvasEditArtifactStore launcher canvasEditSessionLauncher sessions canvasEditSessionStore executions canvasEditExecutionResolver } func newCanvasEditService( tasks canvasEditTaskStore, canvases canvasEditCanvasStore, releases canvasEditReleaseStore, artifacts canvasEditArtifactStore, launcher canvasEditSessionLauncher, sessions canvasEditSessionStore, executions canvasEditExecutionResolver, ) *canvasEditService { return &canvasEditService{ tasks: tasks, canvases: canvases, releases: releases, artifacts: artifacts, launcher: launcher, sessions: sessions, executions: executions, } } func (s *canvasEditService) start(ctx context.Context, canvasID, requestedPrompt string) (canvasEditResult, error) { if s == nil || s.tasks == nil || s.canvases == nil || s.releases == nil || s.artifacts == nil || s.launcher == nil || s.sessions == nil || s.executions == nil { return canvasEditResult{}, newCanvasEditError(http.StatusServiceUnavailable, "canvas_edit_unavailable", "canvas editing is unavailable", nil) } item, entries, err := s.loadEditableCanvas(ctx, canvasID) if err != nil { return canvasEditResult{}, err } agentProfileID, executorID, err := s.editWorkspaceDefaults(ctx, item.WorkspaceID) if err != nil { return canvasEditResult{}, err } task, err := s.createEditTask(ctx, item, agentProfileID, executorID, requestedPrompt) if err != nil { return canvasEditResult{}, err } launchResponse, err := s.launchEditSession(ctx, task, item, agentProfileID, executorID, entries, requestedPrompt) if err != nil { return s.rollbackEditTask(task.ID, err) } return canvasEditResult{ TaskID: task.ID, SessionID: launchResponse.SessionID, CanvasID: item.ID, AgentExecutionID: launchResponse.AgentExecutionID, }, nil } func (s *canvasEditService) rollbackEditTask(taskID string, err error) (canvasEditResult, error) { var editErr *canvasEditError if !errors.As(err, &editErr) || editErr == nil { editErr = newCanvasEditError(http.StatusInternalServerError, "canvas_edit_failed", "canvas edit session could not be started", err) } if cleanupErr := s.rollbackTask(taskID); cleanupErr != nil { editErr.cause = errors.Join(editErr.cause, fmt.Errorf("rollback canvas edit task: %w", cleanupErr)) } return canvasEditResult{}, editErr } func (s *canvasEditService) loadEditableCanvas(ctx context.Context, canvasID string) (*canvasservice.Canvas, []copyfiles.Entry, error) { item, err := s.canvases.Get(ctx, strings.TrimSpace(canvasID)) if err != nil || item == nil { return nil, nil, newCanvasEditError(http.StatusNotFound, "canvas_not_found", "canvas was not found", nil) } if err := s.tasks.AuthorizeWorkspaceAccess(ctx, item.WorkspaceID); err != nil { return nil, nil, newCanvasEditError(http.StatusNotFound, "canvas_not_found", "canvas was not found", nil) } if item.ScopeKind != canvasservice.ScopeWorkspace || item.TaskID != "" { return nil, nil, newCanvasEditError(http.StatusNotFound, "canvas_not_found", "canvas was not found", nil) } if item.Status != instances.StatusActive || item.ActiveReleaseID == "" { return nil, nil, newCanvasEditError(http.StatusConflict, "pending_first_release", "canvas has no active release to edit", nil) } if item.ActiveReleaseStatus != instances.ValidationValid { return nil, nil, newCanvasEditError(http.StatusConflict, "canvas_not_editable", "canvas active release is not valid", nil) } release, err := s.releases.GetRelease(ctx, item.ActiveReleaseID) if err != nil || release.InstanceID != item.PluginInstanceID || release.ValidationStatus != instances.ValidationValid { return nil, nil, newCanvasEditError(http.StatusConflict, "canvas_not_editable", "canvas active release is unavailable", err) } files, err := s.artifacts.ReadFiles(webapp.Artifact{Digest: release.PackageDigest, RelativePath: release.ArtifactPath, Bytes: release.ArtifactBytes, Available: true}) if err != nil { return nil, nil, newCanvasEditError(http.StatusConflict, "source_unavailable", "canvas active release source is unavailable", err) } entries := canvasEditSourceEntries(item.ID, files) if len(entries) == 0 { return nil, nil, newCanvasEditError(http.StatusConflict, "source_unavailable", "canvas active release source is empty", nil) } return item, entries, nil } func (s *canvasEditService) editWorkspaceDefaults(ctx context.Context, workspaceID string) (string, string, error) { workspace, err := s.tasks.GetWorkspace(ctx, workspaceID) if err != nil || workspace == nil { return "", "", newCanvasEditError(http.StatusNotFound, "canvas_not_found", "canvas workspace was not found", nil) } agentProfileID := workspaceDefaultID(workspace.DefaultAgentProfileID) if agentProfileID == "" { return "", "", newCanvasEditError(http.StatusBadRequest, "agent_profile_required", "workspace has no default agent profile configured", nil) } return agentProfileID, workspaceDefaultID(workspace.DefaultExecutorID), nil } func (s *canvasEditService) createEditTask(ctx context.Context, item *canvasservice.Canvas, agentProfileID, executorID, requestedPrompt string) (*models.Task, error) { metadata := map[string]interface{}{ "origin": canvasEditOrigin, "canvas_id": item.ID, "canvas_release_id": item.ActiveReleaseID, models.MetaKeyAgentProfileID: agentProfileID, } if executorID != "" { metadata[models.MetaKeyExecutorID] = executorID } result, err := s.tasks.CreateTask(ctx, &taskservice.CreateTaskRequest{ WorkspaceID: item.WorkspaceID, Title: "Edit canvas: " + item.Title, Description: strings.TrimSpace(requestedPrompt), IsEphemeral: true, Origin: canvasEditOrigin, Metadata: metadata, }) if err == nil && result.Task != nil && result.Outcome == taskservice.CreateTaskOutcomeCreated { return result.Task, nil } if err == nil { err = errors.New("canvas edit task was not created") } return nil, newCanvasEditError(http.StatusInternalServerError, "task_create_failed", "canvas edit task could not be created", err) } func (s *canvasEditService) launchEditSession(ctx context.Context, task *models.Task, item *canvasservice.Canvas, agentProfileID, executorID string, entries []copyfiles.Entry, requestedPrompt string) (*orchestrator.LaunchSessionResponse, error) { launchResponse, err := s.launcher.LaunchSession(ctx, &orchestrator.LaunchSessionRequest{ TaskID: task.ID, Intent: orchestrator.IntentStart, AgentProfileID: agentProfileID, ExecutorID: executorID, }) if err != nil || launchResponse == nil || launchResponse.SessionID == "" || launchResponse.AgentExecutionID == "" { if err == nil { err = errors.New("canvas edit session launch returned no execution") } return nil, newCanvasEditError(http.StatusInternalServerError, "session_start_failed", "canvas edit session could not be started", err) } target := canvasEditSessionTarget{Origin: canvasEditOrigin, TaskID: task.ID, CanvasID: item.ID, ReleaseID: item.ActiveReleaseID} if err := s.sessions.SetSessionMetadataKey(ctx, launchResponse.SessionID, canvasEditSessionTargetMetadataKey, target); err != nil { return nil, newCanvasEditError(http.StatusInternalServerError, "session_target_failed", "canvas edit session target could not be recorded", err) } if err := s.materializeEditSource(ctx, launchResponse.AgentExecutionID, entries); err != nil { return nil, newCanvasEditError(http.StatusInternalServerError, "source_materialization_failed", "canvas source could not be materialized", err) } prompt := canvasEditPrompt(*item, promptSourceRoot(item.ID), requestedPrompt) if _, err := s.launcher.PromptTask(ctx, task.ID, launchResponse.SessionID, prompt, "", false, nil, false); err != nil { return nil, newCanvasEditError(http.StatusInternalServerError, "prompt_dispatch_failed", "canvas edit instructions could not be sent", err) } return launchResponse, nil } func (s *canvasEditService) materializeEditSource(ctx context.Context, executionID string, entries []copyfiles.Entry) error { copier, err := s.executions.ResolveAgentCtl(executionID) if err != nil || copier == nil { if err == nil { err = errors.New("agentctl client is unavailable") } return err } response, err := copier.CopyFiles(ctx, "", entries) if err != nil { return err } if !response.Present { return errors.New("copy-files returned no response") } if len(response.Warnings) > 0 { return fmt.Errorf("copy-files returned warnings: %s", strings.Join(response.Warnings, "; ")) } return nil } func (s *canvasEditService) rollbackTask(taskID string) error { ctx, cancel := context.WithTimeout(context.Background(), constants.TaskDeleteTimeout) defer cancel() return s.tasks.DeleteTask(ctx, taskID) } func workspaceDefaultID(value *string) string { if value == nil { return "" } return strings.TrimSpace(*value) } func canvasEditSourceEntries(canvasID string, files map[string][]byte) []copyfiles.Entry { paths := make([]string, 0, len(files)) for name := range files { paths = append(paths, name) } sort.Strings(paths) entries := make([]copyfiles.Entry, 0, len(paths)) for _, name := range paths { entries = append(entries, copyfiles.Entry{ RelPath: filepath.ToSlash(filepath.Join(canvasSourceRoot(canvasID), filepath.FromSlash(name))), Mode: os.FileMode(0o600), Content: files[name], }) } return entries } func promptSourceRoot(canvasID string) string { return canvasSourceRoot(canvasID) } func canvasEditPrompt(item canvasservice.Canvas, sourceRoot, requestedPrompt string) string { requestedPrompt = strings.TrimSpace(requestedPrompt) if requestedPrompt == "" { requestedPrompt = "Review the current canvas and make a useful improvement." } grants := "none" if len(item.EffectiveGrants) > 0 { values := make([]string, 0, len(item.EffectiveGrants)) for _, grant := range item.EffectiveGrants { resource := grant.Resource if grant.PermissionKind == "network" { resource = grant.NetworkOrigin } values = append(values, grant.PermissionKind+":"+resource+" (scope "+grant.ScopeCeiling+")") } grants = strings.Join(values, ", ") } return fmt.Sprintf( "Edit canvas %q. If the core authoring bundle is not already available, read it once with read_canvas_authoring_skill_kandev without a path. Inspect the current source in %s, use only these effective grants: %s, apply the requested change, validate the result, and publish it with publish_canvas_kandev using canvas_id %s and source_path %s. Requested change: %s", item.Title, sourceRoot, grants, item.ID, sourceRoot, requestedPrompt, ) } // registerCanvasRoutes mounts the feature-gated canvas host API. This helper // is called only from registerRoutes when features.canvases is enabled. func registerCanvasRoutes(p routeParams) { if p.router == nil || p.services == nil || p.services.Canvas == nil || p.services.Plugins == nil { return } h := &canvasHTTPHandler{ canvases: p.services.Canvas, plugins: p.services.Plugins, tasks: p.taskSvc, distribution: p.services.CanvasDistribution, } if p.taskSvc != nil && p.taskRepo != nil && p.orchestratorSvc != nil && p.lifecycleMgr != nil { instanceStore := p.services.Plugins.Instances() artifactStore := p.services.Plugins.WebArtifacts() if instanceStore != nil && artifactStore != nil { h.editor = newCanvasEditService( p.taskSvc, p.services.Canvas, instanceStore, artifactStore, p.orchestratorSvc, p.taskRepo, lifecycleCanvasExecutionResolver{manager: p.lifecycleMgr}, ) } } // Keep the canonical resource-shaped paths alongside the short aliases. // The aliases make the API convenient for direct hosts while preserving a // predictable path for the web client. p.router.GET("/api/v1/tasks/:id/canvases", h.listTask) p.router.GET("/api/v1/canvases/task/:taskID", h.listTask) p.router.GET("/api/v1/canvases/tasks/:taskID", h.listTask) p.router.GET("/api/v1/workspaces/:id/canvases", h.listWorkspace) p.router.GET("/api/v1/canvases/workspace/:workspaceID", h.listWorkspace) p.router.GET("/api/v1/canvases/workspaces/:workspaceID", h.listWorkspace) registerCanvasDistributionRoutes(p.router, h) p.router.GET("/api/v1/canvases/:canvasID", h.get) p.router.PATCH("/api/v1/canvases/:canvasID", h.rename) p.router.GET("/api/v1/canvases/:canvasID/releases", h.releases) p.router.GET("/api/v1/canvases/:canvasID/promotion-preview", h.promotionPreview) p.router.POST("/api/v1/canvases/:canvasID/promotion", h.promote) p.router.GET("/api/v1/canvases/:canvasID/workspace-data-preview", h.workspaceDataPreview) p.router.POST("/api/v1/canvases/:canvasID/workspace-data", h.enableWorkspaceData) p.router.POST("/api/v1/canvases/:canvasID/releases/:releaseID/approve", h.approveRelease) p.router.POST("/api/v1/canvases/:canvasID/releases/:releaseID/reject", h.rejectRelease) p.router.POST("/api/v1/canvases/:canvasID/rollback", h.rollback) p.router.POST("/api/v1/canvases/:canvasID/edit", h.edit) p.router.POST("/api/v1/canvases/:canvasID/runtime", h.runtime) p.router.GET("/api/v1/canvases/:canvasID/runtime", h.runtime) p.router.POST("/api/v1/canvases/:canvasID/archive", h.archive) p.router.POST("/api/v1/canvases/:canvasID/restore", h.restore) p.router.POST("/api/v1/canvases/:canvasID/remove", h.remove) p.router.DELETE("/api/v1/canvases/:canvasID", h.remove) } type canvasHTTPResponse struct { ID string `json:"id"` PluginInstanceID string `json:"plugin_instance_id"` PluginID string `json:"plugin_id"` WorkspaceID string `json:"workspace_id"` TaskID string `json:"task_id,omitempty"` OriginTaskID string `json:"origin_task_id,omitempty"` ScopeKind string `json:"scope_kind"` DataScopeKind string `json:"data_scope_kind"` Title string `json:"title"` CreatedBySessionID string `json:"created_by_session_id,omitempty"` PromotedByUserID string `json:"promoted_by_user_id,omitempty"` PromotedAt *time.Time `json:"promoted_at,omitempty"` Status string `json:"status"` ActiveReleaseID string `json:"active_release_id,omitempty"` ActiveReleaseStatus string `json:"active_release_status,omitempty"` ActiveReleaseError string `json:"active_release_error,omitempty"` GrantGeneration int64 `json:"grant_generation,omitempty"` EffectiveGrants []canvasservice.GrantProjection `json:"effective_grants,omitempty"` ActiveRelease *canvasReleaseResponse `json:"active_release,omitempty"` PendingRelease *canvasReleaseResponse `json:"pending_release,omitempty"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } type canvasReleaseResponse struct { ID string `json:"id"` PackageID string `json:"package_id,omitempty"` Version string `json:"version,omitempty"` DisplayName string `json:"display_name,omitempty"` Description string `json:"description,omitempty"` Author string `json:"author,omitempty"` License string `json:"license,omitempty"` SourceMode string `json:"source_mode,omitempty"` MinKandevVersion string `json:"min_kandev_version,omitempty"` RepoURL string `json:"repo_url,omitempty"` PackageDigest string `json:"package_digest,omitempty"` ValidationStatus string `json:"validation_status"` ValidationError string `json:"validation_error,omitempty"` Permissions *canvasservice.PermissionSummary `json:"permissions,omitempty"` MissingPermissions []string `json:"missing_permissions,omitempty"` PermissionDigest string `json:"permission_digest,omitempty"` SourceActorKind string `json:"source_actor_kind,omitempty"` SourceUserID string `json:"source_user_id,omitempty"` SourceTaskID string `json:"source_task_id,omitempty"` SourceTaskTitle string `json:"source_task_title,omitempty"` SourceSessionID string `json:"source_session_id,omitempty"` SourceSessionName string `json:"source_session_name,omitempty"` ProtocolVersion int `json:"protocol_version,omitempty"` CreatedAt time.Time `json:"created_at"` } func canvasResponse(value canvasservice.Canvas) canvasHTTPResponse { result := canvasHTTPResponse{ ID: value.ID, PluginInstanceID: value.PluginInstanceID, PluginID: value.PluginID, WorkspaceID: value.WorkspaceID, TaskID: value.TaskID, OriginTaskID: value.OriginTaskID, ScopeKind: value.ScopeKind, DataScopeKind: value.DataScopeKind, Title: value.Title, CreatedBySessionID: value.CreatedBySessionID, PromotedByUserID: value.PromotedByUserID, PromotedAt: value.PromotedAt, Status: value.Status, ActiveReleaseID: value.ActiveReleaseID, ActiveReleaseStatus: value.ActiveReleaseStatus, ActiveReleaseError: value.ActiveReleaseError, GrantGeneration: value.GrantGeneration, EffectiveGrants: value.EffectiveGrants, CreatedAt: value.CreatedAt, UpdatedAt: value.UpdatedAt, } if value.ActiveRelease != nil { result.ActiveRelease = releaseResponseFromMetadata(value.ActiveRelease) } if value.PendingRelease != nil { result.PendingRelease = releaseResponseFromMetadata(value.PendingRelease) } return result } func (h *canvasHTTPHandler) listTask(c *gin.Context) { taskID := strings.TrimSpace(canvasRouteParam(c, "id", "taskID")) taskSvc := h.tasks if taskSvc == nil { writeCanvasError(c, http.StatusNotFound, "canvas_not_found", nil) return } task, err := taskSvc.GetTask(c.Request.Context(), taskID) if err != nil || task == nil { writeCanvasError(c, http.StatusNotFound, "canvas_not_found", nil) return } if err := taskSvc.AuthorizeWorkspaceAccess(c.Request.Context(), task.WorkspaceID); err != nil { writeCanvasError(c, http.StatusNotFound, "canvas_not_found", nil) return } includeArchived, ok := canvasIncludeArchived(c) if !ok { return } canvases, err := h.canvases.ListForTask(c.Request.Context(), task.WorkspaceID, taskID, includeArchived) if err != nil { h.writeError(c, err) return } writeCanvasJSON(c, http.StatusOK, map[string]interface{}{"canvases": mapCanvasResponses(canvases)}) } func (h *canvasHTTPHandler) listWorkspace(c *gin.Context) { workspaceID := strings.TrimSpace(canvasRouteParam(c, "id", "workspaceID")) taskSvc := h.tasks if taskSvc == nil { writeCanvasError(c, http.StatusNotFound, "canvas_not_found", nil) return } if err := taskSvc.AuthorizeWorkspaceAccess(c.Request.Context(), workspaceID); err != nil { writeCanvasError(c, http.StatusNotFound, "canvas_not_found", nil) return } includeArchived, ok := canvasIncludeArchived(c) if !ok { return } canvases, err := h.canvases.ListWorkspaceCanvases(c.Request.Context(), workspaceID, includeArchived) if err != nil { h.writeError(c, err) return } writeCanvasJSON(c, http.StatusOK, map[string]interface{}{"canvases": mapCanvasResponses(canvases)}) } func (h *canvasHTTPHandler) get(c *gin.Context) { canvas, err := h.canvases.Get(c.Request.Context(), c.Param("canvasID")) if err != nil { h.writeError(c, err) return } if !h.authorizeWorkspace(c, canvas.WorkspaceID) { return } response := canvasResponse(*canvas) h.decorateCanvasReleaseSources(c.Request.Context(), canvas.WorkspaceID, response.ActiveRelease, response.PendingRelease) writeCanvasJSON(c, http.StatusOK, response) } func (h *canvasHTTPHandler) rename(c *gin.Context) { item, err := h.canvases.Get(c.Request.Context(), c.Param("canvasID")) if err != nil { h.writeError(c, err) return } if !h.authorizeWorkspaceManage(c, item.WorkspaceID) { return } var request struct { Title string `json:"title"` } if err := c.ShouldBindJSON(&request); err != nil { writeCanvasError(c, http.StatusBadRequest, "invalid_request", nil) return } updated, err := h.canvases.Rename(c.Request.Context(), item.ID, request.Title) if err != nil { h.writeError(c, err) return } writeCanvasJSON(c, http.StatusOK, canvasResponse(*updated)) } func (h *canvasHTTPHandler) releases(c *gin.Context) { canvas, err := h.canvases.Get(c.Request.Context(), c.Param("canvasID")) if err != nil { h.writeError(c, err) return } if !h.authorizeWorkspace(c, canvas.WorkspaceID) { return } instanceStore := h.plugins.Instances() instance, err := instanceStore.Get(c.Request.Context(), canvas.PluginInstanceID) if err != nil { h.writeError(c, err) return } grants, err := instanceStore.ListGrants(c.Request.Context(), canvas.PluginInstanceID) if err != nil { h.writeError(c, err) return } list, err := instanceStore.ListReleases(c.Request.Context(), canvas.PluginInstanceID) if err != nil { h.writeError(c, err) return } result := make([]canvasReleaseResponse, 0, len(list)) for _, release := range list { response := releaseResponse(release, instance.ScopeKind, grants) h.decorateCanvasReleaseSources(c.Request.Context(), canvas.WorkspaceID, &response) result = append(result, response) } writeCanvasJSON(c, http.StatusOK, map[string]interface{}{"releases": result}) } func releaseResponse(release instances.Release, scope string, grants []instances.Grant) canvasReleaseResponse { return *releaseResponseFromMetadata(canvasservice.ReleaseMetadataForHTTP(release, scope, grants)) } func releaseResponseFromMetadata(value *canvasservice.ReleaseMetadata) *canvasReleaseResponse { if value == nil { return nil } return &canvasReleaseResponse{ ID: value.ID, PackageDigest: value.PackageDigest, ValidationStatus: value.ValidationStatus, PackageID: value.PackageID, Version: value.Version, DisplayName: value.DisplayName, Description: value.Description, Author: value.Author, License: value.License, SourceMode: value.SourceMode, MinKandevVersion: value.MinKandevVersion, RepoURL: value.RepoURL, ValidationError: value.ValidationError, Permissions: value.Permissions, MissingPermissions: value.MissingPermissions, PermissionDigest: value.PermissionDigest, SourceActorKind: value.SourceActorKind, SourceUserID: value.SourceUserID, SourceTaskID: value.SourceTaskID, SourceSessionID: value.SourceSessionID, ProtocolVersion: value.ProtocolVersion, CreatedAt: value.CreatedAt, } } func (h *canvasHTTPHandler) promotionPreview(c *gin.Context) { item, err := h.canvases.Get(c.Request.Context(), c.Param("canvasID")) if err != nil { h.writeError(c, err) return } if item == nil || !h.authorizeWorkspace(c, item.WorkspaceID) { return } preview, err := h.canvases.PromotionPreview(c.Request.Context(), item.ID) if err != nil { h.writeError(c, err) return } if preview == nil || preview.Canvas == nil { return } sourceLabels := projectCanvasSourceLabels( c.Request.Context(), h.tasks, item.WorkspaceID, preview.SourceTaskID, preview.SourceSessionID, ) writeCanvasJSON(c, http.StatusOK, map[string]interface{}{ "canvas_id": preview.Canvas.ID, "title": preview.Canvas.Title, "origin_task_id": preview.Canvas.OriginTaskID, "active_release": releaseResponseFromCanvas(preview.Canvas), "source_actor_kind": preview.SourceActorKind, "source_user_id": preview.SourceUserID, "source_task_id": preview.SourceTaskID, "source_task_title": sourceLabels.TaskTitle, "source_session_id": preview.SourceSessionID, "source_session_name": sourceLabels.SessionName, "permissions": preview.Permissions, "active_release_id": preview.ActiveReleaseID, "permission_digest": preview.PermissionDigest, "grant_generation": preview.GrantGeneration, "current_scope": preview.CurrentScope, "target_scope": preview.TargetScope, "current_data_scope_kind": preview.CurrentDataScope, "target_data_scope_kind": preview.TargetDataScope, "placement": preview.Placement, }) } func releaseResponseFromCanvas(value *canvasservice.Canvas) *canvasReleaseResponse { if value == nil || value.ActiveRelease == nil { return nil } return releaseResponseFromMetadata(value.ActiveRelease) } type canvasPromotionRequest struct { ExpectedReleaseID string `json:"expected_release_id"` ExpectedPermissionDigest string `json:"expected_permission_digest"` ExpectedGrantGeneration *int64 `json:"expected_grant_generation"` } func (h *canvasHTTPHandler) promote(c *gin.Context) { canvas, err := h.canvases.Get(c.Request.Context(), c.Param("canvasID")) if err != nil { h.writeError(c, err) return } if !h.authorizeWorkspace(c, canvas.WorkspaceID) { return } var request canvasPromotionRequest if c.Request.Body == nil || c.ShouldBindJSON(&request) != nil || strings.TrimSpace(request.ExpectedReleaseID) == "" || strings.TrimSpace(request.ExpectedPermissionDigest) == "" || request.ExpectedGrantGeneration == nil || *request.ExpectedGrantGeneration < 0 { writeCanvasError(c, http.StatusBadRequest, "promotion_review_required", nil) return } updated, err := h.canvases.PromoteCanvasReviewed(c.Request.Context(), canvas.ID, canvasRuntimeUser(c), request.ExpectedReleaseID, request.ExpectedPermissionDigest, *request.ExpectedGrantGeneration) if err != nil { h.writeError(c, err) return } writeCanvasJSON(c, http.StatusOK, canvasResponse(*updated)) } func (h *canvasHTTPHandler) workspaceDataPreview(c *gin.Context) { item, err := h.canvases.Get(c.Request.Context(), c.Param("canvasID")) if err != nil { h.writeError(c, err) return } if item == nil || !h.authorizeWorkspaceOwner(c, item.WorkspaceID) { return } preview, err := h.canvases.WorkspaceDataPreview(c.Request.Context(), item.ID) if err != nil { h.writeError(c, err) return } writeCanvasJSON(c, http.StatusOK, map[string]interface{}{ "canvas": canvasResponse(*preview.Canvas), "active_release_id": preview.ActiveReleaseID, "permission_digest": preview.PermissionDigest, "grant_generation": preview.GrantGeneration, "current_data_scope_kind": preview.CurrentDataScopeKind, "target_data_scope_kind": preview.TargetDataScopeKind, "permissions": preview.Permissions, }) } func (h *canvasHTTPHandler) enableWorkspaceData(c *gin.Context) { item, err := h.canvases.Get(c.Request.Context(), c.Param("canvasID")) if err != nil { h.writeError(c, err) return } if item == nil || !h.authorizeWorkspaceOwner(c, item.WorkspaceID) { return } var request canvasPromotionRequest if c.Request.Body == nil || c.ShouldBindJSON(&request) != nil || strings.TrimSpace(request.ExpectedReleaseID) == "" || strings.TrimSpace(request.ExpectedPermissionDigest) == "" || request.ExpectedGrantGeneration == nil || *request.ExpectedGrantGeneration < 0 { writeCanvasError(c, http.StatusBadRequest, "workspace_data_review_required", nil) return } updated, err := h.canvases.EnableWorkspaceDataReviewed( c.Request.Context(), item.ID, canvasRuntimeUser(c), request.ExpectedReleaseID, request.ExpectedPermissionDigest, *request.ExpectedGrantGeneration, ) if err != nil { h.writeError(c, err) return } writeCanvasJSON(c, http.StatusOK, canvasResponse(*updated)) } func (h *canvasHTTPHandler) approveRelease(c *gin.Context) { h.releaseMutation(c, func(ctx context.Context, canvasID, releaseID, userID string) (*canvasservice.Canvas, error) { return h.canvases.ApproveRelease(ctx, canvasID, releaseID, userID) }) } func (h *canvasHTTPHandler) rejectRelease(c *gin.Context) { h.releaseMutation(c, func(ctx context.Context, canvasID, releaseID, _ string) (*canvasservice.Canvas, error) { return h.canvases.RejectRelease(ctx, canvasID, releaseID) }) } func (h *canvasHTTPHandler) releaseMutation(c *gin.Context, mutation func(context.Context, string, string, string) (*canvasservice.Canvas, error)) { canvas, err := h.canvases.Get(c.Request.Context(), c.Param("canvasID")) if err != nil { h.writeError(c, err) return } if !h.authorizeWorkspace(c, canvas.WorkspaceID) { return } updated, err := mutation(c.Request.Context(), canvas.ID, c.Param("releaseID"), canvasRuntimeUser(c)) if err != nil { h.writeError(c, err) return } writeCanvasJSON(c, http.StatusOK, canvasResponse(*updated)) } func (h *canvasHTTPHandler) rollback(c *gin.Context) { canvas, err := h.canvases.Get(c.Request.Context(), c.Param("canvasID")) if err != nil { h.writeError(c, err) return } if !h.authorizeWorkspace(c, canvas.WorkspaceID) { return } var body struct { ReleaseID string `json:"release_id"` } if c.Request.Body != nil { if err := c.ShouldBindJSON(&body); err != nil && !errors.Is(err, io.EOF) { writeCanvasError(c, http.StatusBadRequest, "invalid_request", nil) return } } updated, err := h.canvases.RollbackRelease(c.Request.Context(), canvas.ID, body.ReleaseID) if err != nil { h.writeError(c, err) return } writeCanvasJSON(c, http.StatusOK, canvasResponse(*updated)) } func (h *canvasHTTPHandler) edit(c *gin.Context) { if h.editor == nil { writeCanvasError(c, http.StatusServiceUnavailable, "canvas_edit_unavailable", nil) return } var request struct { Prompt string `json:"prompt,omitempty"` } if c.Request.Body != nil { if err := c.ShouldBindJSON(&request); err != nil && !errors.Is(err, io.EOF) { writeCanvasError(c, http.StatusBadRequest, "invalid_request", nil) return } } result, err := h.editor.start(c.Request.Context(), c.Param("canvasID"), request.Prompt) if err != nil { var editErr *canvasEditError if errors.As(err, &editErr) && editErr != nil { writeCanvasError(c, editErr.status, editErr.code, nil) return } writeCanvasError(c, http.StatusInternalServerError, "canvas_edit_failed", nil) return } writeCanvasJSON(c, http.StatusOK, result) } type canvasRuntimeRequest struct { WebAppKey string `json:"web_app_key"` Placement string `json:"placement"` } func (h *canvasHTTPHandler) runtime(c *gin.Context) { canvas, err := h.canvases.Get(c.Request.Context(), c.Param("canvasID")) if err != nil { h.writeError(c, err) return } if !h.authorizeWorkspace(c, canvas.WorkspaceID) { return } if canvas.Status != instances.StatusActive || canvas.ActiveReleaseID == "" { writeCanvasError(c, http.StatusConflict, "pending_first_release", map[string]interface{}{"status": canvas.Status}) return } if canvas.ActiveReleaseStatus != instances.ValidationValid { writeCanvasError(c, http.StatusConflict, canvasHostError(canvas.ActiveReleaseStatus), map[string]interface{}{"status": canvas.ActiveReleaseStatus, "error": canvas.ActiveReleaseError}) return } var request canvasRuntimeRequest if c.Request.Method == http.MethodPost { if err := c.ShouldBindJSON(&request); err != nil && !errors.Is(err, http.ErrNotSupported) && !errors.Is(err, io.EOF) { writeCanvasError(c, http.StatusBadRequest, "invalid_request", nil) return } } path, release, app, binding, err := h.runtimeBinding(c, *canvas, request) if err != nil { h.writeError(c, err) return } writeCanvasJSON(c, http.StatusOK, map[string]interface{}{ "runtime_url": path, "release_id": release.ID, "web_app_key": app.Key, "placement": appPlacement(app, request.Placement, canvas.ScopeKind), "expires_in_seconds": int(webapp.RuntimeTokenTTL / time.Second), "canvas": canvasResponse(*canvas), "binding": map[string]interface{}{"scope_kind": binding.ScopeKind, "grant_generation": binding.GrantGeneration}, }) } func (h *canvasHTTPHandler) archive(c *gin.Context) { h.mutate(c, func(ctx context.Context, id string) (*canvasservice.Canvas, error) { return h.canvases.Archive(ctx, id) }) } func (h *canvasHTTPHandler) restore(c *gin.Context) { h.mutate(c, func(ctx context.Context, id string) (*canvasservice.Canvas, error) { return h.canvases.Restore(ctx, id) }) } func (h *canvasHTTPHandler) mutate(c *gin.Context, mutation func(context.Context, string) (*canvasservice.Canvas, error)) { // Keeping mutation routing in one place prevents archive and restore from // accidentally gaining different authorization behavior. canvas, err := h.canvases.Get(c.Request.Context(), c.Param("canvasID")) if err != nil { h.writeError(c, err) return } if !h.authorizeWorkspace(c, canvas.WorkspaceID) { return } updated, err := mutation(c.Request.Context(), c.Param("canvasID")) if err != nil { h.writeError(c, err) return } writeCanvasJSON(c, http.StatusOK, canvasResponse(*updated)) } func (h *canvasHTTPHandler) remove(c *gin.Context) { canvas, err := h.canvases.Get(c.Request.Context(), c.Param("canvasID")) if err != nil { h.writeError(c, err) return } if !h.authorizeWorkspace(c, canvas.WorkspaceID) { return } if err := h.canvases.Remove(c.Request.Context(), c.Param("canvasID")); err != nil { h.writeError(c, err) return } c.Status(http.StatusNoContent) } func (h *canvasHTTPHandler) authorizeWorkspace(c *gin.Context, workspaceID string) bool { taskSvc := h.tasks if taskSvc == nil { writeCanvasError(c, http.StatusNotFound, "canvas_not_found", nil) return false } if err := taskSvc.AuthorizeWorkspaceAccess(c.Request.Context(), workspaceID); err != nil { writeCanvasError(c, http.StatusNotFound, "canvas_not_found", nil) return false } return true } func (h *canvasHTTPHandler) authorizeWorkspaceManage(c *gin.Context, workspaceID string) bool { if !h.authorizeWorkspace(c, workspaceID) { return false } if err := h.tasks.AuthorizeWorkspaceScope(c.Request.Context(), workspaceID, authz.ScopeWorkspaceManage); err != nil { writeCanvasError(c, http.StatusForbidden, "forbidden", nil) return false } return true } func (h *canvasHTTPHandler) authorizeWorkspaceOwner(c *gin.Context, workspaceID string) bool { owner, err := checkCanvasWorkspaceOwner(c.Request.Context(), h.tasks, workspaceID, canvasRuntimeUser(c)) if err != nil { writeCanvasError(c, http.StatusNotFound, "canvas_not_found", nil) return false } if !owner { writeCanvasError(c, http.StatusForbidden, "workspace_owner_required", nil) return false } return true } func checkCanvasWorkspaceOwner(ctx context.Context, access canvasWorkspaceOwnerAccess, workspaceID, userID string) (bool, error) { if access == nil { return false, errors.New("canvas workspace access is unavailable") } if err := access.AuthorizeWorkspaceAccess(ctx, workspaceID); err != nil { return false, err } workspace, err := access.GetWorkspace(ctx, workspaceID) if err != nil { return false, err } if workspace == nil { return false, errors.New("canvas workspace was not found") } actorID := strings.TrimSpace(userID) ownerID := strings.TrimSpace(workspace.OwnerID) return actorID != "" && (ownerID == actorID || (ownerID == "" && actorID == userstore.DefaultUserID)), nil } func canvasRouteParam(c *gin.Context, names ...string) string { for _, name := range names { if value := c.Param(name); value != "" { return value } } return "" }