Skip to content
quarry Artifact [781613c9a7]
Login

Artifact [781613c9a7]

Artifact 781613c9a7a886e28c440e6da151828ac32efb8c678164e52b54d9e831d7aa79:


package api

import (
	"context"
	"encoding/json"
	"fmt"
	"log"
	"reflect"
	"strings"

	"craftdesign.group/quarry/internal/ops"
)

// A plan is several calls an agent asks its person for under one title
// (RFC 0028): one request, one row, one approval. It is approved whole.
// Approval runs the steps in order, each as the person through the
// ordinary registry; the first that fails stops the plan, earlier steps
// stand, and later ones do not run.

// planOp is the op a plan's request is recorded under.
const planOp = "request.plan"

// maxPlanSteps is the most steps a plan may have (RFC 0028, decision 5).
const maxPlanSteps = 20

type planStepIn struct {
	Op    string          `json:"op"`
	Input json.RawMessage `json:"input"`
}

type planIn struct {
	Title  string       `json:"title" validate:"required,max=200"`
	Reason string       `json:"reason" validate:"max=200"`
	Steps  []planStepIn `json:"steps"`
}

// planStep is one step as recorded when the agent asked. Code is the
// repository's project code then: a repository deleted and made again
// under the name is not the one the person was asked about.
type planStep struct {
	Op       string          `json:"op"`
	Repo     string          `json:"repo,omitempty"`
	Code     string          `json:"code,omitempty"`
	Input    json.RawMessage `json:"input"`
	Sentence string          `json:"sentence"`
}

// planStepResult is what one step did. Answer has the fields the op
// marks secret taken out; they go to the asking agent once.
type planStepResult struct {
	Sentence string          `json:"sentence"`
	State    string          `json:"state"` // done | failed
	Answer   json.RawMessage `json:"answer,omitempty"`
	Err      string          `json:"err,omitempty"`
}

// PlanStepView is a step as a page or the asking agent reads it.
type PlanStepView struct {
	Sentence string `json:"sentence"`
	// State is "" while the plan waits, then done, failed or not run.
	State string `json:"state,omitempty"`
	Err   string `json:"err,omitempty"`
}

// planSteps is a plan request's steps with what became of each; nil
// for a request that is not a plan.
func planSteps(s SignOffRequest) []PlanStepView {
	if s.Op != planOp {
		return nil
	}
	var steps []planStep
	if err := json.Unmarshal([]byte(s.Input), &steps); err != nil {
		return nil
	}
	var results []planStepResult
	if s.Result != "" {
		if err := json.Unmarshal([]byte(s.Result), &results); err != nil {
			results = nil
		}
	}
	views := make([]PlanStepView, len(steps))
	for i, st := range steps {
		views[i].Sentence = st.Sentence
		switch {
		case i < len(results):
			views[i].State, views[i].Err = results[i].State, results[i].Err
		case s.Result != "":
			views[i].State = "not run"
		}
	}
	return views
}

// decodeStep reads a step's input into the op's own input type.
func decodeStep(desc *ops.Descriptor, raw json.RawMessage) (any, error) {
	in := desc.NewIn()
	if len(raw) > 0 {
		dec := json.NewDecoder(strings.NewReader(string(raw)))
		dec.DisallowUnknownFields()
		if err := dec.Decode(in); err != nil {
			return nil, err
		}
	}
	return reflect.ValueOf(in).Elem().Interface(), nil
}

type planOut struct {
	SignOff SignOffRequest `json:"signoff"`
	Steps   []PlanStepView `json:"steps"`
}

func (d Deps) registerPlan(r *ops.Registry) error {
	return ops.Register(r, ops.Op[planIn, planOut]{
		Name: planOp,
		Doc: "Ask your person to approve several calls as one request (RFC 0028): at most 20 steps under one title. Each " +
			"step must be something you could ask for on its own, or the whole plan is refused naming the step. Approval " +
			"runs the steps in order as your person; the first that fails stops the plan, and earlier steps stand. Read " +
			"each step's outcome, and any secrets once, from `GET /api/v1/signoffs/{id}`.",
		Kind:  ops.Command,
		Auth:  ops.Token,
		Route: "POST /api/v1/requests",
		Run: func(ctx context.Context, in planIn) (planOut, error) {
			if d.SignOffs == nil {
				return planOut{}, fmt.Errorf("this forge keeps no requests: %w", ops.ErrUnavailable)
			}
			agent := ops.User(ctx)
			person := d.personFor(agent)
			if person == "" {
				return planOut{}, fmt.Errorf("a plan is an agent's to ask, and %s has nobody to ask: %w", agent, ops.ErrForbidden)
			}
			if !d.agentAsks(agent) {
				return planOut{}, fmt.Errorf("%s takes no requests from %s: %w", person, agent, ops.ErrForbidden)
			}
			if len(in.Steps) == 0 || len(in.Steps) > maxPlanSteps {
				return planOut{}, fmt.Errorf("a plan has 1 to %d steps, not %d: %w", maxPlanSteps, len(in.Steps), ops.ErrInvalid)
			}
			steps := make([]planStep, len(in.Steps))
			for i, st := range in.Steps {
				step, err := d.planStepFor(ctx, r, person, st)
				if err != nil {
					return planOut{}, fmt.Errorf("step %d (%s): %w", i+1, st.Op, err)
				}
				steps[i] = step
			}
			raw, err := json.Marshal(steps)
			if err != nil {
				return planOut{}, err
			}
			s, err := d.SignOffs.RequestSignOff(SignOffRequest{Agent: agent, Owner: person, Op: planOp,
				Input: string(raw), Summary: fmt.Sprintf("%s (%d steps)", in.Title, len(steps)), Reason: strings.TrimSpace(in.Reason)})
			if err != nil {
				return planOut{}, err
			}
			d.tellPerson(s)
			return planOut{SignOff: s, Steps: planSteps(s)}, nil
		},
	})
}

