feat: expand companion platform, device controls, and memoir workflows
ci / verify (push) Canceled after 0s

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.
This commit is contained in:
Codex Local
2026-10-08 10:06:44 +08:00
parent 98fb2965d0
commit bf1233d5df
861 changed files with 94614 additions and 3209 deletions
+22 -6
View File
@@ -4,21 +4,21 @@ package vikingmemory
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"data-terminal-cloud/internal/memoryidentity"
"data-terminal-cloud/internal/outbox"
)
const AssistantID = "platform_companion_v1"
const AssistantID = memoryidentity.AssistantID
type Options struct {
BaseURL, ResourceID, CollectionName, APIKey, EventType, ProfileType string
@@ -60,8 +60,7 @@ func NewClient(options Options) (*Client, error) {
return &Client{options: options, http: &http.Client{Timeout: options.Timeout, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}}, nil
}
func UserID(tenantID, subjectID string) string {
digest := sha256.Sum256([]byte(tenantID + ":" + subjectID))
return "u_" + hex.EncodeToString(digest[:])
return memoryidentity.UserID(tenantID, subjectID)
}
func SessionID(conversationID string, batch int) string {
return "c_" + strings.ReplaceAll(conversationID, "-", "") + fmt.Sprintf("_%d", batch)
@@ -94,7 +93,12 @@ func (c *Client) call(ctx context.Context, path string, payload map[string]any,
code = *result.Code
}
failure := fmt.Errorf("Viking %s HTTP %d code %d", path, resp.StatusCode, code)
if resp.StatusCode == 429 || resp.StatusCode >= 500 || (resp.StatusCode >= 200 && resp.StatusCode < 300) {
// A valid nonzero business response requires classification/operator review.
// Only an unreadable successful response has an uncertain write outcome.
if resp.StatusCode == http.StatusTooManyRequests || code == 1000029 {
return outbox.RetryAfter(failure, vikingRetryDelay(resp.Header.Get("Retry-After"), time.Now()))
}
if resp.StatusCode >= 500 || (resp.StatusCode >= 200 && resp.StatusCode < 300 && (err != nil || result.Code == nil)) {
return outbox.Retryable(failure)
}
return failure
@@ -106,6 +110,18 @@ func (c *Client) call(ctx context.Context, path string, payload map[string]any,
}
return nil
}
func vikingRetryDelay(header string, now time.Time) time.Duration {
// Rate limits without a usable Retry-After get a full cooldown minute.
// This prevents the short generic retry schedule exhausting the job.
if seconds, err := strconv.ParseInt(strings.TrimSpace(header), 10, 64); err == nil && seconds > 0 {
return time.Duration(min(seconds, int64((24*time.Hour)/time.Second))) * time.Second
}
if deadline, err := http.ParseTime(header); err == nil && deadline.After(now) {
return min(deadline.Sub(now), 24*time.Hour)
}
return time.Minute
}
func (c *Client) AddSession(ctx context.Context, sessionID, userID string, messages []Message, when int64) error {
if sessionID == "" || userID == "" || len(messages) == 0 || len(messages) > 100 {
return errors.New("invalid Viking session")
+38 -1
View File
@@ -58,7 +58,7 @@ func TestVikingErrorsDoNotLeakServerBodyOrKey(t *testing.T) {
if err == nil || strings.Contains(err.Error(), "secret") || strings.Contains(err.Error(), "transcript") {
t.Fatalf("unsafe error %v", err)
}
if outbox.IsRetryable(err) != (status != 401) {
if outbox.IsRetryable(err) != (status == 429 || status >= 500) {
t.Fatalf("retry classification %v", err)
}
})
@@ -92,3 +92,40 @@ func TestDeleteUsesExactUserForBothMemoryTypes(t *testing.T) {
t.Fatal(err, calls)
}
}
func TestVikingDeletionRateLimitPreservesCooldownAndFailure(t *testing.T) {
for _, status := range []int{http.StatusTooManyRequests, http.StatusOK} {
t.Run(fmt.Sprint(status), func(t *testing.T) {
calls := 0
c := testClient(t, func(w http.ResponseWriter, r *http.Request) {
calls++
w.Header().Set("Retry-After", "120")
w.WriteHeader(status)
fmt.Fprint(w, `{"code":1000029,"message":"private provider body"}`)
})
err := c.DeleteUser(context.Background(), "alice")
if err == nil || !outbox.IsRetryable(err) || outbox.RetryDelay(err) != 2*time.Minute || calls != 1 {
t.Fatalf("rate limit must remain an incomplete deletion: calls=%d delay=%s err=%v", calls, outbox.RetryDelay(err), err)
}
if strings.Contains(err.Error(), "private") {
t.Fatal("provider body leaked into retained error")
}
})
}
}
func TestVikingRetryDelayParsesSecondsAndDates(t *testing.T) {
now := time.Date(2026, 10, 4, 14, 0, 0, 0, time.UTC)
for _, tt := range []struct {
header string
want time.Duration
}{
{"", time.Minute}, {"invalid", time.Minute}, {"-1", time.Minute},
{"120", 2 * time.Minute}, {now.Add(90 * time.Second).Format(http.TimeFormat), 90 * time.Second},
{now.Add(-time.Minute).Format(http.TimeFormat), time.Minute}, {"9223372036854775807", 24 * time.Hour},
} {
if got := vikingRetryDelay(tt.header, now); got != tt.want {
t.Errorf("Retry-After %q = %s, want %s", tt.header, got, tt.want)
}
}
}
+85
View File
@@ -0,0 +1,85 @@
package vikingmemory
import (
"context"
"crypto/sha256"
"data-terminal-cloud/internal/callmemory"
"encoding/hex"
"encoding/json"
"errors"
"strings"
)
// ChatMemories returns untruncated native-chat records and checks scope again on
// every provider result. Memoir extraction and projection assistants are excluded.
func (c *Client) ChatMemories(ctx context.Context, user, kind, query string, limit int, start, end int64) ([]callmemory.VikingRecord, bool, error) {
if user == "" || kind != "event" && kind != "profile" || limit < 1 || limit > 5000 {
return nil, false, errors.New("invalid chat memory scope")
}
rule := c.options.EventType
if kind == "profile" {
rule = c.options.ProfileType
}
filter := map[string]any{"user_id": []string{user}, "assistant_id": []string{AssistantID}, "memory_type": []string{rule}}
if start > 0 {
filter["start_time"] = start
}
if end > 0 {
filter["end_time"] = end
}
payload := map[string]any{"filter": filter, "limit": limit}
if query != "" {
payload["query"] = query
}
var data struct {
Items []struct {
Item
Time int64 `json:"time"`
Types []string `json:"memory_type"`
} `json:"result_list"`
}
if e := c.call(ctx, "/api/memory/"+kind+"/search", payload, &data); e != nil {
return nil, false, e
}
out := []callmemory.VikingRecord{}
limited := len(data.Items) >= limit
for _, item := range data.Items {
// Shared records cannot be deleted through an individual elder's console.
if item.ID == "" || len(item.UserID) != 1 || item.UserID[0] != user || len(item.AssistantID) != 1 || item.AssistantID[0] != AssistantID || !contains(item.Types, rule) {
// A filtered or malformed response cannot prove a deleted ID is absent.
limited = true
continue
}
content := []string{}
for _, key := range []string{"summary", "user_profile"} {
if raw := item.MemoryInfo[key]; len(raw) > 0 {
var text string
if json.Unmarshal(raw, &text) != nil {
text = string(raw)
}
if strings.TrimSpace(text) != "" {
content = append(content, text)
}
}
}
if len(content) == 0 {
b, _ := json.MarshalIndent(item.MemoryInfo, "", " ")
content = append(content, string(b))
}
r := callmemory.VikingRecord{ID: item.ID, Kind: kind, Time: item.Time, SessionID: item.SessionID, Content: strings.Join(content, "\n"), MemoryInfo: item.MemoryInfo}
raw, e := json.Marshal(r)
if e != nil {
return nil, false, e
}
sum := sha256.Sum256(raw)
r.Digest = hex.EncodeToString(sum[:])
out = append(out, r)
}
return out, limited, nil
}
func (c *Client) DeleteChatMemory(ctx context.Context, kind, id string) error {
if (kind != "event" && kind != "profile") || id == "" {
return errors.New("invalid chat memory deletion")
}
return c.call(ctx, "/api/memory/"+kind+"/delete", map[string]any{kind + "_id": id}, nil)
}
+56
View File
@@ -0,0 +1,56 @@
package vikingmemory
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestConsoleScopeFullContentAndProviderFilters(t *testing.T) {
long := strings.Repeat("完整正文", 500)
var calls []map[string]any
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var p map[string]any
_ = json.NewDecoder(r.Body).Decode(&p)
calls = append(calls, p)
if r.URL.Path == "/api/memory/event/delete" {
if p["event_id"] != "owned" {
t.Error(p)
}
_, _ = w.Write([]byte(`{"code":0}`))
return
}
if r.URL.Path != "/api/memory/event/search" {
t.Error(r.URL.Path)
}
items := []map[string]any{}
for _, scope := range []struct{ id, user, assistant string }{{"owned", "user-a", AssistantID}, {"foreign-user", "user-b", AssistantID}, {"memoir", "user-a", ManagedAssistantID}} {
items = append(items, map[string]any{"id": scope.id, "user_id": []string{scope.user}, "assistant_id": []string{scope.assistant}, "memory_type": []string{"event_v1"}, "time": int64(123000), "memory_info": map[string]any{"summary": long, "original_messages": []string{"合成原文"}}})
}
items = append(items, map[string]any{"id": "shared", "user_id": []string{"user-a", "user-b"}, "assistant_id": []string{AssistantID}, "memory_type": []string{"event_v1"}})
_ = json.NewEncoder(w).Encode(map[string]any{"code": 0, "data": map[string]any{"result_list": items}})
}))
defer server.Close()
c, e := NewClient(Options{BaseURL: server.URL, ResourceID: "synthetic", APIKey: "fake-key", EventType: "event_v1", ProfileType: "profile_v1", Timeout: 1000000000})
if e != nil {
t.Fatal(e)
}
c.http = server.Client()
records, limited, e := c.ChatMemories(context.Background(), "user-a", "event", "家乡", 100, 10, 20)
if e != nil || !limited || len(records) != 1 || records[0].Content != long || len(records[0].Digest) != 64 {
t.Fatal(records, limited, e)
}
filter := calls[0]["filter"].(map[string]any)
if filter["start_time"] != float64(10) || filter["end_time"] != float64(20) || calls[0]["query"] != "家乡" || calls[0]["resource_id"] != "synthetic" {
t.Fatal(calls)
}
if e = c.DeleteChatMemory(context.Background(), "event", records[0].ID); e != nil {
t.Fatal(e)
}
if e = c.DeleteChatMemory(context.Background(), "unexpected", "owned"); e == nil || len(calls) != 2 {
t.Fatal("invalid kind reached provider")
}
}
+284
View File
@@ -0,0 +1,284 @@
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)
}
+59
View File
@@ -0,0 +1,59 @@
package vikingmemory
import (
"context"
"encoding/json"
"errors"
"strings"
)
// Events are the managed unit: profiles are not independently regenerated from
// a deleted/corrected event. Every event is mirrored into the editable platform
// store before it can be used in a voice prompt.
const ManagedAssistantID = "voicelink_managed_v1"
func (c *Client) AddManagedSession(ctx context.Context, session, user string, messages []Message, at int64) error {
if session == "" || user == "" || len(messages) == 0 || len(messages) > 1000 {
return errors.New("invalid managed session")
}
return c.call(ctx, "/api/memory/session/add", map[string]any{
"session_id": session, "messages": messages, "store_file": false,
"metadata": map[string]any{"default_user_id": user, "default_assistant_id": ManagedAssistantID, "time": at},
"extract_memory_type": []string{c.options.EventType},
}, nil)
}
func (c *Client) ManagedEvents(ctx context.Context, user, session string) ([]Item, error) {
if user == "" || session == "" {
return nil, errors.New("managed scope required")
}
var data struct {
Items []Item `json:"result_list"`
}
err := c.call(ctx, "/api/memory/event/search", map[string]any{"filter": map[string]any{
"user_id": []string{user}, "assistant_id": []string{ManagedAssistantID}, "session_id": []string{session}, "memory_type": []string{c.options.EventType},
}, "limit": 1000}, &data)
if err != nil {
return nil, err
}
if len(data.Items) >= 1000 {
return nil, errors.New("managed result limit reached")
}
out := []Item{}
for _, item := range data.Items {
if item.ID == "" || !contains(item.UserID, user) || !contains(item.AssistantID, ManagedAssistantID) || item.SessionID != session {
continue
}
var summary string
if json.Unmarshal(item.MemoryInfo["summary"], &summary) != nil {
continue
}
summary = strings.TrimSpace(summary)
if summary == "" || len([]rune(summary)) > 4000 {
continue
}
item.Content = summary
out = append(out, item)
}
return out, nil
}
+249
View File
@@ -0,0 +1,249 @@
package vikingmemory
import (
"context"
"encoding/json"
"fmt"
"net/http"
"os"
"strings"
"testing"
"data-terminal-cloud/internal/audit"
"data-terminal-cloud/internal/callmemory"
"data-terminal-cloud/internal/deviceauth"
"data-terminal-cloud/internal/identity"
"data-terminal-cloud/internal/saas"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool"
)
func TestManagedUploadPreservesScopeAndOnlyExtractsManagedEvents(t *testing.T) {
c := testClient(t, func(w http.ResponseWriter, r *http.Request) {
var p map[string]any
json.NewDecoder(r.Body).Decode(&p)
if p["session_id"] != "c_test" || p["resource_id"] != "mem-test" || p["store_file"] != false {
t.Fatal("wrong upload destination")
}
types := p["extract_memory_type"].([]any)
if len(types) != 1 || types[0] != "event_v1" {
t.Fatal("independent profile extraction enabled")
}
meta := p["metadata"].(map[string]any)
if meta["default_user_id"] != "elder-a" || meta["default_assistant_id"] != ManagedAssistantID {
t.Fatal("wrong elder")
}
fmt.Fprint(w, `{"code":0}`)
})
if e := c.AddManagedSession(context.Background(), "c_test", "elder-a", []Message{{Role: "user", RoleID: "elder-a", Content: "茶"}}, 1); e != nil {
t.Fatal(e)
}
}
func TestManagedTranscriptToMemoryAndNextCall(t *testing.T) {
db := os.Getenv("TEST_DATABASE_URL")
if db == "" {
t.Skip("TEST_DATABASE_URL required")
}
ctx := context.Background()
p, e := pgxpool.New(ctx, db)
if e != nil {
t.Fatal(e)
}
defer p.Close()
exec := func(q string, args ...any) {
t.Helper()
if _, e := p.Exec(ctx, q, args...); e != nil {
t.Fatal(e)
}
}
subject, elder, device, sid := uuid.NewString(), uuid.NewString(), uuid.NewString(), uuid.NewString()
tenant := "10000000-0000-0000-0000-000000000001"
exec(`INSERT INTO subject_profile(id,public_id,tenant_id,household_id,preferred_name,status) VALUES($1::uuid,$2::uuid,$3::uuid,'10000000-0000-0000-0000-000000000201','managed-test','active')`, subject, elder, tenant)
exec(`INSERT INTO consent_record(tenant_id,subject_id,consent_type,scope,policy_version,notice_digest,actor_user_id,evidence_ref) SELECT tenant_id,$1::uuid,consent_type,scope,policy_version,notice_digest,actor_user_id,evidence_ref FROM consent_record WHERE subject_id='10000000-0000-0000-0000-000000000401' AND withdrawn_at IS NULL`, subject)
exec(`INSERT INTO consent_snapshot(tenant_id,subject_id,snapshot_version,effective_consents,source_consent_ids) VALUES($1::uuid,$2::uuid,1,'{"voice_chat":true}','[]')`, tenant, subject)
exec(`INSERT INTO memory_user_state(subject_id,tenant_id) VALUES($1::uuid,$2::uuid)`, subject, tenant)
exec(`INSERT INTO device(id,tenant_id,serial_digest,model,hardware_revision,status,credential_ref,activation_state) VALUES($1::uuid,$2::uuid,$3,'test','test','registered','local-dev://test','ACTIVE')`, device, tenant, strings.Repeat("a", 28)+strings.ReplaceAll(device, "-", ""))
exec(`INSERT INTO device_binding(tenant_id,device_id,household_id,subject_id,bound_by,reason,generation) VALUES($1::uuid,$2::uuid,'10000000-0000-0000-0000-000000000201',$3::uuid,'10000000-0000-0000-0000-000000000101','test',1)`, tenant, device, subject)
exec(`INSERT INTO subject_voice_agent(subject_id,bot_name,bot_id,status) VALUES($1::uuid,$2,$2,'READY')`, subject, "test-"+subject)
exec(`INSERT INTO device_activation_job(device_id,tenant_id,provider_device_name,provider_bot_id,status,binding_generation,config_version,sdk_registered_at) VALUES($1::uuid,$2::uuid,$3,'bot','READY',1,1,now())`, device, tenant, "test-"+device)
exec(`INSERT INTO call_session(id,tenant_id,subject_id,device_id,boot_id,hook_event_id,call_generation,binding_generation,call_state,transcript_state,ended_at,memory_mode,transcript_revision,budget_snapshot) VALUES($1::uuid,$2::uuid,$3::uuid,$4::uuid,$1::uuid,$1,1,1,'ENDED','PARTIAL',now(),'HOT_ONLY',2,'{"protocol":"DIRECT-CHAT-1"}')`, sid, tenant, subject, device)
exec(`INSERT INTO call_utterance(session_id,utterance_id,source_revision,speaker,text_content,interrupted,source_at) VALUES($1::uuid,'u',0,'USER','我喜欢淡茶',false,now()),($1::uuid,'a',0,'ASSISTANT','记住了',false,now()+interval '1 second')`, sid)
user := UserID(tenant, subject)
remote := SessionID(sid, 0)
uploads := 0
fail := true
deleted := false
cloudText := "喜欢淡茶"
c := testClient(t, func(w http.ResponseWriter, r *http.Request) {
var in map[string]any
json.NewDecoder(r.Body).Decode(&in)
switch {
case strings.HasSuffix(r.URL.Path, "/session/add"):
uploads++
if fail {
w.WriteHeader(503)
fmt.Fprint(w, `{"code":9}`)
return
}
msgs := in["messages"].([]any)
if len(msgs) != 2 || in["session_id"] != remote {
t.Fatal("lost or duplicated transcript")
}
for _, msg := range msgs {
v := msg.(map[string]any)
if v["role"] == "user" && v["role_id"] != user {
t.Fatal("wrong upload elder")
}
}
fmt.Fprint(w, `{"code":0}`)
case strings.HasSuffix(r.URL.Path, "/search"):
f := in["filter"].(map[string]any)
if f["user_id"].([]any)[0] != user || f["session_id"].([]any)[0] != remote {
t.Fatal("unscoped search")
}
items := []any{map[string]any{"id": "foreign", "user_id": []string{"other-elder"}, "assistant_id": []string{ManagedAssistantID}, "session_id": remote, "memory_info": map[string]any{"summary": "不能串人"}}}
if !deleted {
items = append(items, map[string]any{"id": "event-one", "user_id": []string{user}, "assistant_id": []string{ManagedAssistantID}, "session_id": remote, "memory_info": map[string]any{"summary": cloudText}})
}
json.NewEncoder(w).Encode(map[string]any{"code": 0, "data": map[string]any{"result_list": items}})
case strings.HasSuffix(r.URL.Path, "/update"):
if in["event_id"] != "event-one" {
t.Fatal("wrong update target")
}
cloudText = in["memory_info"].(map[string]any)["summary"].(string)
fmt.Fprint(w, `{"code":0}`)
case strings.HasSuffix(r.URL.Path, "/delete"):
if in["event_id"] != "event-one" {
t.Fatal("wrong delete target")
}
deleted = true
fmt.Fprint(w, `{"code":0}`)
case strings.HasSuffix(r.URL.Path, "/batch_delete"):
f := in["filter"].(map[string]any)
if f["user_id"].([]any)[0] != user || f["assistant_id"].([]any)[0] != ManagedAssistantID {
t.Fatal("unscoped withdrawal")
}
deleted = true
fmt.Fprint(w, `{"code":0}`)
default:
t.Fatal("unexpected endpoint", r.URL.Path)
}
})
m := NewManaged(p, c)
s := callmemory.NewService(p, nil)
s.ConfigureManagedSync(m.SyncNext)
s.ConfigureProjection(m)
due := func() { exec(`UPDATE call_memory_sync SET next_attempt_at=now() WHERE session_id=$1::uuid`, sid) }
if e = m.SyncNext(ctx); e == nil {
t.Fatal("failed upload reported successful")
}
fail = false
due()
if e = m.SyncNext(ctx); e != nil {
t.Fatal(e)
}
due()
if e = m.SyncNext(ctx); e != nil {
t.Fatal(e)
}
due()
if e = m.SyncNext(ctx); e != nil {
t.Fatal(e)
}
if uploads != 2 {
t.Fatal("acknowledged upload repeated", uploads)
}
var n int
var id string
if e = p.QueryRow(ctx, `SELECT count(*),min(public_id::text) FROM curated_memory WHERE subject_id=$1::uuid`, subject).Scan(&n, &id); e != nil || n != 1 {
t.Fatal("import duplicate or foreign memory", n, e)
}
if e = s.ProjectNext(ctx); e != nil {
t.Fatal(e)
}
// A provider correction may refresh an untouched auto-memory.
cloudText = "喜欢不加糖的淡茶"
due()
if e = m.SyncNext(ctx); e != nil {
t.Fatal(e)
}
var contextText string
s.ConfigureDirect(callmemory.DirectOptions{CallbackURL: "https://example.com", CallbackSecret: []byte(strings.Repeat("k", 32)), Prepare: func(_ context.Context, gotSubject, bot, session, text string) error {
if gotSubject != subject {
t.Fatal("wrong preparation elder")
}
contextText = text
return nil
}})
next := uuid.NewString()
d := deviceauth.Identity{ID: device, TenantID: tenant, BindingGeneration: 1}
if _, e = s.DirectRequest(ctx, d, "start", callmemory.DirectInput{SessionID: next}); e != nil {
t.Fatal(e)
}
if !strings.Contains(contextText, "淡茶") || strings.Contains(contextText, "串人") {
t.Fatal("next call did not receive scoped memory")
}
if _, e = s.DirectRequest(ctx, d, "end", callmemory.DirectInput{SessionID: next, Reason: "hangup", SDKStopped: true}); e != nil {
t.Fatal(e)
}
principal := identity.Principal{Issuer: "http://localhost:58180/realms/voice-local", Subject: "00000000-0000-0000-0000-000000000101"}
portal := saas.NewService(p, audit.NewRepository(), nil, nil)
visible, e := portal.ListSubjectMemories(ctx, principal, elder, "")
if e != nil || len(visible) != 1 || visible[0].ConfirmedBy != "" {
t.Fatal("mini-program cannot list automatically extracted memory", e, len(visible))
}
text := "喜欢温开水"
if _, e = portal.UpdateMemory(ctx, principal, id, "managed-test-edit", 2, saas.UpdateMemoryInput{Content: &text, Reason: "更正测试"}); e != nil {
t.Fatal(e)
}
if e = s.ProjectNext(ctx); e != nil {
t.Fatal(e)
}
if cloudText != text {
t.Fatal("edit not projected")
}
if e = s.ProjectNext(ctx); e != nil {
t.Fatal(e)
}
due()
if e = m.SyncNext(ctx); e != nil {
t.Fatal(e)
}
if e = p.QueryRow(ctx, `SELECT count(*) FROM call_utterance WHERE session_id=$1::uuid`, sid).Scan(&n); e != nil || n != 2 {
t.Fatal("chat records lost", e)
}
// Use the actual user deletion API; repeated import cannot resurrect it.
if _, e = portal.RequestMemoryDeletion(ctx, principal, id, "测试删除", "managed-test-delete", 3); e != nil {
t.Fatal(e)
}
due()
if e = m.SyncNext(ctx); e != nil {
t.Fatal(e)
}
var status string
if e = p.QueryRow(ctx, `SELECT status FROM curated_memory WHERE public_id=$1::uuid`, id).Scan(&status); e != nil || status != "PENDING_DELETE" {
t.Fatal("deleted record resurrected", status, e)
}
// Exact deletion projection and its confirmed absence; no second model involved.
r := callmemory.ProjectionRecord{ID: id, Subject: subject, Tenant: tenant, Status: "PENDING_DELETE", Policy: "NEVER"}
if _, e = m.Project(ctx, r, "", "", false); e != nil {
t.Fatal(e)
}
if !deleted {
t.Fatal("remote deletion missing")
}
result, e := m.Project(ctx, r, "", "", true)
if e != nil || !result.Visible {
t.Fatal("cleanup acknowledgement missing", e)
}
deleted = false
exec(`UPDATE consent_record SET withdrawn_at=now() WHERE subject_id=$1::uuid AND consent_type='personalized_memory'`, subject)
due()
if e = m.SyncNext(ctx); e != nil {
t.Fatal(e)
}
if !deleted {
t.Fatal("withdrawal did not clean managed cloud scope")
}
}
+185
View File
@@ -0,0 +1,185 @@
package vikingmemory
import (
"context"
"data-terminal-cloud/internal/callmemory"
"data-terminal-cloud/internal/outbox"
"encoding/json"
"errors"
"fmt"
"github.com/jackc/pgx/v5/pgxpool"
"strconv"
"strings"
)
const ProjectionAssistantID = "platform_companion_v2"
type ProjectionAdapter struct {
pool *pgxpool.Pool
client *Client
}
type projectionSchema struct {
EventType string `json:"event_type"`
ProfileType string `json:"profile_type"`
IDField string `json:"memory_id_field"`
VersionField string `json:"record_version_field"`
OperationField string `json:"operation_key_field"`
EventContent string `json:"event_content_field"`
ProfileContent string `json:"profile_content_field"`
ProfileGroups bool `json:"profile_groups_verified"`
}
func NewProjectionAdapter(pool *pgxpool.Pool, client *Client) *ProjectionAdapter {
return &ProjectionAdapter{pool, client}
}
func (a *ProjectionAdapter) schema(ctx context.Context) (projectionSchema, error) {
var p projectionSchema
var raw []byte
e := a.pool.QueryRow(ctx, `SELECT projection_schema FROM call_provider_profile WHERE published AND capabilities->>'H08'='true' AND COALESCE(evidence->>'H08','')<>'' ORDER BY created_at DESC LIMIT 1`).Scan(&raw)
if e != nil {
return p, &callmemory.Error{Status: 409, Code: "PROJECTION_SCHEMA_UNVERIFIED"}
}
if e = json.Unmarshal(raw, &p); e != nil {
return p, e
}
if p.EventType == "" || p.IDField == "" || p.VersionField == "" || p.OperationField == "" || p.EventContent == "" {
return p, &callmemory.Error{Status: 409, Code: "PROJECTION_SCHEMA_UNVERIFIED"}
}
return p, nil
}
// Project uses provider CRUD APIs, never AddSession. An uncertain add is only
// reconciled by its persistent operation key; absence in a query is not permission
// to create again. A full 5000-item result cannot prove absence and is blocked.
func (a *ProjectionAdapter) Project(ctx context.Context, r callmemory.ProjectionRecord, priorKind, priorID string, reconcileOnly bool) (callmemory.ProjectionResult, error) {
out := callmemory.ProjectionResult{}
p, e := a.schema(ctx)
if e != nil {
return out, e
}
kind := "event"
rule := p.EventType
field := p.EventContent
if r.Kind == "PROFILE" {
if p.ProfileType == "" || p.ProfileContent == "" || !p.ProfileGroups {
return out, &callmemory.Error{Status: 409, Code: "PROFILE_PROJECTION_UNVERIFIED"}
}
kind = "profile"
rule = p.ProfileType
field = p.ProfileContent
}
if priorID != "" && priorKind != "" && priorKind != kind {
return out, &callmemory.Error{Status: 409, Code: "PROJECTION_KIND_CHANGE_REQUIRES_CLEANUP"}
}
user := UserID(r.Tenant, r.Subject)
operation := r.ID + ":" + strconv.FormatInt(r.Version, 10)
serve := r.Status == "ACTIVE" && r.Policy == "ALLOWED" && (r.Kind != "OPEN_ITEM" || r.ItemStatus == "OPEN")
items, complete, e := a.list(ctx, kind, rule, user)
if e != nil {
return out, e
}
matches := []Item{}
for _, item := range items {
var id string
_ = json.Unmarshal(item.MemoryInfo[p.IDField], &id)
if id == r.ID {
matches = append(matches, item)
}
}
if len(matches) > 1 {
return out, &callmemory.Error{Status: 409, Code: "DUPLICATE_PROVIDER_PROJECTION"}
}
if len(matches) == 1 {
priorID = matches[0].ID
out.ID = priorID
out.Kind = kind
var version int64
_ = json.Unmarshal(matches[0].MemoryInfo[p.VersionField], &version)
var content string
_ = json.Unmarshal(matches[0].MemoryInfo[field], &content)
if serve && version == r.Version && content == r.Content {
out.Visible = true
return out, nil
}
}
if !serve {
if reconcileOnly && priorID == "" && len(matches) == 0 {
return out, errors.New("uncertain create must be reconciled before proving deletion")
}
if len(matches) == 0 && complete {
out.Kind = kind
out.ID = priorID
out.Visible = true
return out, nil
}
if priorID == "" {
return out, &callmemory.Error{Status: 409, Code: "PROVIDER_ABSENCE_NOT_PROVEN"}
}
e = a.client.call(ctx, "/api/memory/"+kind+"/delete", map[string]any{kind + "_id": priorID}, nil)
if e != nil {
return out, projectionError(e)
}
out.ID = priorID
out.Kind = kind
return out, nil
}
if reconcileOnly && priorID == "" {
return out, errors.New("provider add outcome remains unknown")
}
if !complete && priorID == "" {
return out, &callmemory.Error{Status: 409, Code: "PROVIDER_SCOPE_TOO_LARGE"}
}
info := map[string]any{p.IDField: r.ID, p.VersionField: r.Version, p.OperationField: operation, field: r.Content}
payload := map[string]any{"memory_info": info}
path := "/api/memory/" + kind + "/update"
if priorID != "" {
payload[kind+"_id"] = priorID
} else {
path = "/api/memory/" + kind + "/add"
payload[kind+"_type"] = rule
payload["user_id"] = user
payload["assistant_id"] = ProjectionAssistantID
if kind == "profile" {
payload["group_id"] = r.ID
payload["is_upsert"] = false
} else {
payload["update_profiles"] = []string{}
}
}
var data map[string]json.RawMessage
e = a.client.call(ctx, path, payload, &data)
if e != nil {
return out, projectionError(e)
}
if priorID == "" {
_ = json.Unmarshal(data[kind+"_id"], &priorID)
if strings.TrimSpace(priorID) == "" {
return out, errors.New("provider acknowledgement has no object identity")
}
}
out.Kind = kind
out.ID = priorID
out.Visible = false
return out, nil
}
func (a *ProjectionAdapter) list(ctx context.Context, kind, rule, user string) ([]Item, bool, error) {
var data struct {
Items []Item `json:"result_list"`
}
e := a.client.call(ctx, "/api/memory/"+kind+"/search", map[string]any{"filter": map[string]any{"user_id": []string{user}, "assistant_id": []string{ProjectionAssistantID}, "memory_type": []string{rule}}, "limit": 5000}, &data)
if e != nil {
return nil, false, projectionError(e)
}
for _, item := range data.Items {
if item.ID == "" || !contains(item.UserID, user) || !contains(item.AssistantID, ProjectionAssistantID) {
return nil, false, &callmemory.Error{Status: 409, Code: "PROVIDER_SCOPE_VIOLATION"}
}
}
return data.Items, len(data.Items) < 5000, nil
}
func projectionError(e error) error {
if outbox.IsRetryable(e) {
return e
}
return &callmemory.Error{Status: 409, Code: "PROVIDER_SCHEMA_OR_AUTH_REJECTED", Message: fmt.Sprintf("Provider rejected projection: %T", e)}
}
@@ -0,0 +1,76 @@
package vikingmemory
import (
"context"
"data-terminal-cloud/internal/callmemory"
"encoding/json"
"fmt"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool"
"net/http"
"os"
"strings"
"testing"
)
func TestCuratedProjectionUnknownCreateNeverDuplicatesOrProvesDeletion(t *testing.T) {
url := os.Getenv("TEST_DATABASE_URL")
if url == "" {
t.Skip("TEST_DATABASE_URL required")
}
ctx := context.Background()
pool, e := pgxpool.New(ctx, url)
if e != nil {
t.Fatal(e)
}
defer pool.Close()
id := "test-" + uuid.NewString()
defer pool.Exec(ctx, `DELETE FROM call_provider_profile WHERE id=$1`, id)
_, e = pool.Exec(ctx, `INSERT INTO call_provider_profile(id,protocol_version,sdk_version,bot_id,model_version,transport,audio_profile,vendor_template,field_mapping,capabilities,evidence,projection_schema,published) VALUES($1,'CALL-MEMORY-1.1','test','test','test','RTC','{}','{}','{}','{"H08":true}','{"H08":"synthetic test only"}','{"event_type":"curated","memory_id_field":"memory_id","record_version_field":"record_version","operation_key_field":"operation_key","event_content_field":"content"}',true)`, id)
if e != nil {
t.Fatal(e)
}
adds := 0
var stored map[string]any
visible := false
c := testClient(t, func(w http.ResponseWriter, r *http.Request) {
var in map[string]any
json.NewDecoder(r.Body).Decode(&in)
switch {
case strings.HasSuffix(r.URL.Path, "/search"):
result := []any{}
if visible && stored != nil {
result = append(result, map[string]any{"id": "event-1", "user_id": []string{UserID("t", "s")}, "assistant_id": []string{ProjectionAssistantID}, "memory_info": stored})
}
json.NewEncoder(w).Encode(map[string]any{"code": 0, "data": map[string]any{"result_list": result}})
case strings.HasSuffix(r.URL.Path, "/add"):
adds++
stored = in["memory_info"].(map[string]any)
fmt.Fprint(w, `{"code":0,"data":{}}`)
default:
t.Error("unexpected external operation", r.URL.Path)
http.Error(w, "unexpected", 400)
}
})
a := NewProjectionAdapter(pool, c)
record := callmemory.ProjectionRecord{Tenant: "t", Subject: "s", ID: uuid.NewString(), Kind: "EVENT", Content: "喜欢喝茶", Policy: "ALLOWED", Status: "ACTIVE", Version: 1}
_, e = a.Project(ctx, record, "", "", false)
if e == nil || adds != 1 {
t.Fatal("missing add identity not treated as unknown", e, adds)
}
_, e = a.Project(ctx, record, "", "", true)
if e == nil || adds != 1 {
t.Fatal("uncertain create duplicated", e, adds)
}
record.Status = "PENDING_DELETE"
_, e = a.Project(ctx, record, "", "", true)
if e == nil {
t.Fatal("empty search falsely proved deletion of uncertain write")
}
visible = true
record.Status = "ACTIVE"
result, e := a.Project(ctx, record, "", "", true)
if e != nil || !result.Visible || adds != 1 {
t.Fatal("failed to reconcile original event", result, e, adds)
}
}
+4
View File
@@ -17,6 +17,7 @@ import (
const SyncEvent = "conversation.viking_sync_requested"
const eligibleSQL = `SELECT EXISTS(SELECT 1 FROM subject_profile sp WHERE sp.id=$2::uuid AND sp.tenant_id=$1::uuid AND sp.status='active'
AND NOT EXISTS(SELECT 1 FROM memory_user_state mus WHERE mus.subject_id=sp.id)
AND NOT EXISTS(SELECT 1 FROM privacy_request pr WHERE pr.tenant_id=sp.tenant_id AND pr.subject_id=sp.id AND pr.request_type='DELETE')
AND EXISTS(SELECT 1 FROM consent_record cr WHERE cr.tenant_id=sp.tenant_id AND cr.subject_id=sp.id AND cr.consent_type='personalized_memory' AND cr.withdrawn_at IS NULL AND (cr.expires_at IS NULL OR cr.expires_at>now()))
AND (NOT EXISTS(SELECT 1 FROM consent_record cr WHERE cr.tenant_id=sp.tenant_id AND cr.subject_id=sp.id AND cr.consent_type='transcript_storage')
@@ -241,5 +242,8 @@ func (s *Service) DeleteSubject(ctx context.Context, tenant, subject string) err
return err
}
defer conn.Exec(context.Background(), `SELECT pg_advisory_unlock(hashtextextended($1,0))`, user)
if err := s.client.deleteManagedUser(ctx, user); err != nil {
return err
}
return s.client.DeleteUser(ctx, user)
}