Files
2026-08-24 14:14:38 +00:00

376 lines
14 KiB
Go

// host_interactions.go implements pluginHost's Interactions() accessor — the
// Host interaction API (ADR 0052). Reads are gated on api_read:interactions,
// writes on api_write:interactions; the two gate independently, so an
// attention or inbox plugin can declare read-only and still reconcile.
//
// The gate cannot live at the accessor (like Tasks() and Messages(), this one
// mixes reads and writes), so every method checks its own capability.
//
// Reads come from the durable request record through the task service — the
// same authority kandev's own pending-action projection uses, never a
// repository directly. Writes route through the first-party services the
// native UI drives (the orchestrator for permissions, the clarification
// handler for question bundles), so the agent actually unblocks and every
// surface converges through the normal events. Both dependencies arrive
// through narrow interfaces satisfied by backendapp adapters, because the
// service types cannot be referenced here without an import cycle.
package plugins
import (
"context"
"fmt"
"strings"
taskmodels "github.com/kandev/kandev/internal/task/models"
"github.com/kandev/kandev/pkg/pluginsdk"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
// interactionDataSource is the narrow slice of internal/task/service.Service
// the interaction reads need. GetInteraction resolves ANY interaction,
// terminal ones included — that is what makes a plugin's event cache
// reconcilable — and returns (nil, nil) for an unknown id.
type interactionDataSource interface {
ListPendingInteractions(ctx context.Context, filter taskmodels.PendingInteractionFilter) ([]*taskmodels.Interaction, error)
GetInteraction(ctx context.Context, pendingID string) (*taskmodels.Interaction, error)
}
// PluginPermissionResponse is the plugins-local permission response a
// responder adapter translates into the orchestrator's resolution request.
// Kandev's permission resolution is keyed on the full (task, session, request,
// pending) identity, and every field is taken from the durable record rather
// than from the plugin.
type PluginPermissionResponse struct {
TaskID string
SessionID string
RequestID string
PendingID string
OptionID string
Cancelled bool
}
// PluginClarificationAnswer is the plugins-local answer a responder adapter
// translates into the clarification resolver's outcome.
type PluginClarificationAnswer struct {
QuestionID string
SelectedOptions []string
CustomText string
}
// interactionResponder is the narrow write path, adapted by backendapp over
// the orchestrator and the clarification handler. Implementations MUST return
// gRPC status errors so the outcome vocabulary a plugin sees (NotFound for an
// unknown id, FailedPrecondition for an already-resolved one, InvalidArgument
// for a malformed answer set) is decided by the layer that knows the
// first-party service's failure modes rather than by string matching here.
type interactionResponder interface {
RespondToPermission(ctx context.Context, in PluginPermissionResponse) error
AnswerClarification(ctx context.Context, pendingID string, answers []PluginClarificationAnswer) error
DeclineClarification(ctx context.Context, pendingID, reason string) error
}
// Interactions returns the interaction accessor. Like Tasks() and Messages()
// it mixes independently-gated reads and writes, so the capability check lives
// on each method rather than here.
func (h *pluginHost) Interactions() pluginsdk.InteractionAccessor {
return interactionReader{host: h}
}
var _ pluginsdk.InteractionHost = (*pluginHost)(nil)
type interactionReader struct{ host *pluginHost }
func (r interactionReader) ListPending(
ctx context.Context, filter pluginsdk.InteractionFilter, page pluginsdk.Page,
) ([]pluginsdk.Interaction, *pluginsdk.PageInfo, error) {
if !r.host.capabilities.CanRead(resourceInteractions) {
return nil, nil, permissionDenied(apiReadCapability(resourceInteractions))
}
if r.host.interactionData == nil {
return r.host.UnimplementedHostData.Interactions().ListPending(ctx, filter, page)
}
if err := validateInteractionKinds(filter.Kinds); err != nil {
return nil, nil, err
}
// Each session/task/kind value becomes its own SQL bind parameter; cap the
// combined count for the same reason messageReader.List does, so a large
// filter fails fast instead of hitting the host-parameter limit.
if n := len(filter.SessionIDs) + len(filter.TaskIDs) + len(filter.Kinds); n > maxMessageFilterValues {
return nil, nil, invalidArgument(fmt.Sprintf(
"interaction filter has %d session/task/kind values, max %d", n, maxMessageFilterValues))
}
interactions, err := r.host.interactionData.ListPendingInteractions(ctx, taskmodels.PendingInteractionFilter{
SessionIDs: filter.SessionIDs,
TaskIDs: filter.TaskIDs,
Kinds: filter.Kinds,
})
if err != nil {
return nil, nil, err
}
items, info := paginate(interactionsToDTOs(interactions), page)
return items, info, nil
}
// Get returns a gRPC NotFound error (not a (nil, nil) success) for an id that
// resolves to nothing, so the in-process contract matches what a plugin
// observes over the wire.
func (r interactionReader) Get(ctx context.Context, id string) (*pluginsdk.Interaction, error) {
if !r.host.capabilities.CanRead(resourceInteractions) {
return nil, permissionDenied(apiReadCapability(resourceInteractions))
}
if r.host.interactionData == nil {
return r.host.UnimplementedHostData.Interactions().Get(ctx, id)
}
interaction, err := r.host.resolveInteraction(ctx, id)
if err != nil {
return nil, err
}
dto := interactionModelToDTO(interaction)
return &dto, nil
}
func (r interactionReader) RespondToPermission(
ctx context.Context, in pluginsdk.PermissionResponse,
) (*pluginsdk.Interaction, error) {
responder, err := r.host.interactionWriteTarget()
if err != nil {
return nil, err
}
if responder == nil {
return r.host.UnimplementedHostData.Interactions().RespondToPermission(ctx, in)
}
interaction, err := r.host.answerableInteraction(ctx, in.InteractionID, taskmodels.InteractionKindPermission)
if err != nil {
return nil, err
}
optionID, rejected, err := resolvePermissionChoice(interaction, in)
if err != nil {
return nil, err
}
if err := responder.RespondToPermission(ctx, PluginPermissionResponse{
TaskID: interaction.TaskID,
SessionID: interaction.SessionID,
RequestID: interaction.RequestID,
PendingID: interaction.ID,
OptionID: optionID,
Cancelled: in.Cancelled,
}); err != nil {
return nil, err
}
// The orchestrator records the same derivation: a cancelled or reject-kind
// response is rejected, anything else approved.
resolved := taskmodels.InteractionStatusApproved
if rejected || in.Cancelled {
resolved = taskmodels.InteractionStatusRejected
}
return r.host.reloadInteraction(ctx, interaction, resolved)
}
func (r interactionReader) AnswerClarification(
ctx context.Context, in pluginsdk.ClarificationResponse,
) (*pluginsdk.Interaction, error) {
responder, err := r.host.interactionWriteTarget()
if err != nil {
return nil, err
}
if responder == nil {
return r.host.UnimplementedHostData.Interactions().AnswerClarification(ctx, in)
}
interaction, err := r.host.answerableInteraction(ctx, in.InteractionID, taskmodels.InteractionKindClarification)
if err != nil {
return nil, err
}
if len(in.Answers) == 0 {
return nil, invalidArgument("answers is required")
}
answers := make([]PluginClarificationAnswer, len(in.Answers))
for i, answer := range in.Answers {
if answer.QuestionID == "" {
return nil, invalidArgument(fmt.Sprintf("answer %d is missing question_id", i+1))
}
answers[i] = PluginClarificationAnswer{
QuestionID: answer.QuestionID,
SelectedOptions: answer.SelectedOptions,
CustomText: answer.CustomText,
}
}
if err := responder.AnswerClarification(ctx, interaction.ID, answers); err != nil {
return nil, err
}
return r.host.reloadInteraction(ctx, interaction, taskmodels.InteractionStatusAnswered)
}
func (r interactionReader) CancelClarification(
ctx context.Context, id, reason string,
) (*pluginsdk.Interaction, error) {
responder, err := r.host.interactionWriteTarget()
if err != nil {
return nil, err
}
if responder == nil {
return r.host.UnimplementedHostData.Interactions().CancelClarification(ctx, id, reason)
}
interaction, err := r.host.answerableInteraction(ctx, id, taskmodels.InteractionKindClarification)
if err != nil {
return nil, err
}
if err := responder.DeclineClarification(ctx, interaction.ID, reason); err != nil {
return nil, err
}
// Cancel is delivered as a decline of the bundle, so its terminal status is
// rejected rather than cancelled (see the DeclineClarification adapter).
return r.host.reloadInteraction(ctx, interaction, taskmodels.InteractionStatusRejected)
}
// interactionWriteTarget performs the checks every write shares: the
// api_write:interactions gate, and both halves of the write path being wired
// (the responder that delivers, and the reader that resolves the target and
// its post-write state). Returns a nil responder with a nil error when the
// host is unwired, so the caller can answer through its OWN Unimplemented
// counterpart rather than borrowing an unrelated method's error.
func (h *pluginHost) interactionWriteTarget() (interactionResponder, error) {
if !h.capabilities.CanWrite(resourceInteractions) {
return nil, permissionDenied(apiWriteCapability(resourceInteractions))
}
responder := h.interactionResponder()
if responder == nil || h.interactionData == nil {
return nil, nil
}
return responder, nil
}
func (h *pluginHost) interactionResponder() interactionResponder {
if h.interactionDeps == nil {
return nil
}
return h.interactionDeps()
}
// answerableInteraction resolves id and enforces the terminal-once contract:
// an unknown id is NotFound, an interaction of the wrong kind or one that
// already carries a resolution is FailedPrecondition. Refusing the second
// response is the point — dispatching it would either fail deep inside the
// agent transport or, worse, answer a DIFFERENT request that reused the
// pending slot.
func (h *pluginHost) answerableInteraction(
ctx context.Context, id string, kind taskmodels.InteractionKind,
) (*taskmodels.Interaction, error) {
interaction, err := h.resolveInteraction(ctx, id)
if err != nil {
return nil, err
}
if interaction.Kind != kind {
return nil, status.Errorf(codes.FailedPrecondition,
"interaction %q is a %s request, not a %s request", id, interaction.Kind, kind)
}
if interaction.Status.IsTerminal() {
return nil, status.Errorf(codes.FailedPrecondition,
"interaction %q is already %s", id, interaction.Status)
}
return interaction, nil
}
func (h *pluginHost) resolveInteraction(ctx context.Context, id string) (*taskmodels.Interaction, error) {
if id == "" {
return nil, invalidArgument("id is required")
}
interaction, err := h.interactionData.GetInteraction(ctx, id)
if err != nil {
return nil, err
}
if interaction == nil {
return nil, interactionNotFound(id)
}
return interaction, nil
}
// reloadInteraction returns the interaction's post-write state. The write
// already succeeded, so a failed re-read must not turn a delivered response
// into an error the caller would retry.
//
// The fallback stamps resolved on the pre-write snapshot rather than handing
// it back untouched: the contract says every response method returns the
// interaction in its NEW terminal state, and a snapshot still reading
// "pending" invites exactly the retry of an already-delivered response that
// terminal-once exists to prevent.
func (h *pluginHost) reloadInteraction(
ctx context.Context, before *taskmodels.Interaction, resolved taskmodels.InteractionStatus,
) (*pluginsdk.Interaction, error) {
reloaded, err := h.interactionData.GetInteraction(ctx, before.ID)
if err == nil && reloaded != nil {
dto := interactionModelToDTO(reloaded)
return &dto, nil
}
fallback := *before
fallback.Status = resolved
dto := interactionModelToDTO(&fallback)
return &dto, nil
}
// resolvePermissionChoice validates the plugin's choice against the options
// the agent actually offered and derives the approve/deny outcome from the
// chosen option's kind. A plugin therefore cannot report an outcome that was
// never on the menu, and kandev never has to trust a plugin-supplied verdict.
func resolvePermissionChoice(
interaction *taskmodels.Interaction, in pluginsdk.PermissionResponse,
) (string, bool, error) {
if in.Cancelled {
if in.OptionID != "" {
return "", false, invalidArgument("option_id must be empty when cancelled is set")
}
// A dismissal is not an explicit deny. The orchestrator already
// records cancelled responses as rejected; passing rejected too would
// conflate the two outcomes the message status deliberately splits on.
return "", false, nil
}
if in.OptionID == "" {
return "", false, invalidArgument("option_id is required when cancelled is not set")
}
for _, option := range interaction.Options {
if option.ID != in.OptionID {
continue
}
return option.ID, strings.HasPrefix(option.Kind, permissionRejectKindPrefix), nil
}
return "", false, invalidArgument(fmt.Sprintf(
"option_id %q is not one of interaction %q's options", in.OptionID, interaction.ID))
}
// permissionRejectKindPrefix matches ACP's reject_once / reject_always option
// kinds. Prefix-matching rather than an exact set keeps a future reject_* kind
// denying by default instead of silently approving.
const permissionRejectKindPrefix = "reject"
func validateInteractionKinds(kinds []string) error {
for _, kind := range kinds {
switch taskmodels.InteractionKind(kind) {
case taskmodels.InteractionKindPermission, taskmodels.InteractionKindClarification:
default:
return invalidArgument(fmt.Sprintf("unknown interaction kind %q", kind))
}
}
return nil
}
func interactionNotFound(id string) error {
return status.Errorf(codes.NotFound, "interaction %q not found", id)
}
// ── Denied reader ───────────────────────────────────────────────────────────
//
// Unlike the read-only resources there is no denied stub here: reads and
// writes gate independently on the same accessor, so each method returns its
// own denial naming the capability it needed.
func interactionsToDTOs(interactions []*taskmodels.Interaction) []pluginsdk.Interaction {
out := make([]pluginsdk.Interaction, 0, len(interactions))
for _, interaction := range interactions {
if interaction == nil {
continue
}
out = append(out, interactionModelToDTO(interaction))
}
return out
}