internal/adminapi/schedule_write_handler.go
Rama main · réplica de lectura del repositorio original.
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
}