Files
go-sip/internal/store/local_call_terminal.go
T

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
}