Files
Codex Local bf1233d5df
ci / verify (push) Canceled after 0s
feat: expand companion platform, device controls, and memoir workflows
Add customer and operator login, BLE provisioning, device fleet controls, call memory, memoir generation, and refreshed family and maintenance portals. Include API contracts, migrations, firmware sources, tests, assets, and deployment documentation.
2026-10-08 10:06:44 +08:00

285 lines
13 KiB
Go

package vikingmemory
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"strings"
"time"
"data-terminal-cloud/internal/callmemory"
"data-terminal-cloud/internal/outbox"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
type Managed struct {
pool *pgxpool.Pool
client *Client
}
func NewManaged(p *pgxpool.Pool, c *Client) *Managed { return &Managed{p, c} }
// Upload only ended calls with the currently authorized, retained transcript.
// A late final subtitle creates another revision; the stable remote session ID
// replaces that session instead of accumulating duplicate uploads on retry.
func (m *Managed) SyncNext(ctx context.Context) error {
_, err := m.pool.Exec(ctx, `INSERT INTO call_memory_sync(session_id,transcript_revision)
SELECT id,transcript_revision FROM call_session c WHERE budget_snapshot->>'protocol'='DIRECT-CHAT-1'
AND ended_at IS NOT NULL AND transcript_state IN ('PARTIAL','COMPLETE') AND transcript_revision>0 AND memory_mode<>'OFF'
AND managed_call_memory_authorized(subject_id) ON CONFLICT DO NOTHING`)
if err != nil {
return err
}
conn, err := m.pool.Acquire(ctx)
if err != nil {
return err
}
defer conn.Release()
var sid, tenant, subject, state string
var revision int64
var uploaded *time.Time
err = conn.QueryRow(ctx, `SELECT j.session_id::text,c.tenant_id::text,c.subject_id::text,j.transcript_revision,j.state,j.uploaded_at
FROM call_memory_sync j JOIN call_session c ON c.id=j.session_id
WHERE (j.state<>'SKIPPED' OR (j.uploaded_at IS NOT NULL AND NOT managed_call_memory_authorized(c.subject_id))) AND j.next_attempt_at<=now() ORDER BY j.next_attempt_at LIMIT 1`).Scan(&sid, &tenant, &subject, &revision, &state, &uploaded)
if errors.Is(err, pgx.ErrNoRows) {
return nil
}
if err != nil {
return err
}
user := UserID(tenant, subject)
var locked bool
if err = conn.QueryRow(ctx, `SELECT pg_try_advisory_lock(hashtextextended($1,0))`, user).Scan(&locked); err != nil || !locked {
return err
}
defer func() {
c, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
if _, e := conn.Exec(c, `SELECT pg_advisory_unlock(hashtextextended($1,0))`, user); e != nil {
conn.Conn().Close(c)
}
}()
// Re-read after the lock: another worker may already have handled this job.
err = conn.QueryRow(ctx, `SELECT j.state,j.uploaded_at FROM call_memory_sync j JOIN call_session c ON c.id=j.session_id WHERE j.session_id=$1::uuid AND j.transcript_revision=$2 AND (j.state<>'SKIPPED' OR (j.uploaded_at IS NOT NULL AND NOT managed_call_memory_authorized(c.subject_id))) AND j.next_attempt_at<=now()`, sid, revision).Scan(&state, &uploaded)
if errors.Is(err, pgx.ErrNoRows) {
return nil
}
if err != nil {
return err
}
var allowed, suppressed bool
var current int64
err = conn.QueryRow(ctx, `SELECT managed_call_memory_authorized(c.subject_id),c.transcript_revision,
EXISTS(SELECT 1 FROM memory_source_suppression s CROSS JOIN LATERAL jsonb_array_elements(s.source_refs) r WHERE s.subject_id=c.subject_id AND r->>'session_id'=c.id::text)
FROM call_session c WHERE id=$1::uuid`, sid).Scan(&allowed, &current, &suppressed)
if err != nil {
return err
}
skip := func() error {
_, e := conn.Exec(ctx, `UPDATE call_memory_sync SET state='SKIPPED',last_error=NULL,next_attempt_at=now()+interval '5 minutes' WHERE session_id=$1::uuid AND transcript_revision=$2`, sid, revision)
return e
}
if !allowed {
// A withdrawal must also remove managed provider data; do not merely hide it.
if err = m.client.deleteManagedUser(ctx, user); err != nil {
return m.retry(ctx, conn, sid, revision, "VIKING_CLEANUP_FAILED")
}
return skip()
}
if revision != current || suppressed {
return skip()
}
remote := SessionID(sid, 0)
if uploaded == nil {
rows, e := conn.Query(ctx, `SELECT speaker,text_content,source_at FROM call_utterance WHERE session_id=$1::uuid ORDER BY source_at,utterance_id`, sid)
if e != nil {
return e
}
messages := []Message{}
hasUser := false
hasAssistant := false
for rows.Next() {
var speaker, text string
var at time.Time
if e = rows.Scan(&speaker, &text, &at); e != nil {
rows.Close()
return e
}
if strings.TrimSpace(text) == "" || text == "[deleted]" {
continue
}
role, id := "", user
if speaker == "USER" {
role = "user"
hasUser = true
} else if speaker == "ASSISTANT" {
role = "assistant"
id = ManagedAssistantID
hasAssistant = true
}
if role != "" {
messages = append(messages, Message{Role: role, RoleID: id, Content: text, Time: at.UnixMilli()})
}
}
e = rows.Err()
rows.Close()
if e != nil {
return e
}
if !hasUser || !hasAssistant {
return skip()
}
if len(messages) > 1000 {
return m.retry(ctx, conn, sid, revision, "TRANSCRIPT_TOO_LARGE")
}
if e = m.client.AddManagedSession(ctx, remote, user, messages, messages[0].Time); e != nil {
return m.retry(ctx, conn, sid, revision, "VIKING_UPLOAD_FAILED")
}
_, err = conn.Exec(ctx, `UPDATE call_memory_sync SET state='UPLOADED',uploaded_at=now(),attempts=attempts+1,last_error=NULL,next_attempt_at=now()+interval '10 seconds' WHERE session_id=$1::uuid AND transcript_revision=$2`, sid, revision)
return err
}
items, err := m.client.ManagedEvents(ctx, user, remote)
if err != nil {
return m.retry(ctx, conn, sid, revision, "VIKING_RETRIEVAL_FAILED")
}
if err = m.importEvents(ctx, conn, sid, tenant, subject, revision, items); err != nil {
return err
}
// Extraction is asynchronous. Keep polling, including after an empty result;
// an accepted upload is not proof that extraction completed or found a fact.
delay := 15
if time.Since(*uploaded) > 10*time.Minute {
delay = 300
}
if len(items) > 0 {
state = "SYNCED"
} else {
state = "UPLOADED"
}
_, err = conn.Exec(ctx, `UPDATE call_memory_sync SET state=$3,last_error=NULL,next_attempt_at=now()+make_interval(secs=>$4) WHERE session_id=$1::uuid AND transcript_revision=$2`, sid, revision, state, delay)
return err
}
func (m *Managed) retry(ctx context.Context, conn *pgxpool.Conn, sid string, revision int64, code string) error {
_, e := conn.Exec(ctx, `UPDATE call_memory_sync SET attempts=attempts+1,last_error=$3,next_attempt_at=now()+make_interval(secs=>LEAST(300,15*(attempts+1))) WHERE session_id=$1::uuid AND transcript_revision=$2`, sid, revision, code)
if e != nil {
return e
}
return errors.New(code)
}
func (m *Managed) importEvents(ctx context.Context, conn *pgxpool.Conn, sid, tenant, subject string, revision int64, items []Item) error {
tx, e := conn.Begin(ctx)
if e != nil {
return e
}
defer tx.Rollback(context.Background())
var allowed bool
e = tx.QueryRow(ctx, `SELECT managed_call_memory_authorized(us.subject_id) AND c.transcript_revision=$3 AND NOT EXISTS(SELECT 1 FROM memory_source_suppression s CROSS JOIN LATERAL jsonb_array_elements(s.source_refs) r WHERE s.subject_id=us.subject_id AND r->>'session_id'=$2) FROM memory_user_state us JOIN call_session c ON c.subject_id=us.subject_id WHERE us.subject_id=$1::uuid AND c.id=$2::uuid FOR UPDATE OF us`, subject, sid, revision).Scan(&allowed)
if e != nil {
return e
}
if !allowed {
return nil
}
for _, item := range items {
sum := sha256.Sum256([]byte(item.Content))
digest := hex.EncodeToString(sum[:])
id := uuid.NewSHA1(uuid.NameSpaceOID, []byte("viking-managed:"+m.client.options.ResourceID+":"+subject+":"+item.ID)).String()
var localVersion, importedVersion int64
var status, policy, previousDigest string
e = tx.QueryRow(ctx, `SELECT cm.version,cm.status,cm.proactive_policy,o.imported_version,o.provider_digest FROM curated_memory cm JOIN managed_memory_origin o ON o.memory_id=cm.id WHERE cm.id=$1::uuid FOR UPDATE OF cm`, id).Scan(&localVersion, &status, &policy, &importedVersion, &previousDigest)
exists := e == nil
if e != nil && !errors.Is(e, pgx.ErrNoRows) {
return e
}
// Only untouched automatic records may follow a provider correction.
// Never overwrite a user's edit, disable, or deletion.
if exists && (localVersion != importedVersion || status != "ACTIVE" || policy != "ALLOWED" || previousDigest == digest) {
continue
}
var refs []byte
if e = tx.QueryRow(ctx, `SELECT jsonb_build_array(jsonb_build_object('type','provider_memory','session_id',$1::text,'provider_id',$2::text)) || COALESCE(jsonb_agg(jsonb_build_object('type','user_turn','session_id',session_id::text,'utterance_id',utterance_id,'source_revision',source_revision)),'[]'::jsonb) FROM call_utterance WHERE session_id=$1::uuid`, sid, item.ID).Scan(&refs); e != nil {
return e
}
if !exists {
if _, e = tx.Exec(ctx, `INSERT INTO curated_memory(id,public_id,tenant_id,subject_id,status,memory_type,proactive_policy,sensitivity,entity_key,statement_basis,source_time) VALUES($1::uuid,$1::uuid,$2::uuid,$3::uuid,'ACTIVE','EVENT','ALLOWED','normal',$4,'MODEL_EXTRACTED',now())`, id, tenant, subject, "viking:"+item.ID); e != nil {
return e
}
}
vid := uuid.NewString()
if _, e = tx.Exec(ctx, `INSERT INTO memory_version(id,memory_id,version_no,content,source_refs,change_reason,content_digest) VALUES($1::uuid,$2::uuid,$6,$3,$4::jsonb,'viking-session-extraction',$5)`, vid, id, item.Content, refs, digest, localVersion+1); e != nil {
return e
}
if _, e = tx.Exec(ctx, `UPDATE curated_memory SET current_version_id=$2::uuid,version=$3,updated_at=now() WHERE id=$1::uuid`, id, vid, localVersion+1); e != nil {
return e
}
if _, e = tx.Exec(ctx, `INSERT INTO managed_memory_origin(memory_id,subject_id,session_id,provider_id,provider_digest,imported_version) VALUES($1::uuid,$2::uuid,$3::uuid,$4,$5,$6) ON CONFLICT(memory_id) DO UPDATE SET provider_digest=EXCLUDED.provider_digest,imported_version=EXCLUDED.imported_version`, id, subject, sid, item.ID, digest, localVersion+1); e != nil {
return e
}
if _, e = tx.Exec(ctx, `INSERT INTO memory_index_entry(tenant_id,subject_id,memory_id,memory_version_id,index_type,status) VALUES($1::uuid,$2::uuid,$3::uuid,$4::uuid,'pgvector','PENDING')`, tenant, subject, id, vid); e != nil {
return e
}
dedupe := "memory-index-v1:" + vid
payload, _ := json.Marshal(map[string]string{"memory_version_id": vid})
if _, e = outbox.NewStore(m.pool).Enqueue(ctx, tx, outbox.NewEvent{TenantID: &tenant, AggregateType: "curated_memory", AggregateID: id, EventType: "memory.index_requested", DeduplicationKey: &dedupe, Payload: payload}); e != nil {
return e
}
}
return tx.Commit(ctx)
}
// The platform mirror is the serving source for pure S2S. Native MemoryConfig
// stays off, so cloud extraction cannot bypass local edits or deletions.
func (m *Managed) Project(ctx context.Context, r callmemory.ProjectionRecord, priorKind, priorID string, reconcile bool) (callmemory.ProjectionResult, error) {
result := callmemory.ProjectionResult{Kind: "event", ID: r.ID, Visible: true}
var sid, id string
err := m.pool.QueryRow(ctx, `SELECT o.session_id::text,o.provider_id FROM managed_memory_origin o JOIN curated_memory cm ON cm.id=o.memory_id WHERE cm.public_id=$1::uuid AND cm.subject_id=$2::uuid AND cm.tenant_id=$3::uuid`, r.ID, r.Subject, r.Tenant).Scan(&sid, &id)
// Manually authored platform memories do not require a provider mirror to serve.
if errors.Is(err, pgx.ErrNoRows) {
var direct bool
if e := m.pool.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM call_session WHERE subject_id=$1::uuid AND budget_snapshot->>'protocol'='DIRECT-CHAT-1')`, r.Subject).Scan(&direct); e != nil {
return result, e
}
if !direct {
return NewProjectionAdapter(m.pool, m.client).Project(ctx, r, priorKind, priorID, reconcile)
}
result.Kind = "local"
return result, nil
}
if err != nil {
return result, err
}
result.ID = id
items, err := m.client.ManagedEvents(ctx, UserID(r.Tenant, r.Subject), SessionID(sid, 0))
if err != nil {
return result, err
}
for _, item := range items {
if item.ID != id {
continue
}
serve := r.Status == "ACTIVE" && r.Policy == "ALLOWED" && (r.Kind != "OPEN_ITEM" || r.ItemStatus == "OPEN")
if !serve {
err = m.client.call(ctx, "/api/memory/event/delete", map[string]any{"event_id": id}, nil)
} else if item.Content != r.Content {
// original_messages is returned by search but rejected by event/update.
err = m.client.call(ctx, "/api/memory/event/update", map[string]any{"event_id": id, "memory_info": map[string]string{"summary": r.Content}}, nil)
} else {
return result, nil
}
result.Visible = false
return result, err
}
// Absence means provider cleanup is complete. Local records remain authoritative.
return result, nil
}
func (c *Client) deleteManagedUser(ctx context.Context, user string) error {
if user == "" {
return errors.New("managed user required")
}
return c.call(ctx, "/api/memory/event/batch_delete", map[string]any{"delete_type": "full", "filter": map[string]any{"user_id": []string{user}, "assistant_id": []string{ManagedAssistantID}}}, nil)
}