Ctrl K
ComunidadGuías…

internal/adminapi/schedule_write_handler.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
package adminapi

import (
	"bytes"
	"encoding/json"
	"errors"
	"io"
	"net/http"
	"strings"
	"time"

	"github.com/google/jsonschema-go/jsonschema"

	"github.com/yeomyeonggeori/blueclaw/internal/task"
)

const (
	scheduleToolRequestByteLimit = 16384
	scheduleHintPageSize         = 200
)

var errScheduleToolRequestInvalid = errors.New("invalid schedule request")

type scheduleToolCreateRequest struct {
	TaskInstruction string `json:"taskInstruction"`
	Description     string `json:"description"`
	Kind            string `json:"kind"`
	RunAt           string `json:"runAt"`
	ExpiresAt       string `json:"expiresAt"`
	IntervalSecond  int    `json:"intervalSecond"`
	CronExpression  string `json:"cronExpression"`
	TimeZone        string `json:"timeZone"`
	MaxRunCount     int    `json:"maxRunCount"`
	RepeatPolicy    string `json:"repeatPolicy"`
	Platform        string `json:"platform"`
	ConversationID  string `json:"conversationID"`
	ReplyTargetID   string `json:"replyTargetID"`
	IsApproved      bool   `json:"isApproved"`
}

type scheduleToolUpdateRequest struct {
	ScheduleHint    string  `json:"scheduleHint"`
	TaskInstruction *string `json:"taskInstruction"`
	Description     *string `json:"description"`
	Kind            *string `json:"kind"`
	RunAt           *string `json:"runAt"`
	ExpiresAt       *string `json:"expiresAt"`
	IntervalSecond  *int    `json:"intervalSecond"`
	CronExpression  *string `json:"cronExpression"`
	TimeZone        *string `json:"timeZone"`
	MaxRunCount     *int    `json:"maxRunCount"`
	RepeatPolicy    *string `json:"repeatPolicy"`
}

type scheduleToolCancelRequest struct {
	ScheduleHints []string `json:"scheduleHints"`
}

type cancelledScheduleItem struct {
	ScheduleID  string `json:"scheduleID"`
	Description string `json:"description"`
}

type scheduleToolCancelResult struct {
	ScheduleIDs []string                `json:"scheduleIDs"`
	Cancelled   []cancelledScheduleItem `json:"cancelled"`
}

type scheduleHintConflict struct {
	Error      string                   `json:"error"`
	Hint       string                   `json:"hint"`
	Candidates []task.ScheduleCandidate `json:"candidates"`
}

func (scheduleHandler ScheduleHandler) HandleToolCreate(responseWriter http.ResponseWriter, request *http.Request) {
	creatorPersonID, isReady := scheduleHandler.readyForScheduleWrite(responseWriter, request)
	if !isReady {
		return
	}
	var input scheduleToolCreateRequest
	if errorValue := decodeScheduleToolRequest(request, scheduleToolCreateInputSchema, &input); errorValue != nil {
		http.Error(responseWriter, errorValue.Error(), http.StatusBadRequest)
		return
	}
	referenceTime := time.Now().UTC()
	schedule, errorValue := task.CreateSchedule(scheduleHandler.ListRepository, task.ScheduleCreateInput{
		Description:     input.Description,
		TaskInstruction: input.TaskInstruction,
		Kind:            input.Kind,
		RunAt:           input.RunAt,
		ExpiresAt:       input.ExpiresAt,
		IntervalSecond:  input.IntervalSecond,
		CronExpression:  input.CronExpression,
		TimeZone:        input.TimeZone,
		MaxRunCount:     input.MaxRunCount,
		RepeatPolicy:    input.RepeatPolicy,
	}, task.ScheduleCreateContext{
		CreatorPersonID: creatorPersonID,
		Delivery: task.ScheduleDeliveryBinding{
			Platform:       input.Platform,
			ConversationID: input.ConversationID,
			ReplyTargetID:  input.ReplyTargetID,
		},
		CompanyTimeZone: scheduleHandler.companyTimeZone(),
		IsApproved:      input.IsApproved,
		ReferenceTime:   referenceTime,
	})
	if errorValue != nil {
		writeScheduleWriteError(responseWriter, errorValue)
		return
	}
	writeJSON(responseWriter, http.StatusOK, task.ProjectScheduleMutation(schedule))
}

