270 lines
8.7 KiB
Go
270 lines
8.7 KiB
Go
package agent
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"log"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
agentpb "git.ipao.vip/rogee/go-sip/gen/agent"
|
|
"google.golang.org/protobuf/proto"
|
|
)
|
|
|
|
// ResultEntry never includes audio, OSS credentials or a signed URL. Prepared
|
|
// results must not be reported: a PUT could have been interrupted or rejected.
|
|
type ResultEntry struct {
|
|
SourceEventID string `json:"source_event_id"`
|
|
DispatcherID string `json:"dispatcher_id"`
|
|
TenantID int64 `json:"tenant_id"`
|
|
Payload []byte `json:"payload"`
|
|
Upload *agentpb.UploadObservation `json:"upload,omitempty"`
|
|
State string `json:"state"`
|
|
}
|
|
|
|
type ResultJournal struct{ Root string }
|
|
|
|
func (j ResultJournal) path(id string) (string, error) {
|
|
if j.Root == "" || id == "" || strings.ContainsAny(id, "/\\\x00") {
|
|
return "", errors.New("private result journal and original call identity are required")
|
|
}
|
|
sum := sha256.Sum256([]byte(id))
|
|
return filepath.Join(j.Root, ".results", hex.EncodeToString(sum[:])+".json"), nil
|
|
}
|
|
|
|
func (j ResultJournal) prepare() (string, error) {
|
|
if j.Root == "" {
|
|
return "", errors.New("private result journal root is required")
|
|
}
|
|
if err := checkRecoveryDirectory(j.Root); err != nil {
|
|
return "", err
|
|
}
|
|
dir := filepath.Join(j.Root, ".results")
|
|
if err := os.Mkdir(dir, 0700); err != nil && !errors.Is(err, os.ErrExist) {
|
|
return "", err
|
|
}
|
|
if err := checkRecoveryDirectory(dir); err != nil {
|
|
return "", err
|
|
}
|
|
return dir, nil
|
|
}
|
|
|
|
func (j ResultJournal) save(entry ResultEntry, state string) error {
|
|
if entry.DispatcherID == "" || entry.TenantID <= 0 || !json.Valid(entry.Payload) || len(entry.Payload) == 0 || entry.Upload != nil || entry.State != "" {
|
|
return errors.New("original result identity or payload is invalid")
|
|
}
|
|
path, err := j.path(entry.SourceEventID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
dir, err := j.prepare()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
entry.State = state
|
|
data, err := json.Marshal(entry)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600)
|
|
if err != nil {
|
|
return fmt.Errorf("save original result: %w", err)
|
|
}
|
|
written, writeErr := file.Write(data)
|
|
if writeErr == nil && written != len(data) {
|
|
writeErr = io.ErrShortWrite
|
|
}
|
|
syncErr := file.Sync()
|
|
closeErr := file.Close()
|
|
if err := firstError(writeErr, syncErr, closeErr); err != nil {
|
|
return fmt.Errorf("persist original result: %w", err)
|
|
}
|
|
return syncDirectory(dir)
|
|
}
|
|
|
|
func (j ResultJournal) SaveReady(entry ResultEntry) error { return j.save(entry, "ready") }
|
|
func (j ResultJournal) SavePrepared(entry ResultEntry) error { return j.save(entry, "prepared") }
|
|
func (j ResultJournal) SaveEndPending(entry ResultEntry) error { return j.save(entry, "end_pending") }
|
|
|
|
func (j ResultJournal) load(id string) (ResultEntry, string, error) {
|
|
path, err := j.path(id)
|
|
if err != nil {
|
|
return ResultEntry{}, "", err
|
|
}
|
|
info, err := os.Lstat(path)
|
|
if err != nil {
|
|
return ResultEntry{}, "", err
|
|
}
|
|
if !info.Mode().IsRegular() || info.Mode().Perm()&0077 != 0 || info.Size() > 1<<20 {
|
|
return ResultEntry{}, "", errors.New("original result file is not private or exceeds the supported size")
|
|
}
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return ResultEntry{}, "", err
|
|
}
|
|
var entry ResultEntry
|
|
decoder := json.NewDecoder(bytes.NewReader(data))
|
|
decoder.DisallowUnknownFields()
|
|
if err := decoder.Decode(&entry); err != nil {
|
|
return ResultEntry{}, "", err
|
|
}
|
|
var trailing any
|
|
if err := decoder.Decode(&trailing); err == nil {
|
|
return ResultEntry{}, "", errors.New("original result has trailing data")
|
|
} else if !errors.Is(err, io.EOF) {
|
|
return ResultEntry{}, "", err
|
|
}
|
|
if entry.SourceEventID != id || entry.DispatcherID == "" || entry.TenantID <= 0 || !json.Valid(entry.Payload) || len(entry.Payload) == 0 || (entry.State != "prepared" && entry.State != "ready" && entry.State != "end_pending") || (entry.State != "ready" && entry.Upload != nil) {
|
|
return ResultEntry{}, "", errors.New("original result journal identity or state is invalid")
|
|
}
|
|
return entry, path, nil
|
|
}
|
|
|
|
func (j ResultJournal) MarkEnded(id string) error {
|
|
entry, path, err := j.load(id)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if entry.State != "end_pending" {
|
|
return errors.New("original end is not awaiting confirmation")
|
|
}
|
|
entry.State = "ready"
|
|
return writeJSONAtomic(path, entry)
|
|
}
|
|
|
|
func (j ResultJournal) MarkUploaded(id string, observation *agentpb.UploadObservation) error {
|
|
entry, path, err := j.load(id)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if entry.State != "prepared" || observation == nil || observation.GetUploadId() == "" || observation.GetRecordingId() == "" || observation.GetPutStatusCode() < 200 || observation.GetPutStatusCode() >= 300 || observation.GetSizeBytes() <= 0 || observation.GetChecksumSha256() == "" {
|
|
return errors.New("confirmed original upload observation is required")
|
|
}
|
|
entry.State = "ready"
|
|
entry.Upload = proto.Clone(observation).(*agentpb.UploadObservation)
|
|
return writeJSONAtomic(path, entry)
|
|
}
|
|
|
|
func (j ResultJournal) Ack(id string) error {
|
|
entry, path, err := j.load(id)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if entry.State != "ready" {
|
|
return errors.New("cannot remove a result whose upload outcome is unknown")
|
|
}
|
|
if err := os.Remove(path); err != nil {
|
|
return err
|
|
}
|
|
return syncDirectory(filepath.Dir(path))
|
|
}
|
|
|
|
// DiscardPrepared is only called after the durable audio+result recovery entry
|
|
// has itself reported the same final result. Old entries have no journal.
|
|
func (j ResultJournal) DiscardPrepared(id string) error {
|
|
entry, path, err := j.load(id)
|
|
if err != nil {
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
return nil
|
|
}
|
|
return err
|
|
}
|
|
if entry.State != "prepared" {
|
|
return errors.New("cannot discard a confirmed result")
|
|
}
|
|
if err := os.Remove(path); err != nil {
|
|
return err
|
|
}
|
|
return syncDirectory(filepath.Dir(path))
|
|
}
|
|
|
|
// Recover only re-reports ready results under their original identity. A
|
|
// prepared upload is left untouched for inspection, never retried or assumed
|
|
// uploaded. Independent entries continue when one is blocked.
|
|
func (j ResultJournal) Recover(ctx context.Context, report func(context.Context, ResultEntry) error) error {
|
|
return j.RecoverWithEnd(ctx, nil, report)
|
|
}
|
|
|
|
func (j ResultJournal) RecoverWithEnd(ctx context.Context, confirmEnd, report func(context.Context, ResultEntry) error) error {
|
|
dir, err := j.prepare()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
items, err := os.ReadDir(dir)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
var blocked []error
|
|
for _, item := range items {
|
|
if err := ctx.Err(); err != nil {
|
|
return errors.Join(append(blocked, err)...)
|
|
}
|
|
path := filepath.Join(dir, item.Name())
|
|
if item.IsDir() || !strings.HasSuffix(item.Name(), ".json") || len(item.Name()) != 69 {
|
|
blocked = append(blocked, errors.New("unexpected private result journal entry"))
|
|
continue
|
|
}
|
|
info, err := os.Lstat(path)
|
|
if err != nil || !info.Mode().IsRegular() || info.Mode().Perm()&0077 != 0 || info.Size() > 1<<20 {
|
|
blocked = append(blocked, errors.New("private result file is invalid"))
|
|
continue
|
|
}
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
blocked = append(blocked, err)
|
|
continue
|
|
}
|
|
var id struct {
|
|
SourceEventID string `json:"source_event_id"`
|
|
}
|
|
if err := json.Unmarshal(data, &id); err != nil {
|
|
blocked = append(blocked, errors.New("invalid private result journal"))
|
|
continue
|
|
}
|
|
entry, originalPath, err := j.load(id.SourceEventID)
|
|
if err != nil || path != originalPath {
|
|
blocked = append(blocked, errors.New("result journal identity mismatch"))
|
|
continue
|
|
}
|
|
if entry.State == "end_pending" {
|
|
if confirmEnd == nil {
|
|
blocked = append(blocked, errors.New("original call end requires Dispatcher confirmation"))
|
|
continue
|
|
}
|
|
if err := confirmEnd(ctx, entry); err != nil {
|
|
blocked = append(blocked, fmt.Errorf("confirm original call end: %w", err))
|
|
continue
|
|
}
|
|
if err := j.MarkEnded(entry.SourceEventID); err != nil {
|
|
blocked = append(blocked, fmt.Errorf("persist original call end: %w", err))
|
|
continue
|
|
}
|
|
entry.State = "ready"
|
|
}
|
|
if entry.State != "ready" {
|
|
log.Printf("Agent recovery blocked stage=original_upload_outcome event_digest=%s", item.Name()[:12])
|
|
blocked = append(blocked, errors.New("original upload outcome requires inspection"))
|
|
continue
|
|
}
|
|
if report == nil {
|
|
blocked = append(blocked, errors.New("result reporter is required"))
|
|
continue
|
|
}
|
|
if err := report(ctx, entry); err != nil {
|
|
blocked = append(blocked, fmt.Errorf("report original result: %w", err))
|
|
continue
|
|
}
|
|
if err := j.Ack(entry.SourceEventID); err != nil {
|
|
blocked = append(blocked, fmt.Errorf("confirm original result: %w", err))
|
|
}
|
|
}
|
|
return errors.Join(blocked...)
|
|
}
|