// planStepFor checks one step as a request of its own would be checked,
// and records it.
func (d Deps) planStepFor(ctx context.Context, r *ops.Registry, person string, st planStepIn) (planStep, error) {
	desc, ok := r.Lookup(st.Op)
	if !ok {
		return planStep{}, fmt.Errorf("no such operation: %w", ops.ErrInvalid)
	}
	if desc.Kind != ops.Command {
		return planStep{}, fmt.Errorf("only a call that changes something can be a step: %w", ops.ErrInvalid)
	}
	arg, err := decodeStep(desc, st.Input)
	if err != nil {
		return planStep{}, fmt.Errorf("its input does not read: %v: %w", err, ops.ErrInvalid)
	}
	if !askableFor(st.Op, person, arg) {
		return planStep{}, fmt.Errorf("not something an agent may ask for: %w", ops.ErrForbidden)
	}
	repo := ""
	if desc.Scope == ops.ScopeRepo {
		repo = repoOf(arg)
	}
	if !d.mayAsk(ctx, desc, repo, person) {
		return planStep{}, fmt.Errorf("outside what this agent may ask %s for: %w", person, ops.ErrForbidden)
	}
	step := planStep{Op: st.Op, Repo: repo, Input: st.Input}
	if repo != "" {
		rr, err := d.repo(repo)
		if err != nil {
			return planStep{}, err
		}
		step.Code = rr.Code
	}
	if step.Sentence = desc.Sentence(arg); step.Sentence == "" {
		step.Sentence = describeCall(st.Op, repo, arg)
	}
	return step, nil
}

// runPlan runs an approved plan, in order, as the approver, and stops at
// the first step that fails. What the steps answered is kept whichever
// way it ends, so the agent can collect the secrets of the steps that ran.
func (d Deps) runPlan(ctx context.Context, r *ops.Registry, s SignOffRequest) error {
	var steps []planStep
	if err := json.Unmarshal([]byte(s.Input), &steps); err != nil {
		return fmt.Errorf("the plan no longer reads: %v: %w", err, ops.ErrInvalid)
	}
	results := make([]planStepResult, 0, len(steps))
	secrets := map[string]json.RawMessage{}
	finish := func() {
		raw, err := json.Marshal(results)
		if err != nil {
			log.Printf("signoff %s: plan results: %v", s.ID, err)
			return
		}
		secret := ""
		if len(secrets) > 0 {
			b, err := json.Marshal(secrets)
			if err != nil {
				log.Printf("signoff %s: plan secrets: %v", s.ID, err)
			} else {
				secret = string(b)
			}
		}
		if err := d.SignOffs.FinishSignOff(s.ID, string(raw), secret); err != nil {
			log.Printf("signoff %s: recording the plan: %v", s.ID, err)
		}
	}
	for i, st := range steps {
		answer, secret, err := d.runPlanStep(ctx, r, s, st)
		if err != nil {
			results = append(results, planStepResult{Sentence: st.Sentence, State: "failed", Err: err.Error()})
			finish()
			return fmt.Errorf("failed at step %d (%s): %w", i+1, st.Sentence, err)
		}
		results = append(results, planStepResult{Sentence: st.Sentence, State: "done", Answer: json.RawMessage(answer)})
		if secret != "" {
			secrets[fmt.Sprint(i+1)] = json.RawMessage(secret)
		}
		if d.Acts != nil {
			if err := d.Acts.RecordForgeAct(s.Agent+" (signed off by "+s.Owner+")", st.Op, st.Repo, s.Owner); err != nil {
				logForgeActFailure(st.Op, st.Repo, err)
			}
		}
	}
	finish()
	return nil
}

func (d Deps) runPlanStep(ctx context.Context, r *ops.Registry, s SignOffRequest, st planStep) (answer, secret string, err error) {
	desc, ok := r.Lookup(st.Op)
	if !ok {
		return "", "", fmt.Errorf("no operation %q: %w", st.Op, ops.ErrInvalid)
	}
	if st.Repo != "" {
		rr, err := d.repo(st.Repo)
		if err != nil || rr.Code != st.Code {
			return "", "", fmt.Errorf("%s is not the repository it was asked about: %w", st.Repo, ops.ErrInvalid)
		}
	}
	arg, err := decodeStep(desc, st.Input)
	if err != nil {
		return "", "", fmt.Errorf("its input no longer reads: %v: %w", err, ops.ErrInvalid)
	}
	if !askableFor(st.Op, s.Owner, arg) {
		return "", "", fmt.Errorf("not something an agent may ask for: %w", ops.ErrInvalid)
	}
	out, err := r.Invoke(ctx, st.Op, arg)
	if err != nil {
		return "", "", err
	}
	return splitSecret(out)
}