// Package buildstep is what both build modules share: the runner of a long step that answers a tool call at once // and ends on its key in the module's `steps` state (novox/hq ADR 0469 ยง2; issue 1471), the toolchain guard // (issue 1473) and the fetch of one commit over SSH (issue 1474). package buildstep import ( "context" "crypto/rand" "encoding/hex" "encoding/json" "errors" "fmt" "strconv" "time" "git.novox.be/novox/mesh-sdk/go/delivery" ) // Put writes one key of a state: stdio.State(delivery.StepsState).Put's shape (go/state.go). type Put func(key string, value any) (uint64, error) // EndAttempts and EndRetry bound how a step's end is written: a write that fails is tried again, since the end // is the only way the step's reader learns it is over. var ( EndAttempts = 5 EndRetry = 2 * time.Second ) var now = time.Now // NewID is a step id: the time in milliseconds in base 36 and six random hex digits, lower-case letters and // digits only, so it is one key token and differs between two steps of one node even in one millisecond. func NewID() string { var b [3]byte _, _ = rand.Read(b[:]) return strconv.FormatInt(now().UnixMilli(), 36) + hex.EncodeToString(b[:]) } // Start begins a step of tool on node and answers its id at once: the key . holds state running when // Start returns. run is then called in a goroutine of its own, under ctx (the process's, not the tool call's: the // call is cut at 30 s, the step is not). When it returns, the key is written passed with run's result, failed with // the error's text, or refused when the error is a *Refusal; a panic in run ends the step failed, naming it, and // the process goes on. If the running write fails, nothing was started and Start answers the error. func Start(ctx context.Context, put Put, node, tool string, run func(ctx context.Context) (result any, err error)) (string, error) { step := delivery.Step{ID: NewID(), Tool: tool, Node: node, State: delivery.StepRunning, Started: now().UTC()} key, err := delivery.StepKey(node, step.ID) if err != nil { return "", err } if err := write(put, key, step); err != nil { return "", fmt.Errorf("buildstep: writing the running step %s: %w", key, err) } go func() { result, err := protect(ctx, run) step.Ended = now().UTC() switch ref := (*Refusal)(nil); { case err == nil: step.State = delivery.StepPassed if result != nil { raw, merr := json.Marshal(result) if merr != nil { step.State, step.Reason = delivery.StepFailed, "the result is not JSON: "+merr.Error() } else { step.Result = raw } } case errors.As(err, &ref): step.State, step.Reason = delivery.StepRefused, err.Error() default: step.State, step.Reason = delivery.StepFailed, err.Error() } for attempt := 1; ; attempt++ { if werr := write(put, key, step); werr == nil || attempt >= EndAttempts { return } time.Sleep(EndRetry) } }() return step.ID, nil } // protect is run with a panic turned into the error that ends the step. func protect(ctx context.Context, run func(ctx context.Context) (any, error)) (result any, err error) { defer func() { if p := recover(); p != nil { result, err = nil, fmt.Errorf("the step panicked: %v", p) } }() return run(ctx) } func write(put Put, key string, step delivery.Step) error { raw, err := delivery.EncodeStep(step) if err != nil { return err } _, err = put(key, json.RawMessage(raw)) return err }