package api
import (
"context"
"encoding/json"
"errors"
"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"`
// Pickup binds the request to a pickup code (RFC 0030): the hex
// SHA-256 of a 32-byte key only the asker's program holds. Its
// secrets then leave only through POST /api/v1/pickups/collect.
Pickup string `json:"pickup,omitempty" validate:"max=64"`
}
// 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"`
// Pending is a step on a repository an earlier step of the plan
// creates: it could not be checked when the agent asked, so it is
// checked when it runs, as the person, and has no Code.
Pending bool `json:"pending,omitempty"`
}
// 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)
}
if err := checkPickupCode(in.Pickup); err != nil {
return planOut{}, err
}
steps := make([]planStep, len(in.Steps))
created := map[string]bool{} // repositories earlier steps create
for i, st := range in.Steps {
step, err := d.planStepFor(ctx, r, person, st, created)
if err != nil {
return planOut{}, fmt.Errorf("step %d (%s): %w", i+1, st.Op, err)
}
steps[i] = step
if st.Op == "repo.add" {
if desc, ok := r.Lookup(st.Op); ok {
if arg, err := decodeStep(desc, st.Input); err == nil {
if n := inputField(arg, "Name"); n != "" {
created[n] = true
}
}
}
}
}
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), Pickup: in.Pickup})
if err != nil {
if errors.Is(err, ErrPickupBound) || errors.Is(err, ErrPickupMismatch) {
return planOut{}, pickupAskError(err)
}
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, created map[string]bool) (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)
}
step := planStep{Op: st.Op, Repo: repo, Input: st.Input}
if repo != "" && created[repo] {
if _, err := d.repo(repo); err != nil {
// Made by an earlier step: nothing to check it against yet,
// so it is checked as it runs (#410). A token limited to
// repositories still asks about none it does not name.
if tok, byToken := ops.TokenOf(ctx); byToken && tok.Limited {
return planStep{}, fmt.Errorf("a token limited to repositories cannot ask about a new one: %w", ops.ErrForbidden)
}
step.Pending = true
}
}
if !step.Pending {
if !d.mayAsk(ctx, desc, repo, person, arg) {
return planStep{}, fmt.Errorf("outside what this agent may ask %s for: %w", person, ops.ErrForbidden)
}
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)
}
ctx = withSignOffAsk(ctx, s)
results := make([]planStepResult, 0, len(steps))
secrets := map[string]json.RawMessage{}
var refs []lapseRef
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.holdSignOffSecret(ctx, s, string(raw), secret, refs); err != nil {
log.Printf("signoff %s: recording the plan: %v", s.ID, err)
}
}
for i, st := range steps {
answer, secret, minted, 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)
}
refs = append(refs, minted...)
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, minted []lapseRef, err error) {
desc, ok := r.Lookup(st.Op)
if !ok {
return "", "", nil, fmt.Errorf("no operation %q: %w", st.Op, ops.ErrInvalid)
}
if st.Repo != "" && !st.Pending {
rr, err := d.repo(st.Repo)
if err != nil || rr.Code != st.Code {
return "", "", nil, 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 "", "", nil, fmt.Errorf("its input no longer reads: %v: %w", err, ops.ErrInvalid)
}
if !askableFor(st.Op, s.Owner, arg) {
return "", "", nil, fmt.Errorf("not something an agent may ask for: %w", ops.ErrInvalid)
}
out, err := r.Invoke(ctx, st.Op, arg)
if err != nil {
return "", "", nil, err
}
answer, secret, err = splitSecret(out)
return answer, secret, lapseRefOf(st.Op, st.Repo, arg, out), err
}