258 lines
9.4 KiB
Go
258 lines
9.4 KiB
Go
package store
|
|
|
|
import (
|
|
"crypto/sha256"
|
|
"database/sql"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"time"
|
|
)
|
|
|
|
// LocalCallTerminal is an immutable, confirmed Agent observation. Recording
|
|
// delivery is deliberately separate: a completed call stops consuming quota
|
|
// even when its final result is waiting for an upload outcome.
|
|
type LocalRecordingManifest struct {
|
|
RecordingID string
|
|
UploadID string
|
|
Format string
|
|
Channels int
|
|
SampleRateHz int
|
|
DurationMs int64
|
|
}
|
|
|
|
// LocalRecordingOutcome contains only a finished upload fact or a terminal
|
|
// failure. It contains no OSS authorization or media bytes.
|
|
type LocalRecordingOutcome struct {
|
|
Status string
|
|
ErrorCode string
|
|
Bucket string
|
|
ObjectKey string
|
|
SizeBytes int64
|
|
ChecksumSHA256 string
|
|
ObservedAt time.Time
|
|
}
|
|
|
|
type LocalCallTerminal struct {
|
|
ExecutionID string
|
|
CallID string
|
|
Source string
|
|
StartedAt time.Time
|
|
EndedAt time.Time
|
|
Outcome string
|
|
ReasonCode string
|
|
RecordingExpected bool
|
|
Recording *LocalRecordingManifest
|
|
}
|
|
|
|
// RecordLocalCallTerminal atomically records an issued call's terminal fact and
|
|
// releases its reservation. A known final result can be enqueued in the same
|
|
// transaction; otherwise the durable fact remains available for recovery.
|
|
func (s *Store) RecordLocalCallTerminal(fact LocalCallTerminal, event *LocalEventRecord) error {
|
|
if fact.ExecutionID == "" || fact.CallID == "" || fact.Outcome == "" ||
|
|
fact.StartedAt.IsZero() || fact.EndedAt.IsZero() || fact.EndedAt.Before(fact.StartedAt) {
|
|
return errors.New("incomplete or reversed local call terminal fact")
|
|
}
|
|
expectedDecision := "issued"
|
|
switch fact.Source {
|
|
case "mock_agent":
|
|
case "dispatcher_refusal", "mock_agent_deadline", "dispatcher_deadline":
|
|
if fact.Source == "dispatcher_refusal" {
|
|
expectedDecision = "refused"
|
|
}
|
|
if fact.Outcome != "failed" || fact.RecordingExpected {
|
|
return errors.New("no-dial terminal cannot claim a call or recording")
|
|
}
|
|
default:
|
|
return errors.New("unknown local terminal source")
|
|
}
|
|
fact.StartedAt = fact.StartedAt.UTC()
|
|
fact.EndedAt = fact.EndedAt.UTC()
|
|
if fact.RecordingExpected != (fact.Recording != nil) {
|
|
return errors.New("expected recording requires a manifest; no recording must not have one")
|
|
}
|
|
var manifestJSON []byte
|
|
if fact.Recording != nil {
|
|
if fact.Recording.RecordingID == "" || fact.Recording.UploadID == "" || fact.Recording.Format == "" ||
|
|
fact.Recording.Channels < 1 || fact.Recording.SampleRateHz < 1 || fact.Recording.DurationMs < 0 {
|
|
return errors.New("incomplete local recording manifest")
|
|
}
|
|
var err error
|
|
manifestJSON, err = json.Marshal(fact.Recording)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
digest, err := localTerminalDigest(fact)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
tx, err := s.db.Begin()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer tx.Rollback()
|
|
var oldDigest string
|
|
err = tx.QueryRow(`SELECT fact_sha256 FROM local_v01_call_terminals WHERE execution_id=?`, fact.ExecutionID).Scan(&oldDigest)
|
|
if err == nil {
|
|
if oldDigest != digest {
|
|
return ErrCommandConflict
|
|
}
|
|
} else if errors.Is(err, sql.ErrNoRows) {
|
|
var reservationID, status, decision string
|
|
if err := tx.QueryRow(`SELECT r.reservation_id,t.status,d.decision FROM tasks t
|
|
JOIN reservations r ON r.execution_id=t.execution_id
|
|
JOIN local_v02_origination_decisions d ON d.execution_id=t.execution_id
|
|
WHERE t.execution_id=?`, fact.ExecutionID).Scan(&reservationID, &status, &decision); err != nil {
|
|
return fmt.Errorf("confirm local call terminal ownership: %w", err)
|
|
}
|
|
if decision != expectedDecision || status == "finished" {
|
|
return ErrCommandConflict
|
|
}
|
|
expected := 0
|
|
if fact.RecordingExpected {
|
|
expected = 1
|
|
}
|
|
var recordingID, uploadID any
|
|
if fact.Recording != nil {
|
|
recordingID, uploadID = fact.Recording.RecordingID, fact.Recording.UploadID
|
|
}
|
|
if _, err := tx.Exec(`INSERT INTO local_v01_call_terminals
|
|
(execution_id,call_id,source,started_at,ended_at,outcome,reason_code,recording_expected,recording_id,upload_id,recording_manifest,fact_sha256)
|
|
VALUES(?,?,?,?,?,?,?,?,?,?,?,?)`, fact.ExecutionID, fact.CallID, fact.Source, fact.StartedAt.Format(time.RFC3339Nano),
|
|
fact.EndedAt.Format(time.RFC3339Nano), fact.Outcome, fact.ReasonCode, expected, recordingID, uploadID, manifestJSON, digest); err != nil {
|
|
return fmt.Errorf("persist local terminal fact: %w", err)
|
|
}
|
|
if err := releaseReservationTx(tx, reservationID, false, nil, s.now().UTC().Format(time.RFC3339Nano)); err != nil {
|
|
return fmt.Errorf("release terminated local call quota: %w", err)
|
|
}
|
|
result, err := tx.Exec(`UPDATE tasks SET status='finished', updated_at=? WHERE execution_id=? AND status!='finished'`,
|
|
s.now().UTC().Format(time.RFC3339Nano), fact.ExecutionID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
rows, err := result.RowsAffected()
|
|
if err != nil || rows != 1 {
|
|
return fmt.Errorf("finish local execution (rows=%d): %w", rows, errors.Join(err, ErrCommandConflict))
|
|
}
|
|
} else {
|
|
return err
|
|
}
|
|
if event != nil {
|
|
if err := enqueueLocalFinalEventTx(tx, fact.ExecutionID, fact.CallID, *event, s.now().UTC().Format(time.RFC3339Nano)); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return tx.Commit()
|
|
}
|
|
|
|
// EnqueueLocalFinalEvent links a unique final result to the persisted terminal
|
|
// fact. A duplicate with the same event and bytes is harmless; a different
|
|
// event ID or body for the same execution is rejected.
|
|
func (s *Store) EnqueueLocalFinalEvent(executionID, callID string, event LocalEventRecord) error {
|
|
if executionID == "" || callID == "" {
|
|
return errors.New("final result requires execution and call identity")
|
|
}
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
tx, err := s.db.Begin()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer tx.Rollback()
|
|
if err := enqueueLocalFinalEventTx(tx, executionID, callID, event, s.now().UTC().Format(time.RFC3339Nano)); err != nil {
|
|
return err
|
|
}
|
|
return tx.Commit()
|
|
}
|
|
|
|
// LoadLocalCallTerminal returns only persisted Agent facts. An empty result ID
|
|
// means recording or final-result delivery is still pending; it does not
|
|
// authorize another originate or upload.
|
|
func (s *Store) LoadLocalCallTerminal(executionID string) (LocalCallTerminal, string, error) {
|
|
var fact LocalCallTerminal
|
|
var startedAt, endedAt string
|
|
var expected int
|
|
var manifestJSON []byte
|
|
var eventID sql.NullString
|
|
fact.ExecutionID = executionID
|
|
err := s.db.QueryRow(`SELECT call_id,source,started_at,ended_at,outcome,reason_code,recording_expected,recording_manifest,result_event_id
|
|
FROM local_v01_call_terminals WHERE execution_id=?`, executionID).
|
|
Scan(&fact.CallID, &fact.Source, &startedAt, &endedAt, &fact.Outcome, &fact.ReasonCode, &expected, &manifestJSON, &eventID)
|
|
if err != nil {
|
|
return LocalCallTerminal{}, "", err
|
|
}
|
|
fact.StartedAt, err = time.Parse(time.RFC3339Nano, startedAt)
|
|
if err != nil {
|
|
return LocalCallTerminal{}, "", fmt.Errorf("decode persisted call start: %w", err)
|
|
}
|
|
fact.EndedAt, err = time.Parse(time.RFC3339Nano, endedAt)
|
|
if err != nil {
|
|
return LocalCallTerminal{}, "", fmt.Errorf("decode persisted call end: %w", err)
|
|
}
|
|
if expected != 0 && expected != 1 {
|
|
return LocalCallTerminal{}, "", ErrCommandConflict
|
|
}
|
|
fact.RecordingExpected = expected == 1
|
|
if fact.RecordingExpected != (manifestJSON != nil) {
|
|
return LocalCallTerminal{}, "", ErrCommandConflict
|
|
}
|
|
if fact.RecordingExpected {
|
|
fact.Recording = &LocalRecordingManifest{}
|
|
if err := json.Unmarshal(manifestJSON, fact.Recording); err != nil {
|
|
return LocalCallTerminal{}, "", fmt.Errorf("decode persisted recording manifest: %w", err)
|
|
}
|
|
}
|
|
return fact, eventID.String, nil
|
|
}
|
|
|
|
func enqueueLocalFinalEventTx(tx *sql.Tx, executionID, callID string, event LocalEventRecord, now string) error {
|
|
if event.EventID == "" || event.TenantKey == "" || event.Exchange == "" || event.RoutingKey == "" || len(event.Body) == 0 {
|
|
return errors.New("complete local final event is required")
|
|
}
|
|
var persistedCallID string
|
|
var expected int
|
|
var recordingOutcome []byte
|
|
var priorEventID, priorDigest sql.NullString
|
|
if err := tx.QueryRow(`SELECT call_id,recording_expected,recording_outcome,result_event_id,result_sha256
|
|
FROM local_v01_call_terminals WHERE execution_id=?`, executionID).
|
|
Scan(&persistedCallID, &expected, &recordingOutcome, &priorEventID, &priorDigest); err != nil {
|
|
return fmt.Errorf("load confirmed terminal before final result: %w", err)
|
|
}
|
|
if expected != 0 && expected != 1 || expected == 1 && recordingOutcome == nil || persistedCallID != callID {
|
|
return ErrCommandConflict
|
|
}
|
|
bodyDigest := sha256.Sum256(event.Body)
|
|
sha := hex.EncodeToString(bodyDigest[:])
|
|
if priorEventID.Valid && (priorEventID.String != event.EventID || !priorDigest.Valid || priorDigest.String != sha) {
|
|
return ErrCommandConflict
|
|
}
|
|
if _, err := enqueueLocalEventTx(tx, event, now); err != nil {
|
|
return err
|
|
}
|
|
if !priorEventID.Valid {
|
|
result, err := tx.Exec(`UPDATE local_v01_call_terminals SET result_event_id=?,result_sha256=?
|
|
WHERE execution_id=? AND result_event_id IS NULL`, event.EventID, sha, executionID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
rows, err := result.RowsAffected()
|
|
if err != nil || rows != 1 {
|
|
return fmt.Errorf("bind unique local final event (rows=%d): %w", rows, errors.Join(err, ErrCommandConflict))
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func localTerminalDigest(fact LocalCallTerminal) (string, error) {
|
|
encoded, err := json.Marshal(fact)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
sum := sha256.Sum256(encoded)
|
|
return hex.EncodeToString(sum[:]), nil
|
|
}
|