func (scheduleHandler ScheduleHandler) HandleToolUpdate(responseWriter http.ResponseWriter, request *http.Request) {
	creatorPersonID, isReady := scheduleHandler.readyForScheduleWrite(responseWriter, request)
	if !isReady {
		return
	}
	var input scheduleToolUpdateRequest
	if errorValue := decodeScheduleToolRequest(request, scheduleToolUpdateInputSchema, &input); errorValue != nil {
		http.Error(responseWriter, errorValue.Error(), http.StatusBadRequest)
		return
	}
	updateInput := task.ScheduleUpdateInput{
		Description:     input.Description,
		TaskInstruction: input.TaskInstruction,
		Kind:            input.Kind,
		RunAt:           input.RunAt,
		ExpiresAt:       input.ExpiresAt,
		IntervalSecond:  input.IntervalSecond,
		CronExpression:  input.CronExpression,
		TimeZone:        input.TimeZone,
		MaxRunCount:     input.MaxRunCount,
		RepeatPolicy:    input.RepeatPolicy,
	}
	if task.ScheduleUpdateChangesNothing(updateInput) {
		http.Error(responseWriter, task.ErrScheduleUpdateFieldRequired.Error(), http.StatusBadRequest)
		return
	}
	referenceTime := time.Now().UTC()
	ownSchedules, errorValue := scheduleHandler.ownOpenSchedules(creatorPersonID, referenceTime)
	if errorValue != nil {
		http.Error(responseWriter, errorValue.Error(), http.StatusInternalServerError)
		return
	}
	resolution := task.ResolveScheduleHint(input.ScheduleHint, ownSchedules)
	if resolution.Outcome != task.ScheduleHintResolved {
		writeScheduleHintConflict(responseWriter, "scheduleHint", input.ScheduleHint, resolution)
		return
	}
	result, errorValue := scheduleHandler.ListRepository.UpdateSchedule(task.ScheduleUpdateRequest{
		ScheduleID:        resolution.Match.ScheduleID,
		RequesterPersonID: creatorPersonID,
		UpdateSchedule: func(existingSchedule task.Schedule) (task.Schedule, error) {
			return task.ApplyScheduleUpdate(existingSchedule, updateInput, scheduleHandler.companyTimeZone(), referenceTime)
		},
	})
	if errorValue != nil {
		writeScheduleWriteError(responseWriter, errorValue)
		return
	}
	if !result.IsFound {
		http.Error(responseWriter, "task schedule not found", http.StatusNotFound)
		return
	}
	writeJSON(responseWriter, http.StatusOK, task.ProjectScheduleMutation(result.Schedule))
}

func (scheduleHandler ScheduleHandler) HandleToolCancel(responseWriter http.ResponseWriter, request *http.Request) {
	creatorPersonID, isReady := scheduleHandler.readyForScheduleWrite(responseWriter, request)
	if !isReady {
		return
	}
	var input scheduleToolCancelRequest
	if errorValue := decodeScheduleToolRequest(request, scheduleToolCancelInputSchema, &input); errorValue != nil {
		http.Error(responseWriter, errorValue.Error(), http.StatusBadRequest)
		return
	}
	referenceTime := time.Now().UTC()
	ownSchedules, errorValue := scheduleHandler.ownOpenSchedules(creatorPersonID, referenceTime)
	if errorValue != nil {
		http.Error(responseWriter, errorValue.Error(), http.StatusInternalServerError)
		return
	}
	scheduleIDs, unresolvedHint, resolution := resolveEveryScheduleHint(input.ScheduleHints, ownSchedules)
	if unresolvedHint != "" {
		writeScheduleHintConflict(responseWriter, "scheduleHints", unresolvedHint, resolution)
		return
	}
	result, errorValue := scheduleHandler.ListRepository.CancelSchedules(task.ScheduleCancelRequest{
		Scope:             task.ScheduleCancelScopeScheduleIDs,
		RequesterPersonID: creatorPersonID,
		ScheduleIDs:       scheduleIDs,
		CancelledAt:       referenceTime,
	})
	if errorValue != nil {
		http.Error(responseWriter, errorValue.Error(), http.StatusInternalServerError)
		return
	}
	writeJSON(responseWriter, http.StatusOK, scheduleToolCancelResult{
		ScheduleIDs: cancelledScheduleIDs(result.Schedules),
		Cancelled:   cancelledScheduleItems(result.Schedules),
	})
}

func resolveEveryScheduleHint(hints []string, ownSchedules []task.Schedule) ([]string, string, task.ScheduleHintResolution) {
	scheduleIDs := []string{}
	seenScheduleIDs := map[string]bool{}
	for _, hint := range hints {
		resolution := task.ResolveScheduleHint(hint, ownSchedules)
		if resolution.Outcome != task.ScheduleHintResolved {
			return nil, hint, resolution
		}
		if seenScheduleIDs[resolution.Match.ScheduleID] {
			continue
		}
		seenScheduleIDs[resolution.Match.ScheduleID] = true
		scheduleIDs = append(scheduleIDs, resolution.Match.ScheduleID)
	}
	return scheduleIDs, "", task.ScheduleHintResolution{}
}

