410 lines
13 KiB
Go
410 lines
13 KiB
Go
package plugins
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"net/http"
|
|
"strings"
|
|
|
|
"google.golang.org/grpc/codes"
|
|
"google.golang.org/grpc/status"
|
|
|
|
"github.com/kandev/kandev/internal/plugins/instances"
|
|
"github.com/kandev/kandev/internal/plugins/state"
|
|
"github.com/kandev/kandev/internal/plugins/webapp"
|
|
"github.com/kandev/kandev/pkg/pluginsdk"
|
|
)
|
|
|
|
func (s *Service) listWebAppTasks(ctx context.Context, w http.ResponseWriter, r *http.Request, host *pluginHost, binding webapp.CapabilityBinding) {
|
|
page, err := webAppRequestPage(r)
|
|
if err != nil {
|
|
writeWebAppError(w, http.StatusBadRequest, "invalid_request")
|
|
return
|
|
}
|
|
includeArchived, err := webAppBoolQuery(r, "include_archived")
|
|
if err != nil {
|
|
writeWebAppError(w, http.StatusBadRequest, "invalid_request")
|
|
return
|
|
}
|
|
workspaceIDs, err := s.webAppTaskWorkspaceIDs(r, binding)
|
|
if err != nil {
|
|
writeWebAppError(w, webAppProtocolStatus(err), webAppErrorCode(err))
|
|
return
|
|
}
|
|
filter := pluginsdk.TaskFilter{
|
|
WorkspaceIDs: workspaceIDs,
|
|
WorkflowIDs: webAppQueryList(r.URL.Query()["workflow_id"]),
|
|
States: webAppQueryList(r.URL.Query()["state"]),
|
|
ParentID: webAppParentQuery(r),
|
|
IncludeArchived: includeArchived,
|
|
IncludeEphemeral: true,
|
|
}
|
|
if binding.ScopeKind == instances.ScopeTask {
|
|
task, err := host.Tasks().Get(ctx, binding.TaskID)
|
|
if err != nil || task == nil {
|
|
writeWebAppError(w, webAppProtocolStatus(err), webAppErrorCode(err))
|
|
return
|
|
}
|
|
if !webAppTaskMatches(ctx, host, binding, *task) || !webAppTaskMatchesFilter(*task, filter) || (!includeArchived && task.ArchivedAt != nil) {
|
|
writeWebAppJSON(w, r, http.StatusOK, webAppPage[webAppTask]{Items: []webAppTask{}, PageInfo: webAppPageInfo{}})
|
|
return
|
|
}
|
|
items, info := paginate([]webAppTask{webAppTaskFromSDK(*task)}, page)
|
|
writeWebAppJSON(w, r, http.StatusOK, webAppPageFromSDK(items, info))
|
|
return
|
|
}
|
|
|
|
items, info, err := host.Tasks().List(ctx, filter, page)
|
|
if err != nil {
|
|
writeWebAppError(w, webAppProtocolStatus(err), webAppErrorCode(err))
|
|
return
|
|
}
|
|
filtered := make([]webAppTask, 0, len(items))
|
|
for _, item := range items {
|
|
if webAppTaskMatches(ctx, host, binding, item) {
|
|
filtered = append(filtered, webAppTaskFromSDK(item))
|
|
}
|
|
}
|
|
writeWebAppJSON(w, r, http.StatusOK, webAppPageFromSDK(filtered, info))
|
|
}
|
|
|
|
func (s *Service) getWebAppTask(ctx context.Context, w http.ResponseWriter, r *http.Request, host *pluginHost, binding webapp.CapabilityBinding, taskID string) {
|
|
if !validWebAppKey(taskID) {
|
|
writeWebAppError(w, http.StatusNotFound, "not_found")
|
|
return
|
|
}
|
|
task, err := host.Tasks().Get(ctx, taskID)
|
|
if err != nil || task == nil {
|
|
writeWebAppError(w, webAppProtocolStatus(err), webAppErrorCode(err))
|
|
return
|
|
}
|
|
if !webAppTaskMatches(ctx, host, binding, *task) {
|
|
writeWebAppError(w, http.StatusNotFound, "not_found")
|
|
return
|
|
}
|
|
writeWebAppJSON(w, r, http.StatusOK, webAppTaskFromSDK(*task))
|
|
}
|
|
|
|
type webAppTaskPatch struct {
|
|
Title *string `json:"title"`
|
|
Description *string `json:"description"`
|
|
State *string `json:"state"`
|
|
WorkflowStepID *string `json:"workflow_step_id"`
|
|
}
|
|
|
|
func (p webAppTaskPatch) hasChanges() bool {
|
|
return p.Title != nil || p.Description != nil || p.State != nil || p.WorkflowStepID != nil
|
|
}
|
|
|
|
func (p webAppTaskPatch) hasTaskFields() bool {
|
|
return p.Title != nil || p.Description != nil || p.State != nil
|
|
}
|
|
|
|
func (s *Service) updateWebAppTask(ctx context.Context, w http.ResponseWriter, r *http.Request, host *pluginHost, binding webapp.CapabilityBinding, taskID string) {
|
|
if !validWebAppKey(taskID) {
|
|
writeWebAppError(w, http.StatusNotFound, "not_found")
|
|
return
|
|
}
|
|
task, err := host.Tasks().Get(ctx, taskID)
|
|
if err != nil || task == nil {
|
|
writeWebAppError(w, webAppProtocolStatus(err), webAppErrorCode(err))
|
|
return
|
|
}
|
|
if !webAppTaskMatches(ctx, host, binding, *task) {
|
|
writeWebAppError(w, http.StatusNotFound, "not_found")
|
|
return
|
|
}
|
|
var patch webAppTaskPatch
|
|
if err := decodeWebAppJSON(r, &patch); err != nil || !patch.hasChanges() {
|
|
writeWebAppError(w, http.StatusBadRequest, "invalid_request")
|
|
return
|
|
}
|
|
var updated *pluginsdk.Task
|
|
if patch.hasTaskFields() {
|
|
updated, err = host.Tasks().Update(ctx, pluginsdk.UpdateTaskInput{
|
|
ID: taskID, Title: patch.Title, Description: patch.Description, State: patch.State,
|
|
})
|
|
if err != nil {
|
|
writeWebAppError(w, webAppProtocolStatus(err), webAppErrorCode(err))
|
|
return
|
|
}
|
|
}
|
|
if patch.WorkflowStepID != nil {
|
|
// Keep the browser PATCH contract, but route transitions through Move so
|
|
// workflow hooks, active-session guards, and step history stay intact.
|
|
outcome, moveErr := host.Tasks().Move(ctx, pluginsdk.MoveTaskInput{
|
|
TaskID: taskID,
|
|
WorkflowStepID: *patch.WorkflowStepID,
|
|
})
|
|
if moveErr != nil {
|
|
writeWebAppError(w, webAppProtocolStatus(moveErr), webAppErrorCode(moveErr))
|
|
return
|
|
}
|
|
if outcome == nil || outcome.Task == nil {
|
|
writeWebAppError(w, http.StatusInternalServerError, webAppRuntimeUnavailable)
|
|
return
|
|
}
|
|
updated = outcome.Task
|
|
}
|
|
if updated == nil {
|
|
writeWebAppError(w, http.StatusBadRequest, "invalid_request")
|
|
return
|
|
}
|
|
writeWebAppJSON(w, r, http.StatusOK, webAppTaskFromSDK(*updated))
|
|
}
|
|
|
|
type webAppMessageRequest struct {
|
|
Text string `json:"text"`
|
|
SessionID string `json:"session_id,omitempty"`
|
|
}
|
|
|
|
func (s *Service) sendWebAppMessage(ctx context.Context, w http.ResponseWriter, r *http.Request, host *pluginHost, binding webapp.CapabilityBinding, taskID string) {
|
|
if !validWebAppKey(taskID) {
|
|
writeWebAppError(w, http.StatusNotFound, "not_found")
|
|
return
|
|
}
|
|
task, err := host.Tasks().Get(ctx, taskID)
|
|
if err != nil || task == nil {
|
|
writeWebAppError(w, webAppProtocolStatus(err), webAppErrorCode(err))
|
|
return
|
|
}
|
|
if !webAppTaskMatches(ctx, host, binding, *task) {
|
|
writeWebAppError(w, http.StatusNotFound, "not_found")
|
|
return
|
|
}
|
|
var request webAppMessageRequest
|
|
if err := decodeWebAppJSON(r, &request); err != nil || strings.TrimSpace(request.Text) == "" {
|
|
writeWebAppError(w, http.StatusBadRequest, "invalid_request")
|
|
return
|
|
}
|
|
dispatch, err := host.Messages().Send(ctx, taskID, request.SessionID, request.Text)
|
|
if err != nil {
|
|
writeWebAppError(w, webAppProtocolStatus(err), webAppErrorCode(err))
|
|
return
|
|
}
|
|
writeWebAppJSON(w, r, http.StatusAccepted, dispatch)
|
|
}
|
|
|
|
func (s *Service) listWebAppWorkflows(ctx context.Context, w http.ResponseWriter, r *http.Request, host *pluginHost, binding webapp.CapabilityBinding) {
|
|
page, err := webAppRequestPage(r)
|
|
if err != nil {
|
|
writeWebAppError(w, http.StatusBadRequest, "invalid_request")
|
|
return
|
|
}
|
|
workspaceIDs, err := s.webAppWorkflowWorkspaceIDs(ctx, r, host, binding)
|
|
if err != nil {
|
|
writeWebAppError(w, webAppProtocolStatus(err), webAppErrorCode(err))
|
|
return
|
|
}
|
|
var workflows []pluginsdk.Workflow
|
|
for _, workspaceID := range workspaceIDs {
|
|
items, _, err := host.Workflows().List(ctx, workspaceID, pluginsdk.Page{Limit: maxPageLimit})
|
|
if err != nil {
|
|
writeWebAppError(w, webAppProtocolStatus(err), webAppErrorCode(err))
|
|
return
|
|
}
|
|
workflows = append(workflows, items...)
|
|
}
|
|
if binding.ScopeKind == instances.ScopeTask {
|
|
task, err := host.Tasks().Get(ctx, binding.TaskID)
|
|
if err != nil || task == nil {
|
|
writeWebAppError(w, webAppProtocolStatus(err), webAppErrorCode(err))
|
|
return
|
|
}
|
|
workflows = filterWebAppWorkflows(workflows, task.WorkflowID)
|
|
}
|
|
items, info := paginate(workflows, page)
|
|
result := make([]webAppWorkflow, len(items))
|
|
for i, workflow := range items {
|
|
result[i] = webAppWorkflowFromSDK(workflow)
|
|
}
|
|
writeWebAppJSON(w, r, http.StatusOK, webAppPageFromSDK(result, info))
|
|
}
|
|
|
|
func (s *Service) listWebAppWorkflowSteps(ctx context.Context, w http.ResponseWriter, r *http.Request, host *pluginHost, binding webapp.CapabilityBinding, workflowID string) {
|
|
if !validWebAppKey(workflowID) {
|
|
writeWebAppError(w, http.StatusNotFound, "not_found")
|
|
return
|
|
}
|
|
workspaceIDs, err := s.webAppWorkflowWorkspaceIDs(ctx, r, host, binding)
|
|
if err != nil {
|
|
writeWebAppError(w, webAppProtocolStatus(err), webAppErrorCode(err))
|
|
return
|
|
}
|
|
allowed := false
|
|
for _, workspaceID := range workspaceIDs {
|
|
workflows, _, err := host.Workflows().List(ctx, workspaceID, pluginsdk.Page{Limit: maxPageLimit})
|
|
if err != nil {
|
|
writeWebAppError(w, webAppProtocolStatus(err), webAppErrorCode(err))
|
|
return
|
|
}
|
|
for _, workflow := range workflows {
|
|
if workflow.ID == workflowID {
|
|
allowed = true
|
|
break
|
|
}
|
|
}
|
|
}
|
|
if binding.ScopeKind == instances.ScopeTask {
|
|
task, err := host.Tasks().Get(ctx, binding.TaskID)
|
|
if err != nil || task == nil || task.WorkflowID != workflowID {
|
|
writeWebAppError(w, http.StatusNotFound, "not_found")
|
|
return
|
|
}
|
|
}
|
|
if !allowed {
|
|
writeWebAppError(w, http.StatusNotFound, "not_found")
|
|
return
|
|
}
|
|
steps, err := host.Workflows().ListSteps(ctx, workflowID)
|
|
if err != nil {
|
|
writeWebAppError(w, webAppProtocolStatus(err), webAppErrorCode(err))
|
|
return
|
|
}
|
|
result := make([]webAppWorkflowStep, len(steps))
|
|
for i, step := range steps {
|
|
result[i] = webAppWorkflowStepFromSDK(step)
|
|
}
|
|
writeWebAppJSON(w, r, http.StatusOK, webAppPage[webAppWorkflowStep]{Items: result, PageInfo: webAppPageInfo{}})
|
|
}
|
|
|
|
func (s *Service) webAppTaskWorkspaceIDs(r *http.Request, binding webapp.CapabilityBinding) ([]string, error) {
|
|
requested := strings.TrimSpace(r.URL.Query().Get("workspace_id"))
|
|
if binding.WorkspaceID != "" {
|
|
if requested != "" && requested != binding.WorkspaceID {
|
|
return nil, status.Error(codes.PermissionDenied, "workspace is outside the canvas scope")
|
|
}
|
|
return []string{binding.WorkspaceID}, nil
|
|
}
|
|
if requested != "" {
|
|
return []string{requested}, nil
|
|
}
|
|
// Task reads can use the Host task reader's global workspace resolution.
|
|
// This avoids requiring a separate workspaces grant for an instance canvas.
|
|
return nil, nil
|
|
}
|
|
|
|
func (s *Service) webAppWorkflowWorkspaceIDs(ctx context.Context, r *http.Request, host *pluginHost, binding webapp.CapabilityBinding) ([]string, error) {
|
|
requested := strings.TrimSpace(r.URL.Query().Get("workspace_id"))
|
|
if binding.WorkspaceID != "" {
|
|
if requested != "" && requested != binding.WorkspaceID {
|
|
return nil, status.Error(codes.PermissionDenied, "workspace is outside the canvas scope")
|
|
}
|
|
return []string{binding.WorkspaceID}, nil
|
|
}
|
|
if requested != "" {
|
|
return []string{requested}, nil
|
|
}
|
|
workspaces, _, err := host.Workspaces().List(ctx, pluginsdk.Page{Limit: maxPageLimit})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
ids := make([]string, len(workspaces))
|
|
for i, workspace := range workspaces {
|
|
ids[i] = workspace.ID
|
|
}
|
|
return ids, nil
|
|
}
|
|
|
|
func webAppTaskMatches(ctx context.Context, host *pluginHost, binding webapp.CapabilityBinding, task pluginsdk.Task) bool {
|
|
if binding.ScopeKind == instances.ScopeInstance {
|
|
return true
|
|
}
|
|
if task.WorkspaceID != binding.WorkspaceID {
|
|
return false
|
|
}
|
|
switch binding.ScopeKind {
|
|
case instances.ScopeWorkspace:
|
|
return true
|
|
case instances.ScopeTask:
|
|
return task.ID == binding.TaskID
|
|
case instances.ScopeRepository:
|
|
return webAppTaskHasRepository(task, binding.RepositoryID)
|
|
case instances.ScopeSession:
|
|
return webAppTaskHasSession(ctx, host, task, binding.SessionID)
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func webAppTaskHasRepository(task pluginsdk.Task, repositoryID string) bool {
|
|
for _, repository := range task.Repositories {
|
|
if repository.RepositoryID == repositoryID || repository.ID == repositoryID {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func webAppTaskHasSession(ctx context.Context, host *pluginHost, task pluginsdk.Task, sessionID string) bool {
|
|
if host.taskData == nil {
|
|
return false
|
|
}
|
|
sessions, err := host.taskData.ListTaskSessions(ctx, task.ID)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
for _, session := range sessions {
|
|
if session != nil && session.ID == sessionID {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func webAppTaskMatchesFilter(task pluginsdk.Task, filter pluginsdk.TaskFilter) bool {
|
|
if len(filter.WorkflowIDs) > 0 && !stringInSlice(filter.WorkflowIDs, task.WorkflowID) {
|
|
return false
|
|
}
|
|
if len(filter.States) > 0 && !stringInSlice(filter.States, task.State) {
|
|
return false
|
|
}
|
|
return filter.ParentID == nil || (task.ParentID != nil && *task.ParentID == *filter.ParentID)
|
|
}
|
|
|
|
func stringInSlice(values []string, target string) bool {
|
|
for _, value := range values {
|
|
if value == target {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func filterWebAppWorkflows(workflows []pluginsdk.Workflow, workflowID string) []pluginsdk.Workflow {
|
|
filtered := make([]pluginsdk.Workflow, 0, 1)
|
|
for _, workflow := range workflows {
|
|
if workflow.ID == workflowID {
|
|
filtered = append(filtered, workflow)
|
|
}
|
|
}
|
|
return filtered
|
|
}
|
|
|
|
func webAppErrorCode(err error) string {
|
|
if err == nil {
|
|
return webAppRuntimeUnavailable
|
|
}
|
|
if errors.Is(err, webapp.ErrRuntimeTokenStale) {
|
|
return "runtime_token_stale"
|
|
}
|
|
if errors.Is(err, state.ErrConflict) {
|
|
return "plugin_state_conflict"
|
|
}
|
|
if errors.Is(err, instances.ErrNotFound) || status.Code(err) == codes.NotFound {
|
|
return "not_found"
|
|
}
|
|
switch status.Code(err) {
|
|
case codes.PermissionDenied:
|
|
return "plugin_permission_denied"
|
|
case codes.InvalidArgument:
|
|
return "invalid_request"
|
|
case codes.FailedPrecondition:
|
|
return "plugin_state_conflict"
|
|
case codes.Unimplemented:
|
|
return webAppRuntimeUnavailable
|
|
default:
|
|
return webAppRuntimeUnavailable
|
|
}
|
|
}
|