Files
kandev/apps/backend/internal/backendapp/plugins_interactions.go
T
2026-08-24 14:14:38 +00:00

171 lines
6.8 KiB
Go

// plugins_interactions.go adapts kandev's first-party interaction response
// paths to the narrow interface the plugin Host interaction API needs (ADR
// 0052). It lives in backendapp for the same reason the task-write adapters
// do: internal/plugins cannot import the orchestrator or the clarification
// resolver without an import cycle.
//
// Nothing here re-implements a response. Permissions go through
// orchestrator.ResolveAgentPermission — the claim/deliver/finalize path the
// web and external-MCP surfaces share, which owns the durable claim that keeps
// two responders from both reaching the agent. Clarification bundles go
// through the same clarification.Resolver instance the REST route and the MCP
// handlers use. The adapter's only real work is translating each path's error
// vocabulary into the gRPC codes the plugin contract promises.
package backendapp
import (
"context"
"errors"
"github.com/kandev/kandev/internal/clarification"
"github.com/kandev/kandev/internal/orchestrator"
"github.com/kandev/kandev/internal/plugins"
taskmodels "github.com/kandev/kandev/internal/task/models"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
// permissionResolver is the orchestrator slice the permission path needs.
// ResolveAgentPermission is used rather than the RespondToPermission wrapper
// so the plugin's responses are attributed to their own source rather than
// silently recorded as web.
type permissionResolver interface {
ResolveAgentPermission(
ctx context.Context, request orchestrator.ResolveAgentPermissionRequest,
) (*orchestrator.ResolveAgentPermissionResult, error)
// RespondToPermission is the exported wrapper that owns the cancel path;
// the orchestrator keeps its cancel helper unexported.
RespondToPermission(
ctx context.Context, taskID, sessionID, requestID, pendingID, optionID string, cancelled, rejected bool,
) error
}
// clarificationBundleResolver is the clarification-resolver slice the
// question-bundle path needs.
type clarificationBundleResolver interface {
ResolveBundle(
ctx context.Context, pendingID string, outcome clarification.Outcome,
) (*clarification.Resolution, bool, error)
}
// pluginsInteractionResponderAdapter satisfies internal/plugins'
// interactionResponder.
type pluginsInteractionResponderAdapter struct {
permissions permissionResolver
clarifications clarificationBundleResolver
}
func (a pluginsInteractionResponderAdapter) RespondToPermission(
ctx context.Context, in plugins.PluginPermissionResponse,
) error {
if a.permissions == nil {
return status.Error(codes.Unimplemented, "permission responses are unavailable")
}
// A dismissal has no option to select, and the orchestrator keeps its
// cancel helper unexported, so route it through the exported wrapper
// rather than inventing an option id the agent never offered.
if in.Cancelled {
return permissionResolutionStatus(a.permissions.RespondToPermission(
ctx, in.TaskID, in.SessionID, in.RequestID, in.PendingID, "", true, false))
}
_, err := a.permissions.ResolveAgentPermission(ctx, orchestrator.ResolveAgentPermissionRequest{
TaskID: in.TaskID,
SessionID: in.SessionID,
RequestID: in.RequestID,
PendingID: in.PendingID,
OptionID: in.OptionID,
Source: taskmodels.PermissionSourceAutomation,
})
return permissionResolutionStatus(err)
}
func (a pluginsInteractionResponderAdapter) AnswerClarification(
ctx context.Context, pendingID string, answers []plugins.PluginClarificationAnswer,
) error {
if a.clarifications == nil {
return status.Error(codes.Unimplemented, "clarification responses are unavailable")
}
outcome := clarification.Outcome{Answers: make([]clarification.Answer, len(answers))}
for i, answer := range answers {
outcome.Answers[i] = clarification.Answer{
QuestionID: answer.QuestionID,
SelectedOptions: answer.SelectedOptions,
CustomText: answer.CustomText,
}
}
return a.resolveBundle(ctx, pendingID, outcome)
}
// DeclineClarification routes a cancel through the resolver's REJECT outcome
// rather than the operator cancel route. Cancel needs the in-memory pending
// entry, so it only works while the original waiter is still parked; the
// reject outcome is durable-claim based and therefore also settles a bundle
// whose waiter went away in a restart, which is precisely the case a
// reconciling plugin is holding.
func (a pluginsInteractionResponderAdapter) DeclineClarification(
ctx context.Context, pendingID, reason string,
) error {
if a.clarifications == nil {
return status.Error(codes.Unimplemented, "clarification responses are unavailable")
}
return a.resolveBundle(ctx, pendingID, clarification.Outcome{
Rejected: true,
RejectReason: reason,
})
}
// resolveBundle maps the resolver's outcome onto the plugin contract. A
// non-claimed resolution means another responder settled the bundle first,
// which is the same FailedPrecondition the host returns when it detects the
// terminal state itself — so a plugin branches on one code however the race
// was caught.
func (a pluginsInteractionResponderAdapter) resolveBundle(
ctx context.Context, pendingID string, outcome clarification.Outcome,
) error {
_, claimed, err := a.clarifications.ResolveBundle(ctx, pendingID, outcome)
if err != nil {
return clarificationResolutionStatus(err)
}
if !claimed {
return status.Errorf(codes.FailedPrecondition,
"clarification %s was already resolved by another responder", pendingID)
}
return nil
}
func clarificationResolutionStatus(err error) error {
switch {
case err == nil:
return nil
case errors.Is(err, clarification.ErrBundleNotFound):
return status.Error(codes.NotFound, err.Error())
case clarification.IsValidationError(err):
return status.Error(codes.InvalidArgument, err.Error())
default:
return status.Error(codes.Internal, err.Error())
}
}
// permissionResolutionStatus maps the orchestrator's permission vocabulary
// onto the plugin contract. Everything that means "this interaction can no
// longer take the response you submitted" collapses to FailedPrecondition,
// which is what terminal-once promises; an unknown identity is NotFound, and
// an option the agent never offered is the caller's mistake.
func permissionResolutionStatus(err error) error {
switch {
case err == nil:
return nil
case errors.Is(err, orchestrator.ErrPermissionNotFound),
errors.Is(err, orchestrator.ErrPermissionTaskOrSessionNotFound):
return status.Error(codes.NotFound, err.Error())
case errors.Is(err, orchestrator.ErrPermissionOptionNotOffered):
return status.Error(codes.InvalidArgument, err.Error())
case errors.Is(err, orchestrator.ErrPermissionAlreadyResolved),
errors.Is(err, orchestrator.ErrPermissionStale),
errors.Is(err, orchestrator.ErrPermissionResolutionInProgress):
return status.Error(codes.FailedPrecondition, err.Error())
default:
return err
}
}