277677f330
Co-authored-by: nova28 <17953305+nova28@users.noreply.github.com> Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
709 lines
25 KiB
Go
709 lines
25 KiB
Go
// host_write.go implements pluginHost's Host data API WRITE surface (ADR 0043
|
|
// phase 2): the CreateTask/UpdateTask methods on the task accessor
|
|
// (Host.Tasks()) and the Send method on the message accessor (Host.Messages()).
|
|
// Writes are gated on api_write:<resource> — undeclared → gRPC PermissionDenied,
|
|
// exactly mirroring the read gating in host_data.go.
|
|
//
|
|
// Every write routes through the same first-party layer the REST/MCP API uses,
|
|
// never a repository — that is how task.* events fire and how a message reaches
|
|
// the agent through the orchestrator's real delivery path (queue when the
|
|
// session is running, resume/start it otherwise). Task writes and message
|
|
// delivery go through narrow taskWriter and taskMessenger interfaces,
|
|
// satisfied by backendapp adapters.
|
|
package plugins
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
|
|
agentsettingsdto "github.com/kandev/kandev/internal/agent/settings/dto"
|
|
"github.com/kandev/kandev/internal/repoclone"
|
|
taskmodels "github.com/kandev/kandev/internal/task/models"
|
|
"github.com/kandev/kandev/internal/task/repository/repoerrors"
|
|
"github.com/kandev/kandev/pkg/pluginsdk"
|
|
"google.golang.org/grpc/codes"
|
|
"google.golang.org/grpc/status"
|
|
)
|
|
|
|
// ── Narrow write data-source interfaces ─────────────────────────────────
|
|
//
|
|
// Both are satisfied by backendapp adapters (the service request types would
|
|
// create an import cycle here): taskWriter over internal/task/service,
|
|
// taskMessenger over the task service + orchestrator delivery path.
|
|
|
|
// TaskCreateInput is the plugins-local task-create request a taskWriter adapter
|
|
// translates into internal/task/service.CreateTaskRequest. Source is the
|
|
// "plugin:<id>" provenance the host stamps (a plugin cannot set it).
|
|
type TaskCreateInput struct {
|
|
WorkspaceID string
|
|
WorkflowID string
|
|
WorkflowStepID string
|
|
Title string
|
|
Description string
|
|
ParentID string
|
|
Source string
|
|
Repositories []pluginsdk.PluginTaskRepository
|
|
Launch TaskLaunchInput
|
|
Metadata map[string]any
|
|
PlanMode bool
|
|
Priority string
|
|
// StartAgent reports that the caller will launch an agent for this task
|
|
// right after creation. The adapter maps it onto CreateTaskRequest so step
|
|
// resolution sends the task to the first step that runs agents rather than
|
|
// to the workflow's parking start step.
|
|
StartAgent bool
|
|
}
|
|
|
|
// TaskLaunchInput contains validated launch fields for a freshly-created
|
|
// plugin task. The backendapp adapter translates it to the orchestrator's
|
|
// StartTask arguments without exposing the task service here.
|
|
type TaskLaunchInput struct {
|
|
AgentProfileID string
|
|
ExecutorProfileID string
|
|
Prompt string
|
|
PlanMode bool
|
|
}
|
|
|
|
// TaskUpdateInput is the plugins-local task-update request a taskWriter adapter
|
|
// translates into internal/task/service.UpdateTaskRequest. A nil field means
|
|
// "leave unchanged".
|
|
type TaskUpdateInput struct {
|
|
ID string
|
|
Title *string
|
|
Description *string
|
|
State *string
|
|
WorkflowStepID *string
|
|
Priority *string
|
|
}
|
|
|
|
// TaskMoveInput is the plugins-local task-move request a taskWriter adapter
|
|
// translates into internal/task/service.MoveTaskWithOptions. Unlike
|
|
// TaskUpdateInput's WorkflowStepID (rejected on the plugin path), Move
|
|
// transitions a task through the same path the board's own move uses.
|
|
// WorkflowID nil means inherit the task's current workflow; a pointer to ""
|
|
// is rejected before reaching the adapter (see taskReader.Move). Source is
|
|
// the "plugin:<id>" provenance the host stamps as the ADR 0015 actor id (a
|
|
// plugin cannot set it).
|
|
type TaskMoveInput struct {
|
|
TaskID string
|
|
WorkflowStepID string
|
|
WorkflowID *string
|
|
Position int32
|
|
Source string
|
|
}
|
|
|
|
// TaskMoveResult is the outcome of a plugin-initiated move. Transitioned and
|
|
// FromStepID mirror internal/task/service.MoveTaskResult's write-transaction
|
|
// sourced fields — they must come from the result, not be re-derived from
|
|
// Task, because a step-changing move's post-commit refresh replaces Task with
|
|
// a plain read that carries neither transient field (see
|
|
// taskmodels.Task.FromStepID's doc). QueuedForStepID is read directly off the
|
|
// returned Task since it is a persisted field, unaffected by that refresh.
|
|
type TaskMoveResult struct {
|
|
Task *taskmodels.Task
|
|
Transitioned bool
|
|
FromStepID string
|
|
}
|
|
|
|
// PluginMessageResult is the outcome of delivering a message to a task session,
|
|
// returned by a taskMessenger adapter. Status is "queued" | "sent" | "started".
|
|
type PluginMessageResult struct {
|
|
SessionID string
|
|
Status string
|
|
}
|
|
|
|
// taskWriter is the narrow slice of the task service the
|
|
// CreateTask/UpdateTask/MoveTask RPCs need, adapted by backendapp to avoid an
|
|
// internal/task/service import cycle. Create/Update return the persisted
|
|
// *taskmodels.Task so the reader can map it to the wire DTO; Move returns the
|
|
// richer TaskMoveResult (see its doc for why).
|
|
type taskWriter interface {
|
|
CreateTask(ctx context.Context, in TaskCreateInput) (*taskmodels.Task, error)
|
|
UpdateTask(ctx context.Context, in TaskUpdateInput) (*taskmodels.Task, error)
|
|
DeleteTask(ctx context.Context, id string) error
|
|
MoveTask(ctx context.Context, in TaskMoveInput) (*TaskMoveResult, error)
|
|
}
|
|
|
|
// taskMessenger delivers a prompt to a task session through the orchestrator's
|
|
// real message-delivery path (resolve session, record the user message, then
|
|
// queue/resume/start by session state — the same behavior as message_task).
|
|
// Source is the "plugin:<id>" provenance stamped on the recorded message. An
|
|
// empty sessionID targets the task's primary session.
|
|
type taskMessenger interface {
|
|
SendMessage(ctx context.Context, taskID, sessionID, text, source string) (PluginMessageResult, error)
|
|
}
|
|
|
|
// taskStarter is the narrow slice of the orchestrator the CreateTask RPC needs
|
|
// to honor start_agent, adapted by backendapp. Its StartTask returns promptly:
|
|
// the adapter launches the agent asynchronously (fire-and-forget on a detached,
|
|
// time-bounded context), so a plugin's CreateTask RPC never blocks on the
|
|
// orchestrator's launch path. Best-effort — a launch failure never fails task
|
|
// creation.
|
|
type taskStarter interface {
|
|
StartTask(ctx context.Context, taskID string, launch TaskLaunchInput) error
|
|
}
|
|
|
|
// pluginSource is the "plugin:<id>" provenance stamped on rows this plugin
|
|
// creates, so a created task/message is attributable and a plugin can never
|
|
// spoof another origin.
|
|
const taskSourceMetadataKey = "source"
|
|
|
|
func (h *pluginHost) pluginSource() string {
|
|
return "plugin:" + h.pluginID
|
|
}
|
|
|
|
// writeDependencies returns the live task messenger and task starter (wired via
|
|
// SetWriteDeps). Read live rather than snapshotted at hostForPlugin time for
|
|
// the same reason as the utility agent (ADR 0048): the orchestrator is
|
|
// constructed after StartActivePlugins spawns boot-active plugins, so a
|
|
// snapshot would strand those hosts. nil on a bare test host.
|
|
func (h *pluginHost) writeDependencies() (taskMessenger, taskStarter) {
|
|
if h.writeDeps == nil {
|
|
return nil, nil
|
|
}
|
|
return h.writeDeps()
|
|
}
|
|
|
|
// ── Task writes (api_write:tasks) ───────────────────────────────────────
|
|
|
|
func (r taskReader) Create(ctx context.Context, in pluginsdk.CreateTaskInput) (*pluginsdk.Task, error) {
|
|
if !r.host.capabilities.CanWrite(resourceTasks) {
|
|
return nil, permissionDenied(apiWriteCapability(resourceTasks))
|
|
}
|
|
if r.host.taskWriter == nil {
|
|
return r.host.UnimplementedHostData.Tasks().Create(ctx, in)
|
|
}
|
|
if in.Title == "" {
|
|
return nil, invalidArgument("title is required")
|
|
}
|
|
if in.Priority != "" && taskmodels.ValidateTaskPriority(in.Priority) != nil {
|
|
return nil, invalidArgument(fmt.Sprintf("invalid task priority %q", in.Priority))
|
|
}
|
|
metadata, err := r.host.pluginTaskMetadata(in.Metadata)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err := r.host.validatePluginTaskRepositories(in.Repositories); err != nil {
|
|
return nil, err
|
|
}
|
|
launch, err := taskLaunchInput(in.Launch)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if in.StartAgent {
|
|
if err := r.host.validateTaskLaunch(ctx, launch); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
workspaceID, workflowID, err := r.host.resolveCreatePlacement(ctx, in)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
created, err := r.host.taskWriter.CreateTask(ctx, TaskCreateInput{
|
|
WorkspaceID: workspaceID,
|
|
WorkflowID: workflowID,
|
|
WorkflowStepID: strDeref(in.WorkflowStepID),
|
|
Title: in.Title,
|
|
Description: in.Description,
|
|
ParentID: strDeref(in.ParentID),
|
|
Source: r.host.pluginSource(),
|
|
Repositories: in.Repositories,
|
|
Launch: launch,
|
|
Metadata: metadata,
|
|
PlanMode: launch.PlanMode,
|
|
Priority: in.Priority,
|
|
StartAgent: in.StartAgent,
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if in.StartAgent {
|
|
r.host.startTaskBestEffort(ctx, created.ID, launch)
|
|
}
|
|
items := []pluginsdk.Task{taskModelToDTO(created)}
|
|
if err := r.host.attachDependencies(ctx, items, []*taskmodels.Task{created}, false, "CreateTask"); err != nil {
|
|
return nil, err
|
|
}
|
|
return &items[0], nil
|
|
}
|
|
|
|
// writeTaskUpdate performs UpdateTask's validation and mutation without
|
|
// attaching pull requests or the dependency projection, returning the raw
|
|
// model. A caller that folds this write into a larger response alongside
|
|
// another write (the canvas PATCH route's Update+Move body) must derive once
|
|
// on whichever result is actually serialized, not once per write it makes;
|
|
// taskReader.Update wraps this for the ordinary single-write callers (gRPC,
|
|
// and every other webapp route), attaching immediately after.
|
|
func (h *pluginHost) writeTaskUpdate(ctx context.Context, in pluginsdk.UpdateTaskInput) (*taskmodels.Task, error) {
|
|
if !h.capabilities.CanWrite(resourceTasks) {
|
|
return nil, permissionDenied(apiWriteCapability(resourceTasks))
|
|
}
|
|
if h.taskWriter == nil {
|
|
_, err := h.UnimplementedHostData.Tasks().Update(ctx, in)
|
|
return nil, err
|
|
}
|
|
if in.ID == "" {
|
|
return nil, invalidArgument("id is required")
|
|
}
|
|
if in.WorkflowStepID != nil {
|
|
return nil, invalidArgument(
|
|
"workflow_step_id cannot be set via UpdateTask: use MoveTask to transition a task between workflow steps",
|
|
)
|
|
}
|
|
if in.Priority != nil && taskmodels.ValidateTaskPriority(*in.Priority) != nil {
|
|
return nil, invalidArgument(fmt.Sprintf("invalid task priority %q", *in.Priority))
|
|
}
|
|
updated, err := h.taskWriter.UpdateTask(ctx, TaskUpdateInput{
|
|
ID: in.ID,
|
|
Title: in.Title,
|
|
Description: in.Description,
|
|
State: in.State,
|
|
WorkflowStepID: in.WorkflowStepID,
|
|
Priority: in.Priority,
|
|
})
|
|
if err != nil {
|
|
if errors.Is(err, repoerrors.ErrTaskNotFound) {
|
|
return nil, taskNotFound(in.ID)
|
|
}
|
|
return nil, err
|
|
}
|
|
return updated, nil
|
|
}
|
|
|
|
func (r taskReader) Update(ctx context.Context, in pluginsdk.UpdateTaskInput) (*pluginsdk.Task, error) {
|
|
updated, err := r.host.writeTaskUpdate(ctx, in)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
items := []pluginsdk.Task{taskModelToDTO(updated)}
|
|
r.host.attachPullRequests(ctx, items)
|
|
if err := r.host.attachDependencies(ctx, items, []*taskmodels.Task{updated}, false, "UpdateTask"); err != nil {
|
|
return nil, err
|
|
}
|
|
return &items[0], nil
|
|
}
|
|
|
|
func (r taskReader) Move(ctx context.Context, in pluginsdk.MoveTaskInput) (*pluginsdk.MoveTaskOutcome, error) {
|
|
if !r.host.capabilities.CanWrite(resourceTasks) {
|
|
return nil, permissionDenied(apiWriteCapability(resourceTasks))
|
|
}
|
|
if r.host.taskWriter == nil {
|
|
return r.host.UnimplementedHostData.Tasks().Move(ctx, in)
|
|
}
|
|
if in.TaskID == "" {
|
|
return nil, invalidArgument("task_id is required")
|
|
}
|
|
if in.WorkflowStepID == "" {
|
|
return nil, invalidArgument("workflow_step_id is required")
|
|
}
|
|
if in.WorkflowID != nil && *in.WorkflowID == "" {
|
|
return nil, invalidArgument("workflow_id must not be empty when present")
|
|
}
|
|
if in.Position < 0 {
|
|
return nil, invalidArgument("position must not be negative")
|
|
}
|
|
result, err := r.host.taskWriter.MoveTask(ctx, TaskMoveInput{
|
|
TaskID: in.TaskID,
|
|
WorkflowStepID: in.WorkflowStepID,
|
|
WorkflowID: in.WorkflowID,
|
|
Position: in.Position,
|
|
Source: r.host.pluginSource(),
|
|
})
|
|
if err != nil {
|
|
if errors.Is(err, repoerrors.ErrTaskNotFound) {
|
|
return nil, taskNotFound(in.TaskID)
|
|
}
|
|
return nil, err
|
|
}
|
|
var queuedForStepID *string
|
|
if result.Task.QueuedForStepID != "" {
|
|
q := result.Task.QueuedForStepID
|
|
queuedForStepID = &q
|
|
}
|
|
items := []pluginsdk.Task{taskModelToDTO(result.Task)}
|
|
r.host.attachPullRequests(ctx, items)
|
|
if err := r.host.attachDependencies(ctx, items, []*taskmodels.Task{result.Task}, false, "MoveTask"); err != nil {
|
|
return nil, err
|
|
}
|
|
return &pluginsdk.MoveTaskOutcome{
|
|
Task: &items[0],
|
|
Transitioned: result.Transitioned,
|
|
QueuedForStepID: queuedForStepID,
|
|
FromStepID: result.FromStepID,
|
|
}, nil
|
|
}
|
|
|
|
// resolveCreatePlacement fills the workspace and workflow a created task lands
|
|
// in when the plugin leaves them empty, mirroring the REST/MCP "sane default"
|
|
// behavior: an empty workspace_id resolves to the single workspace (ambiguous
|
|
// when there are zero or many → InvalidArgument), and an empty workflow_id
|
|
// resolves to that workspace's first workflow. Both defaulters need the read
|
|
// data sources; if those aren't wired the plugin must pass the ids explicitly.
|
|
func (h *pluginHost) resolveCreatePlacement(ctx context.Context, in pluginsdk.CreateTaskInput) (string, string, error) {
|
|
workspaceID := in.WorkspaceID
|
|
if workspaceID == "" {
|
|
resolved, err := h.defaultWorkspaceID(ctx)
|
|
if err != nil {
|
|
return "", "", err
|
|
}
|
|
workspaceID = resolved
|
|
}
|
|
workflowID := in.WorkflowID
|
|
if workflowID == "" {
|
|
resolved, err := h.defaultWorkflowID(ctx, workspaceID)
|
|
if err != nil {
|
|
return "", "", err
|
|
}
|
|
workflowID = resolved
|
|
}
|
|
return workspaceID, workflowID, nil
|
|
}
|
|
|
|
func (h *pluginHost) defaultWorkspaceID(ctx context.Context) (string, error) {
|
|
if h.taskData == nil {
|
|
return "", invalidArgument("workspace_id is required")
|
|
}
|
|
workspaces, err := h.taskData.ListWorkspaces(ctx)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if len(workspaces) != 1 {
|
|
return "", invalidArgument("workspace_id is required: cannot resolve a default among the instance's workspaces")
|
|
}
|
|
return workspaces[0].ID, nil
|
|
}
|
|
|
|
func (h *pluginHost) defaultWorkflowID(ctx context.Context, workspaceID string) (string, error) {
|
|
if h.workflows == nil {
|
|
return "", invalidArgument("workflow_id is required")
|
|
}
|
|
workflows, err := h.workflows.ListWorkflows(ctx, workspaceID, false)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if len(workflows) == 0 {
|
|
return "", invalidArgument("workflow_id is required: workspace has no workflow to default to")
|
|
}
|
|
// ListWorkflows returns workflows in sort order, so the first is the
|
|
// workspace's default landing workflow.
|
|
return workflows[0].ID, nil
|
|
}
|
|
|
|
// startTaskBestEffort asks the wired starter to launch an agent on the freshly
|
|
// created task when the plugin requested start_agent. Best-effort and
|
|
// non-fatal, matching the REST/MCP path's asynchronous auto-start: the starter
|
|
// returns promptly (it launches on a detached, time-bounded context — see the
|
|
// taskStarter doc), and a missing starter or a launch error just leaves the
|
|
// task on the board for a manual start.
|
|
func (h *pluginHost) startTaskBestEffort(ctx context.Context, taskID string, launch TaskLaunchInput) {
|
|
_, starter := h.writeDependencies()
|
|
if starter == nil {
|
|
return
|
|
}
|
|
_ = starter.StartTask(ctx, taskID, launch)
|
|
}
|
|
|
|
// pluginTaskMetadata stamps immutable provenance and keeps plugin data under
|
|
// that provenance key. A plugin cannot replace source or write host metadata
|
|
// outside its own namespace.
|
|
func (h *pluginHost) pluginTaskMetadata(pluginMetadata map[string]any) (map[string]any, error) {
|
|
if _, exists := pluginMetadata[taskSourceMetadataKey]; exists {
|
|
return nil, invalidArgument("metadata.source is reserved")
|
|
}
|
|
metadata := map[string]any{taskSourceMetadataKey: h.pluginSource()}
|
|
if len(pluginMetadata) > 0 {
|
|
metadata[h.pluginSource()] = pluginMetadata
|
|
}
|
|
return metadata, nil
|
|
}
|
|
|
|
func (h *pluginHost) validatePluginTaskRepositories(repositories []pluginsdk.PluginTaskRepository) error {
|
|
for _, repository := range repositories {
|
|
if repository.RepositoryID != "" && repository.Remote != nil {
|
|
return invalidArgument("repository_id and remote descriptor are mutually exclusive")
|
|
}
|
|
if repository.RepositoryID == "" && repository.Remote == nil {
|
|
return invalidArgument("repository_id or remote descriptor is required")
|
|
}
|
|
if repository.Remote == nil {
|
|
continue
|
|
}
|
|
if err := validateRemoteRepositoryDescriptor(repository.Remote); err != nil {
|
|
return err
|
|
}
|
|
if !h.ownsRepositoryProvider(repository.Remote.ProviderID) {
|
|
return statusPermissionDeniedProvider(repository.Remote.ProviderID)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func validateRemoteRepositoryDescriptor(descriptor *pluginsdk.RemoteRepositoryDescriptor) error {
|
|
if descriptor.ProviderID == "" || descriptor.ProviderHost == "" || descriptor.OwnerOrProject == "" ||
|
|
descriptor.ProviderRepositoryID == "" || descriptor.Name == "" || descriptor.CloneURL == "" {
|
|
return invalidArgument("remote repository descriptor is incomplete")
|
|
}
|
|
if err := repoclone.ValidateHTTPSCloneOrigin(descriptor.CloneURL, descriptor.ProviderHost); err != nil {
|
|
return invalidArgument("remote repository clone_url must match its credential-free HTTPS provider origin")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (h *pluginHost) ownsRepositoryProvider(provider string) bool {
|
|
for _, declared := range h.repositoryProviders {
|
|
if strings.EqualFold(provider, declared) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func statusPermissionDeniedProvider(provider string) error {
|
|
return permissionDenied(fmt.Sprintf("repository_provider:%s", provider))
|
|
}
|
|
|
|
func taskLaunchInput(launch *pluginsdk.PluginTaskLaunchOptions) (TaskLaunchInput, error) {
|
|
if launch == nil {
|
|
return TaskLaunchInput{}, nil
|
|
}
|
|
input := TaskLaunchInput{
|
|
AgentProfileID: strDeref(launch.AgentProfileID),
|
|
ExecutorProfileID: strDeref(launch.ExecutorProfileID),
|
|
Prompt: strDeref(launch.Prompt),
|
|
}
|
|
if launch.PlanMode == nil || *launch.PlanMode == "" || strings.EqualFold(*launch.PlanMode, "off") {
|
|
return input, nil
|
|
}
|
|
if strings.EqualFold(*launch.PlanMode, "replace") || strings.EqualFold(*launch.PlanMode, "on") {
|
|
input.PlanMode = true
|
|
return input, nil
|
|
}
|
|
return TaskLaunchInput{}, invalidArgument("launch.plan_mode must be one of: off, on, replace")
|
|
}
|
|
|
|
// validateTaskLaunch confirms that auto-start profile references exist before
|
|
// the task is created. This avoids a plugin creating a task that is guaranteed
|
|
// to fail its requested launch, while keeping launch failures after creation
|
|
// best-effort as documented by taskStarter.
|
|
func (h *pluginHost) validateTaskLaunch(ctx context.Context, launch TaskLaunchInput) error {
|
|
if err := h.validateLaunchAgentProfile(ctx, launch.AgentProfileID); err != nil {
|
|
return err
|
|
}
|
|
return h.validateLaunchExecutorProfile(ctx, launch.ExecutorProfileID)
|
|
}
|
|
|
|
func (h *pluginHost) validateLaunchAgentProfile(ctx context.Context, id string) error {
|
|
if id == "" {
|
|
return nil
|
|
}
|
|
if h.agentProfiles == nil {
|
|
return status.Error(codes.Unimplemented, "agent profiles are unavailable")
|
|
}
|
|
response, err := h.agentProfiles.ListAgents(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if hasAgentProfile(response, id) {
|
|
return nil
|
|
}
|
|
return invalidArgument("launch.agent_profile_id is not available")
|
|
}
|
|
|
|
func hasAgentProfile(response *agentsettingsdto.ListAgentsResponse, id string) bool {
|
|
if response == nil {
|
|
return false
|
|
}
|
|
for _, agent := range response.Agents {
|
|
for _, profile := range agent.Profiles {
|
|
if profile.ID == id {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (h *pluginHost) validateLaunchExecutorProfile(ctx context.Context, id string) error {
|
|
if id == "" {
|
|
return nil
|
|
}
|
|
if h.taskData == nil {
|
|
return status.Error(codes.Unimplemented, "executor profiles are unavailable")
|
|
}
|
|
profiles, err := h.taskData.ListAllExecutorProfiles(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if hasExecutorProfile(profiles, id) {
|
|
return nil
|
|
}
|
|
return invalidArgument("launch.executor_profile_id is not available")
|
|
}
|
|
|
|
func hasExecutorProfile(profiles []*taskmodels.ExecutorProfile, id string) bool {
|
|
for _, profile := range profiles {
|
|
if profile != nil && profile.ID == id {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// PluginOwnedTaskTrees returns a manager whose preview and delete operations
|
|
// require api_write:tasks and exact source provenance on every traversed node.
|
|
func (h *pluginHost) PluginOwnedTaskTrees() pluginsdk.PluginOwnedTaskTreeManager {
|
|
return pluginOwnedTaskTreeManager{host: h}
|
|
}
|
|
|
|
type pluginOwnedTaskTreeManager struct{ host *pluginHost }
|
|
|
|
func (m pluginOwnedTaskTreeManager) Preview(ctx context.Context, rootTaskID string) ([]pluginsdk.Task, error) {
|
|
tasks, err := m.host.pluginOwnedTaskTree(ctx, rootTaskID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
dtos := tasksToDTOs(tasks)
|
|
m.host.attachPullRequests(ctx, dtos)
|
|
if err := m.host.attachDependencies(ctx, dtos, tasks, true, "PreviewPluginOwnedTaskTree"); err != nil {
|
|
return nil, err
|
|
}
|
|
return dtos, nil
|
|
}
|
|
|
|
func (m pluginOwnedTaskTreeManager) Delete(ctx context.Context, rootTaskID string) ([]string, error) {
|
|
tasks, err := m.host.pluginOwnedTaskTree(ctx, rootTaskID)
|
|
if err != nil {
|
|
// Deletion is a retry boundary. A prior attempt may have removed the
|
|
// root before its caller persisted progress, so absence is success.
|
|
if status.Code(err) == codes.NotFound {
|
|
return []string{}, nil
|
|
}
|
|
return nil, err
|
|
}
|
|
if m.host.taskWriter == nil {
|
|
return m.host.UnimplementedHostData.PluginOwnedTaskTrees().Delete(ctx, rootTaskID)
|
|
}
|
|
if err := m.host.rejectMixedOwnershipDelete(ctx, tasks); err != nil {
|
|
return nil, err
|
|
}
|
|
deleted := make([]string, 0, len(tasks))
|
|
deleteCtx := context.WithoutCancel(ctx)
|
|
for index := len(tasks) - 1; index >= 0; index-- {
|
|
if err := m.host.taskWriter.DeleteTask(deleteCtx, tasks[index].ID); err != nil {
|
|
return deleted, fmt.Errorf("delete plugin-owned task tree after deleting %d task(s): %w", len(deleted), err)
|
|
}
|
|
deleted = append(deleted, tasks[index].ID)
|
|
}
|
|
return deleted, nil
|
|
}
|
|
|
|
func (h *pluginHost) rejectMixedOwnershipDelete(ctx context.Context, ownedTasks []*taskmodels.Task) error {
|
|
if len(ownedTasks) == 0 {
|
|
return nil
|
|
}
|
|
ownedIDs := make(map[string]struct{}, len(ownedTasks))
|
|
for _, task := range ownedTasks {
|
|
ownedIDs[task.ID] = struct{}{}
|
|
}
|
|
workspaceTasks, err := h.fetchTasksForWorkspaces(ctx, []string{ownedTasks[0].WorkspaceID}, true, true)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for _, task := range workspaceTasks {
|
|
if _, parentWillBeDeleted := ownedIDs[task.ParentID]; !parentWillBeDeleted {
|
|
continue
|
|
}
|
|
if _, childWillBeDeleted := ownedIDs[task.ID]; childWillBeDeleted {
|
|
continue
|
|
}
|
|
return status.Error(
|
|
codes.FailedPrecondition,
|
|
"plugin-owned task tree contains adopted or user-owned children; detach them before deleting",
|
|
)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (h *pluginHost) pluginOwnedTaskTree(ctx context.Context, rootTaskID string) ([]*taskmodels.Task, error) {
|
|
if !h.capabilities.CanWrite(resourceTasks) {
|
|
return nil, permissionDenied(apiWriteCapability(resourceTasks))
|
|
}
|
|
if h.taskData == nil {
|
|
_, err := h.UnimplementedHostData.PluginOwnedTaskTrees().Preview(ctx, rootTaskID)
|
|
return nil, err
|
|
}
|
|
root, err := h.taskData.GetTask(ctx, rootTaskID)
|
|
if err != nil {
|
|
if errors.Is(err, repoerrors.ErrTaskNotFound) {
|
|
return nil, taskNotFound(rootTaskID)
|
|
}
|
|
return nil, err
|
|
}
|
|
if taskSource(root) != h.pluginSource() {
|
|
return nil, permissionDenied("plugin_owned_tasks")
|
|
}
|
|
tasks, err := h.fetchTasksForWorkspaces(ctx, []string{root.WorkspaceID}, true, true)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
children := make(map[string][]*taskmodels.Task)
|
|
for _, task := range tasks {
|
|
children[task.ParentID] = append(children[task.ParentID], task)
|
|
}
|
|
return ownedTaskSubtree(root, children, h.pluginSource()), nil
|
|
}
|
|
|
|
func ownedTaskSubtree(root *taskmodels.Task, children map[string][]*taskmodels.Task, source string) []*taskmodels.Task {
|
|
if root == nil || taskSource(root) != source {
|
|
return nil
|
|
}
|
|
out := []*taskmodels.Task{root}
|
|
for _, child := range children[root.ID] {
|
|
out = append(out, ownedTaskSubtree(child, children, source)...)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func taskSource(task *taskmodels.Task) string {
|
|
if task == nil || task.Metadata == nil {
|
|
return ""
|
|
}
|
|
source, _ := task.Metadata[taskSourceMetadataKey].(string)
|
|
return source
|
|
}
|
|
|
|
// ── Message send (api_write:messages) ───────────────────────────────────
|
|
|
|
func (r messageReader) Send(ctx context.Context, taskID, sessionID, text string) (*pluginsdk.MessageDispatch, error) {
|
|
if !r.host.capabilities.CanWrite(resourceMessages) {
|
|
return nil, permissionDenied(apiWriteCapability(resourceMessages))
|
|
}
|
|
messenger, _ := r.host.writeDependencies()
|
|
if messenger == nil {
|
|
return r.host.UnimplementedHostData.Messages().Send(ctx, taskID, sessionID, text)
|
|
}
|
|
if taskID == "" {
|
|
return nil, invalidArgument("task_id is required")
|
|
}
|
|
if text == "" {
|
|
return nil, invalidArgument("text is required")
|
|
}
|
|
result, err := messenger.SendMessage(ctx, taskID, sessionID, text, r.host.pluginSource())
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &pluginsdk.MessageDispatch{SessionID: result.SessionID, Status: result.Status}, nil
|
|
}
|
|
|
|
// strDeref returns the pointed-to string, or "" when the pointer is nil —
|
|
// translating the SDK's *string "absent" convention into the plain strings the
|
|
// task service's CreateTaskRequest uses.
|
|
func strDeref(s *string) string {
|
|
if s == nil {
|
|
return ""
|
|
}
|
|
return *s
|
|
}
|