func cancelledScheduleIDs(schedules []task.Schedule) []string {
	scheduleIDs := make([]string, 0, len(schedules))
	for _, schedule := range schedules {
		scheduleIDs = append(scheduleIDs, schedule.ScheduleID)
	}
	return scheduleIDs
}

func cancelledScheduleItems(schedules []task.Schedule) []cancelledScheduleItem {
	items := make([]cancelledScheduleItem, 0, len(schedules))
	for _, schedule := range schedules {
		items = append(items, cancelledScheduleItem{
			ScheduleID:  schedule.ScheduleID,
			Description: schedule.Name,
		})
	}
	return items
}

func (scheduleHandler ScheduleHandler) readyForScheduleWrite(responseWriter http.ResponseWriter, request *http.Request) (string, bool) {
	creatorPersonID := scheduleHandler.signedPrincipal(request)
	if creatorPersonID == "" {
		http.Error(responseWriter, "schedule write authorization required", http.StatusForbidden)
		return "", false
	}
	if scheduleHandler.ListRepository == nil {
		http.Error(responseWriter, "task schedule repository is not configured", http.StatusServiceUnavailable)
		return "", false
	}
	return creatorPersonID, true
}

func (scheduleHandler ScheduleHandler) signedPrincipal(request *http.Request) string {
	if scheduleHandler.ReaderPersonID == nil {
		return ""
	}
	return strings.TrimSpace(scheduleHandler.ReaderPersonID(request))
}

func (scheduleHandler ScheduleHandler) ownOpenSchedules(creatorPersonID string, referenceTime time.Time) ([]task.Schedule, error) {
	result, errorValue := scheduleHandler.ListRepository.ListSchedules(task.ScheduleListRequest{
		CreatorPersonID: creatorPersonID,
		Page:            1,
		PageSize:        scheduleHintPageSize,
		ReferenceTime:   referenceTime,
	})
	if errorValue != nil {
		return nil, errorValue
	}
	return task.OpenSchedulesCreatedBy(result.Schedules, creatorPersonID, referenceTime), nil
}

func writeScheduleWriteError(responseWriter http.ResponseWriter, errorValue error) {
	if task.IsScheduleWriteInputError(errorValue) {
		http.Error(responseWriter, errorValue.Error(), http.StatusBadRequest)
		return
	}
	http.Error(responseWriter, errorValue.Error(), http.StatusInternalServerError)
}

func writeScheduleHintConflict(responseWriter http.ResponseWriter, hintField string, hint string, resolution task.ScheduleHintResolution) {
	candidates := resolution.Candidates
	if candidates == nil {
		candidates = []task.ScheduleCandidate{}
	}
	writeJSON(responseWriter, http.StatusConflict, scheduleHintConflict{
		Error:      unresolvedScheduleHintReason(hintField, resolution.Outcome),
		Hint:       hint,
		Candidates: candidates,
	})
}

func unresolvedScheduleHintReason(hintField string, outcome task.ScheduleHintOutcome) string {
	if outcome == task.ScheduleHintAmbiguous {
		return hintField + " matched more than one schedule the requester created"
	}
	return "no schedule the requester created matched " + hintField
}

func decodeScheduleToolRequest(request *http.Request, schema json.RawMessage, input any) error {
	body, errorValue := io.ReadAll(io.LimitReader(request.Body, scheduleToolRequestByteLimit+1))
	if errorValue != nil || len(body) > scheduleToolRequestByteLimit {
		return errScheduleToolRequestInvalid
	}
	if errorValue := validateAgainstScheduleSchema(schema, body); errorValue != nil {
		return errorValue
	}
	decoder := json.NewDecoder(bytes.NewReader(body))
	decoder.DisallowUnknownFields()
	if errorValue := decoder.Decode(input); errorValue != nil {
		return errScheduleToolRequestInvalid
	}
	if errorValue := decoder.Decode(&struct{}{}); errorValue != io.EOF {
		return errScheduleToolRequestInvalid
	}
	return nil
}

func validateAgainstScheduleSchema(schemaDocument json.RawMessage, body []byte) error {
	var schema jsonschema.Schema
	if errorValue := json.Unmarshal(schemaDocument, &schema); errorValue != nil {
		return errorValue
	}
	resolvedSchema, errorValue := schema.Resolve(nil)
	if errorValue != nil {
		return errorValue
	}
	var instance any
	if errorValue := json.Unmarshal(body, &instance); errorValue != nil {
		return errScheduleToolRequestInvalid
	}
	if errorValue := resolvedSchema.Validate(instance); errorValue != nil {
		return errorValue
	}
	return nil
}