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...) }