package plugins import ( "encoding/json" "net/http" "github.com/kandev/kandev/internal/plugins/instances" "github.com/kandev/kandev/internal/plugins/webapp" "github.com/kandev/kandev/pkg/pluginsdk" ) type webAppPageInfo struct { NextCursor string `json:"next_cursor,omitempty"` HasMore bool `json:"has_more"` } type webAppPage[T any] struct { Items []T `json:"items"` PageInfo webAppPageInfo `json:"page_info"` } func webAppPageFromSDK[T any](items []T, info *pluginsdk.PageInfo) webAppPage[T] { page := webAppPage[T]{Items: items} if info != nil { page.PageInfo = webAppPageInfo{NextCursor: info.NextCursor, HasMore: info.HasMore} } return page } func writeWebAppJSON(w http.ResponseWriter, r *http.Request, status int, value any) { body, err := json.Marshal(value) if err != nil { writeWebAppError(w, http.StatusInternalServerError, webAppRuntimeUnavailable) return } if len(body) > webAppResponseLimit { writeWebAppError(w, http.StatusInternalServerError, "response_too_large") return } w.Header().Set("Content-Type", "application/json; charset=utf-8") w.WriteHeader(status) if r == nil || r.Method != http.MethodHead { _, _ = w.Write(body) } } func writeWebAppError(w http.ResponseWriter, status int, code string) { body, err := json.Marshal(struct { Error string `json:"error"` }{Error: code}) if err != nil { body = []byte(`{"error":"runtime_unavailable"}`) } w.Header().Set("Cache-Control", "no-store") w.Header().Set("Content-Type", "application/json; charset=utf-8") w.WriteHeader(status) _, _ = w.Write(body) } type webAppTaskRepository struct { ID string `json:"id"` RepositoryID string `json:"repository_id"` BaseBranch string `json:"base_branch"` Position int32 `json:"position"` CheckoutBranch string `json:"checkout_branch"` } type webAppPullRequest struct { Number int64 `json:"number"` URL string `json:"url"` Title string `json:"title"` State string `json:"state"` HeadBranch string `json:"head_branch"` BaseBranch string `json:"base_branch"` IsDraft bool `json:"is_draft"` Provider string `json:"provider"` MergedAt *string `json:"merged_at,omitempty"` ClosedAt *string `json:"closed_at,omitempty"` ReviewState string `json:"review_state"` ChecksState string `json:"checks_state"` MergeableState string `json:"mergeable_state"` UnresolvedReviewThreads int32 `json:"unresolved_review_threads"` ChecksTotal int32 `json:"checks_total"` ChecksPassing int32 `json:"checks_passing"` Additions int32 `json:"additions"` Deletions int32 `json:"deletions"` AuthorLogin string `json:"author_login"` } // Title and State are never omitempty: a redacted entry blanks them to "" // rather than dropping the key, per AC-PLUGINS-TASK-DEPS-003.2. Status stays // omitempty because a blocks entry legitimately carries none. type webAppTaskDependencyRef struct { ID string `json:"id"` Title string `json:"title"` State string `json:"state"` Status string `json:"status,omitempty"` } // dependencyRedactionScope carries what AC-PLUGINS-TASK-DEPS-003.8's // admission test needs to decide, for one canvas response, whether an edge // end may be disclosed as a directly readable task. It is built once per // response from the canvas binding plus the set of task IDs that response // already serializes as directly readable tasks (directlyReadableIDs) -- // never from an additional read. type dependencyRedactionScope struct { kind string workspaceID string directlyReadableIDs map[string]struct{} } // dependencyRedactionScopeFromBinding builds the scope for one canvas // response. directlyReadableIDs must list every task ID that response // serializes as a directly readable task (e.g. the single task a GET // returns, or the page a list route returns after its own scope narrowing); // it is ignored for scope kinds whose admission test doesn't consult it. func dependencyRedactionScopeFromBinding(binding webapp.CapabilityBinding, directlyReadableIDs []string) dependencyRedactionScope { ids := make(map[string]struct{}, len(directlyReadableIDs)) for _, id := range directlyReadableIDs { ids[id] = struct{}{} } return dependencyRedactionScope{ kind: instances.EffectiveDataScopeKind(binding.ScopeKind, binding.DataScopeKind), workspaceID: binding.WorkspaceID, directlyReadableIDs: ids, } } // admits reports whether ref may be disclosed as a directly readable task // under s, resolving by scope kind exactly as AC-PLUGINS-TASK-DEPS-003.8 // requires: instance scope admits every end; workspace scope admits an end // whose workspace is known equal to the bound workspace; task scope admits // no end; repository and session scope admit an end only when it is also // returned in the same response as a directly readable task. An end whose // predicate inputs are unavailable (e.g. an empty workspace on either side) // is never admitted, per AC-PLUGINS-TASK-DEPS-003.3's fail-closed rule. func (s dependencyRedactionScope) admits(ref pluginsdk.TaskDependencyRef) bool { switch s.kind { case instances.ScopeInstance: return true case instances.ScopeWorkspace: return s.workspaceID != "" && ref.WorkspaceID != "" && ref.WorkspaceID == s.workspaceID case instances.ScopeRepository, instances.ScopeSession: _, ok := s.directlyReadableIDs[ref.ID] return ok default: // instances.ScopeTask, and any unrecognized scope kind: admit nothing. return false } } // webAppTaskDependencyRefFromSDK redacts Title and State when scope does not // admit ref (REQ-PLUGINS-TASK-DEPS-003). func webAppTaskDependencyRefFromSDK(ref pluginsdk.TaskDependencyRef, scope dependencyRedactionScope) webAppTaskDependencyRef { out := webAppTaskDependencyRef{ID: ref.ID, Title: ref.Title, State: ref.State, Status: ref.Status} if !scope.admits(ref) { out.Title = "" out.State = "" } return out } // webAppTaskDependencyRefsFromSDK never returns nil, even for an empty or // nil input: depends_on/blocks always serialize as [], never as null or an // absent field. func webAppTaskDependencyRefsFromSDK(refs []pluginsdk.TaskDependencyRef, scope dependencyRedactionScope) []webAppTaskDependencyRef { out := make([]webAppTaskDependencyRef, len(refs)) for i, ref := range refs { out[i] = webAppTaskDependencyRefFromSDK(ref, scope) } return out } type webAppTask struct { ID string `json:"id"` WorkspaceID string `json:"workspace_id"` WorkflowID string `json:"workflow_id"` Title string `json:"title"` Description string `json:"description"` State string `json:"state"` Priority string `json:"priority"` CreatedBy string `json:"created_by"` CreatedAt string `json:"created_at"` UpdatedAt string `json:"updated_at"` StartedAt *string `json:"started_at,omitempty"` CompletedAt *string `json:"completed_at,omitempty"` ParentID *string `json:"parent_id,omitempty"` Identifier string `json:"identifier"` IsEphemeral bool `json:"is_ephemeral"` Repositories []webAppTaskRepository `json:"repositories,omitempty"` Metadata map[string]any `json:"metadata,omitempty"` ArchivedAt *string `json:"archived_at,omitempty"` PullRequests []webAppPullRequest `json:"pull_requests,omitempty"` WorkflowStepID string `json:"workflow_step_id"` Position int32 `json:"position"` AssigneeAgentProfileID string `json:"assignee_agent_profile_id"` Labels []string `json:"labels,omitempty"` Autopilot bool `json:"autopilot"` WIPAdmitted bool `json:"wip_admitted"` QueuedForStepID string `json:"queued_for_step_id,omitempty"` QueuedAt *string `json:"queued_at,omitempty"` ProjectID string `json:"project_id,omitempty"` ExternalID string `json:"external_id,omitempty"` Blocked bool `json:"blocked"` // BlockedReason never carries omitempty: it is "" on an unblocked task, and // the key must stay present so absence is never a third state alongside the // other seven projection fields. BlockedReason string `json:"blocked_reason"` // DependsOn/Blocks never carry omitempty: an empty dependency graph still // reports [], not an absent or null field. DependsOn []webAppTaskDependencyRef `json:"depends_on"` Blocks []webAppTaskDependencyRef `json:"blocks"` DependsOnTruncated bool `json:"depends_on_truncated"` BlocksTruncated bool `json:"blocks_truncated"` StartWhenUnblocked bool `json:"start_when_unblocked"` } // webAppTaskFromSDK converts task to its canvas wire form. scope is the // requesting canvas response's dependency redaction scope, used only to // redact out-of-scope dependency edge ends. func webAppTaskFromSDK(task pluginsdk.Task, scope dependencyRedactionScope) webAppTask { result := webAppTask{ ID: task.ID, WorkspaceID: task.WorkspaceID, WorkflowID: task.WorkflowID, Title: task.Title, Description: task.Description, State: task.State, Priority: task.Priority, CreatedBy: task.CreatedBy, CreatedAt: task.CreatedAt, UpdatedAt: task.UpdatedAt, StartedAt: task.StartedAt, CompletedAt: task.CompletedAt, ParentID: task.ParentID, Identifier: task.Identifier, IsEphemeral: task.IsEphemeral, Metadata: task.Metadata, ArchivedAt: task.ArchivedAt, WorkflowStepID: task.WorkflowStepID, Position: task.Position, AssigneeAgentProfileID: task.AssigneeAgentProfileID, Labels: task.Labels, //nolint:staticcheck // retain API v1 label compatibility Autopilot: task.Autopilot, WIPAdmitted: task.WIPAdmitted, QueuedForStepID: task.QueuedForStepID, QueuedAt: task.QueuedAt, ProjectID: task.ProjectID, ExternalID: task.ExternalID, Blocked: task.Blocked, BlockedReason: task.BlockedReason, DependsOn: webAppTaskDependencyRefsFromSDK(task.DependsOn, scope), Blocks: webAppTaskDependencyRefsFromSDK(task.Blocks, scope), DependsOnTruncated: task.DependsOnTruncated, BlocksTruncated: task.BlocksTruncated, StartWhenUnblocked: task.StartWhenUnblocked, } if len(task.Repositories) > 0 { result.Repositories = make([]webAppTaskRepository, len(task.Repositories)) for i, repository := range task.Repositories { result.Repositories[i] = webAppTaskRepository{ ID: repository.ID, RepositoryID: repository.RepositoryID, BaseBranch: repository.BaseBranch, Position: repository.Position, CheckoutBranch: repository.CheckoutBranch, } } } if len(task.PullRequests) > 0 { result.PullRequests = make([]webAppPullRequest, len(task.PullRequests)) for i, pullRequest := range task.PullRequests { result.PullRequests[i] = webAppPullRequest{ Number: pullRequest.Number, URL: pullRequest.URL, Title: pullRequest.Title, State: pullRequest.State, HeadBranch: pullRequest.HeadBranch, BaseBranch: pullRequest.BaseBranch, IsDraft: pullRequest.IsDraft, Provider: pullRequest.Provider, MergedAt: pullRequest.MergedAt, ClosedAt: pullRequest.ClosedAt, ReviewState: pullRequest.ReviewState, ChecksState: pullRequest.ChecksState, MergeableState: pullRequest.MergeableState, UnresolvedReviewThreads: pullRequest.UnresolvedReviewThreads, ChecksTotal: pullRequest.ChecksTotal, ChecksPassing: pullRequest.ChecksPassing, Additions: pullRequest.Additions, Deletions: pullRequest.Deletions, AuthorLogin: pullRequest.AuthorLogin, } } } return result } type webAppWorkflow struct { ID string `json:"id"` WorkspaceID string `json:"workspace_id"` Name string `json:"name"` Description *string `json:"description,omitempty"` SortOrder int32 `json:"sort_order"` CreatedAt string `json:"created_at"` UpdatedAt string `json:"updated_at"` } func webAppWorkflowFromSDK(workflow pluginsdk.Workflow) webAppWorkflow { return webAppWorkflow{ ID: workflow.ID, WorkspaceID: workflow.WorkspaceID, Name: workflow.Name, Description: workflow.Description, SortOrder: workflow.SortOrder, CreatedAt: workflow.CreatedAt, UpdatedAt: workflow.UpdatedAt, } } type webAppWorkflowStep struct { ID string `json:"id"` WorkflowID string `json:"workflow_id"` Name string `json:"name"` Position int32 `json:"position"` StageType string `json:"stage_type"` Color string `json:"color"` IsStartStep bool `json:"is_start_step"` WIPLimit int32 `json:"wip_limit"` AgentProfileID string `json:"agent_profile_id"` OnEnterActionTypes []string `json:"on_enter_action_types,omitempty"` } func webAppWorkflowStepFromSDK(step pluginsdk.WorkflowStep) webAppWorkflowStep { return webAppWorkflowStep{ ID: step.ID, WorkflowID: step.WorkflowID, Name: step.Name, Position: step.Position, StageType: step.StageType, Color: step.Color, IsStartStep: step.IsStartStep, WIPLimit: step.WIPLimit, AgentProfileID: step.AgentProfileID, OnEnterActionTypes: step.OnEnterActionTypes, } }