486 lines
17 KiB
Go
486 lines
17 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"
|
|
taskmodels "github.com/kandev/kandev/internal/task/models"
|
|
"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 instances.EffectiveDataScopeKind(binding.ScopeKind, binding.DataScopeKind) == instances.ScopeTask {
|
|
// Resolved via the non-attaching fetchTask, and dependencies attached
|
|
// only below, after the discard checks: matching the attachment rule
|
|
// the non-task-scope branch below already documents for itself.
|
|
dto, model, err := host.fetchTask(ctx, binding.TaskID)
|
|
if err != nil || dto == nil {
|
|
writeWebAppError(w, webAppProtocolStatus(err), webAppErrorCode(err))
|
|
return
|
|
}
|
|
if !webAppTaskMatches(ctx, host, binding, *dto) || !webAppTaskMatchesFilter(*dto, filter) || (!includeArchived && dto.ArchivedAt != nil) {
|
|
writeWebAppJSON(w, r, http.StatusOK, webAppPage[webAppTask]{Items: []webAppTask{}, PageInfo: webAppPageInfo{}})
|
|
return
|
|
}
|
|
fetched := []pluginsdk.Task{*dto}
|
|
host.attachPullRequests(ctx, fetched)
|
|
var models []*taskmodels.Task
|
|
if model != nil {
|
|
models = []*taskmodels.Task{model}
|
|
}
|
|
if err := host.attachDependencies(ctx, fetched, models, false, "ListWebAppTasks"); err != nil {
|
|
writeWebAppError(w, webAppProtocolStatus(err), webAppErrorCode(err))
|
|
return
|
|
}
|
|
scope := dependencyRedactionScopeFromBinding(binding, nil)
|
|
items, info := paginate([]webAppTask{webAppTaskFromSDK(fetched[0], scope)}, page)
|
|
writeWebAppJSON(w, r, http.StatusOK, webAppPageFromSDK(items, info))
|
|
return
|
|
}
|
|
|
|
// Fetches without deriving dependencies: the binding scope (repository or
|
|
// session) narrows the fetched page further below, and deriving before
|
|
// that narrowing would both waste work on tasks the caller never sees and
|
|
// risk tripping the fan-out cap on a page the actual response never
|
|
// approaches.
|
|
if !host.capabilities.CanRead(resourceTasks) {
|
|
writeWebAppError(w, http.StatusForbidden, "plugin_permission_denied")
|
|
return
|
|
}
|
|
if host.taskData == nil {
|
|
_, _, err := host.UnimplementedHostData.Tasks().List(ctx, filter, page)
|
|
writeWebAppError(w, webAppProtocolStatus(err), webAppErrorCode(err))
|
|
return
|
|
}
|
|
models, info, err := host.fetchTaskPage(ctx, filter, page)
|
|
if err != nil {
|
|
writeWebAppError(w, webAppProtocolStatus(err), webAppErrorCode(err))
|
|
return
|
|
}
|
|
dtos := tasksToDTOs(models)
|
|
host.attachPullRequests(ctx, dtos)
|
|
survivingModels := make([]*taskmodels.Task, 0, len(dtos))
|
|
survivingDTOs := make([]pluginsdk.Task, 0, len(dtos))
|
|
for i, dto := range dtos {
|
|
if webAppTaskMatches(ctx, host, binding, dto) {
|
|
survivingDTOs = append(survivingDTOs, dto)
|
|
survivingModels = append(survivingModels, models[i])
|
|
}
|
|
}
|
|
if err := host.attachDependencies(ctx, survivingDTOs, survivingModels, true, "ListWebAppTasks"); err != nil {
|
|
writeWebAppError(w, webAppProtocolStatus(err), webAppErrorCode(err))
|
|
return
|
|
}
|
|
directlyReadableIDs := make([]string, len(survivingDTOs))
|
|
for i, dto := range survivingDTOs {
|
|
directlyReadableIDs[i] = dto.ID
|
|
}
|
|
scope := dependencyRedactionScopeFromBinding(binding, directlyReadableIDs)
|
|
filtered := make([]webAppTask, len(survivingDTOs))
|
|
for i, dto := range survivingDTOs {
|
|
filtered[i] = webAppTaskFromSDK(dto, scope)
|
|
}
|
|
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
|
|
}
|
|
// Resolved via the non-attaching fetchTask, and dependencies attached only
|
|
// below, after the scope check that can discard the result and 404.
|
|
dto, model, err := host.fetchTask(ctx, taskID)
|
|
if err != nil || dto == nil {
|
|
writeWebAppError(w, webAppProtocolStatus(err), webAppErrorCode(err))
|
|
return
|
|
}
|
|
if !webAppTaskMatches(ctx, host, binding, *dto) {
|
|
writeWebAppError(w, http.StatusNotFound, "not_found")
|
|
return
|
|
}
|
|
fetched := []pluginsdk.Task{*dto}
|
|
host.attachPullRequests(ctx, fetched)
|
|
var models []*taskmodels.Task
|
|
if model != nil {
|
|
models = []*taskmodels.Task{model}
|
|
}
|
|
if err := host.attachDependencies(ctx, fetched, models, false, "GetWebAppTask"); err != nil {
|
|
writeWebAppError(w, webAppProtocolStatus(err), webAppErrorCode(err))
|
|
return
|
|
}
|
|
scope := dependencyRedactionScopeFromBinding(binding, []string{fetched[0].ID})
|
|
writeWebAppJSON(w, r, http.StatusOK, webAppTaskFromSDK(fetched[0], scope))
|
|
}
|
|
|
|
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.fetchTaskForScopeCheck(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() {
|
|
updateInput := pluginsdk.UpdateTaskInput{
|
|
ID: taskID, Title: patch.Title, Description: patch.Description, State: patch.State,
|
|
}
|
|
if patch.WorkflowStepID != nil {
|
|
// A body naming both a task field and workflow_step_id takes both
|
|
// this branch and the Move branch below, but only Move's result is
|
|
// serialized -- so this write must not attach dependencies on a
|
|
// result nobody sees. The one derivation the response makes runs
|
|
// on Move's result, below.
|
|
if _, err = host.writeTaskUpdate(ctx, updateInput); err != nil {
|
|
writeWebAppError(w, webAppProtocolStatus(err), webAppErrorCode(err))
|
|
return
|
|
}
|
|
} else {
|
|
updated, err = host.Tasks().Update(ctx, updateInput)
|
|
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
|
|
}
|
|
scope := dependencyRedactionScopeFromBinding(binding, []string{updated.ID})
|
|
writeWebAppJSON(w, r, http.StatusOK, webAppTaskFromSDK(*updated, scope))
|
|
}
|
|
|
|
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.fetchTaskForScopeCheck(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 instances.EffectiveDataScopeKind(binding.ScopeKind, binding.DataScopeKind) == instances.ScopeTask {
|
|
task, err := host.fetchTaskForScopeCheck(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 instances.EffectiveDataScopeKind(binding.ScopeKind, binding.DataScopeKind) == instances.ScopeTask {
|
|
task, err := host.fetchTaskForScopeCheck(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 {
|
|
dataScope := instances.EffectiveDataScopeKind(binding.ScopeKind, binding.DataScopeKind)
|
|
if dataScope == instances.ScopeInstance {
|
|
return true
|
|
}
|
|
if task.WorkspaceID != binding.WorkspaceID {
|
|
return false
|
|
}
|
|
switch dataScope {
|
|
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
|
|
case codes.ResourceExhausted:
|
|
return "response_too_large"
|
|
default:
|
|
return webAppRuntimeUnavailable
|
|
}
|
|
}
|