Files
kandev/apps/backend/internal/plugins/webapp_protocol_data.go
T
2026-09-23 20:51:55 +01:00

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
}
}