diff --git a/.env.example b/.env.example index 9aad0f1..675eba0 100644 --- a/.env.example +++ b/.env.example @@ -18,6 +18,17 @@ NIVORA_SSE_HEARTBEAT=15s NIVORA_MAX_HISTORY_TURNS=12 NIVORA_MAX_QUESTION_BYTES=16384 +# Durable conversation and audit storage. +# Production should set NIVORA_STORAGE_REQUIRED=true and use PostgreSQL. +NIVORA_STORAGE_REQUIRED=false +NIVORA_STORAGE_DRIVER=sqlite +NIVORA_STORAGE_DSN=file:/var/lib/nivora/nivora.db?_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)&_pragma=foreign_keys(1) +# PostgreSQL example: +# NIVORA_STORAGE_DRIVER=pgx +# NIVORA_STORAGE_DSN=postgres://nivora:password@127.0.0.1:5432/nivora?sslmode=require +NIVORA_STORAGE_RETENTION=720h +NIVORA_STORAGE_CLEANUP_INTERVAL=1h + # Volcengine Ark through the official Eino extension. ARK_API_KEY=ark-xxxxxxxx # Ordered endpoint IDs. Nivora falls back only before streaming begins. diff --git a/README.md b/README.md index 9bd4dbb..f12cc23 100644 --- a/README.md +++ b/README.md @@ -17,11 +17,13 @@ Lumio is the first planned provider integration, but Nivora itself does not know - provider-neutral Tools for knowledge, customer context, resources, diagnosis, transactions, and human-support cases - Provider-side approved-knowledge reference service using the official Eino VikingDB retriever - tenant, approval, freshness, provenance, and score validation after semantic retrieval +- SQLite development and PostgreSQL production storage for public transcripts, run metadata, sanitized Tool audits, and support-case references +- deterministic replay protection and tenant-scoped transcript access - black-box customer-support and knowledge-retrieval JSONL evaluation tools - bounded Provider retries for idempotent reads and idempotent support-case creation - stable Server-Sent Events protocol with heartbeat comments - private service authentication between the product BFF and Nivora -- real Provider readiness checks with short caching +- real Provider and storage readiness checks with short caching - global concurrency and queue protection - Prometheus-compatible runtime metrics - loopback-first production deployment examples @@ -32,13 +34,14 @@ Lumio is the first planned provider integration, but Nivora itself does not know Browser -> Product BFF (session, tenant, brand, scopes, rate limit) -> Nivora :3100 (Eino runtime, private) + -> Nivora conversation/audit database -> Product Provider API (authorization and business truth) -> Product services and database -> approved knowledge service :3110 -> VikingDB ``` -Nivora does not accept a Provider URL from chat requests and does not connect to a product database or VikingDB. The configured Provider remains the source of truth. +Nivora does not accept a Provider URL from chat requests and does not connect to a product business database or VikingDB. The configured Provider remains the source of truth. ## Run locally @@ -54,6 +57,8 @@ Useful endpoints: curl http://127.0.0.1:3100/healthz curl -i http://127.0.0.1:3100/readyz curl http://127.0.0.1:3100/metrics +curl -H 'X-Nivora-Key: replace-with-a-long-random-secret' \ + http://127.0.0.1:3100/v1/conversations/conv-id/transcript ``` Chat requests must come from a trusted BFF. The BFF must replace browser-supplied tenant and principal data with trusted server-side values. @@ -103,6 +108,7 @@ Tool results are not forwarded to the browser. They remain inside the Agent run. - Use separate secrets for product-to-Nivora, Nivora-to-Provider, and Provider-to-knowledge authentication. - The Provider API must enforce customer ownership and redact internal fields. - Anonymous requests can receive only explicitly granted knowledge and case scopes. +- Durable storage contains public messages and sanitized audit metadata only; it never stores chain of thought, bearer contexts, Tool payloads, or product recipes. - Nivora currently performs read operations plus idempotent `case.create` only. ## Documentation @@ -111,6 +117,7 @@ Tool results are not forwarded to the browser. They remain inside the Agent run. - [Provider API v1](docs/provider-api.md) - [CozeLoop integration](docs/cozeloop.md) - [Approved VikingDB knowledge](docs/approved-knowledge.md) +- [Durable conversation storage](docs/durable-storage.md) - [Customer-support evaluation](docs/evaluation.md) - [Volcengine production stack](docs/volcengine-production-stack.md) @@ -128,6 +135,5 @@ make knowledge-eval ## Roadmap -1. Add durable conversations, audit logs, and support cases in Nivora's own storage. -2. Add the production security, load, and shadow-traffic acceptance suite. -3. Add Eino interrupt/resume for human approval of future high-risk actions. +1. Add the production security, load, and shadow-traffic acceptance suite. +2. Add Eino interrupt/resume for human approval of future high-risk actions. diff --git a/cmd/nivora/main.go b/cmd/nivora/main.go index efcdeb0..e5b30d1 100644 --- a/cmd/nivora/main.go +++ b/cmd/nivora/main.go @@ -15,8 +15,13 @@ import ( "github.com/Nesoriel/nivora/internal/agent" "github.com/Nesoriel/nivora/internal/config" + "github.com/Nesoriel/nivora/internal/conversation" + conversationhttp "github.com/Nesoriel/nivora/internal/conversation/httpapi" + "github.com/Nesoriel/nivora/internal/conversation/sqlstore" + "github.com/Nesoriel/nivora/internal/dependency" "github.com/Nesoriel/nivora/internal/model/failover" "github.com/Nesoriel/nivora/internal/promptpolicy" + "github.com/Nesoriel/nivora/internal/provider" providerhttp "github.com/Nesoriel/nivora/internal/provider/httpclient" "github.com/Nesoriel/nivora/internal/requestctx" looptrace "github.com/Nesoriel/nivora/internal/runtrace/cozeloop" @@ -31,6 +36,8 @@ func main() { logger.Error("load configuration", "error", err) os.Exit(1) } + serviceCtx, stopBackground := context.WithCancel(context.Background()) + defer stopBackground() loopRuntime, loopErr := looptrace.New(cfg.CozeLoopEnabled, logger) if loopErr != nil { @@ -40,7 +47,7 @@ func main() { var policy promptpolicy.Source = promptpolicy.Static("", "bundled-v1", "bundled") if loopRuntime.Client() != nil && cfg.CozeLoopPromptKey != "" { - remotePolicy, promptErr := promptpolicy.Remote(context.Background(), loopRuntime.Client(), promptpolicy.RemoteConfig{ + remotePolicy, promptErr := promptpolicy.Remote(serviceCtx, loopRuntime.Client(), promptpolicy.RemoteConfig{ Key: cfg.CozeLoopPromptKey, Version: cfg.CozeLoopPromptVersion, RefreshInterval: cfg.CozeLoopPromptRefresh, @@ -55,6 +62,26 @@ func main() { } } + conversationStore := conversation.Nop() + storageEnabled := cfg.StorageDriver != "" && cfg.StorageDSN != "" + if storageEnabled { + durableStore, storageErr := sqlstore.Open(serviceCtx, cfg.StorageDriver, cfg.StorageDSN) + if storageErr != nil { + logger.Error("open durable conversation store", "error", storageErr) + os.Exit(1) + } + conversationStore = durableStore + if cfg.StorageCleanupInterval > 0 { + go retentionLoop(serviceCtx, conversationStore, cfg.StorageRetention, cfg.StorageCleanupInterval, logger) + } + logger.Info("durable conversation storage configured", "driver", cfg.StorageDriver) + } else if cfg.StorageRequired { + logger.Error("durable conversation storage is required but not configured") + os.Exit(1) + } else { + logger.Warn("durable conversation storage is disabled; production acceptance requires enabling it") + } + providerClient, err := providerhttp.New( cfg.ProviderBaseURL, cfg.ProviderSharedSecret, @@ -65,13 +92,22 @@ func main() { logger.Error("create provider client", "error", err) os.Exit(1) } + var runtimeProvider provider.Provider = providerClient + if storageEnabled { + recordedProvider, recordErr := conversation.NewProviderRecorder(runtimeProvider, conversationStore, cfg.TenantID) + if recordErr != nil { + logger.Error("create Provider audit recorder", "error", recordErr) + os.Exit(1) + } + runtimeProvider = recordedProvider + } - var runtime *agent.Service + var streamer conversation.Streamer if cfg.ArkAPIKey != "" && len(cfg.ArkModels) > 0 { models := make([]einomodel.ToolCallingChatModel, 0, len(cfg.ArkModels)) for _, modelID := range cfg.ArkModels { modelTimeout := cfg.RequestTimeout - chatModel, modelErr := ark.NewChatModel(context.Background(), &ark.ChatModelConfig{ + chatModel, modelErr := ark.NewChatModel(serviceCtx, &ark.ChatModelConfig{ APIKey: cfg.ArkAPIKey, Model: modelID, BaseURL: cfg.ArkBaseURL, @@ -92,24 +128,40 @@ func main() { os.Exit(1) } } - runtime, err = agent.New( + runtime, runtimeErr := agent.New( runtimeModel, - providerClient, + runtimeProvider, agent.WithPolicySource(policy), agent.WithTracer(loopRuntime.Tracer()), agent.WithBuildInfo(cfg.Version, cfg.Commit), ) - if err != nil { - logger.Error("create agent runtime", "error", err) + if runtimeErr != nil { + logger.Error("create agent runtime", "error", runtimeErr) os.Exit(1) } + streamer = runtime + if storageEnabled { + recorder, recordErr := conversation.NewRecorder(runtime, conversationStore, cfg.Version, cfg.Commit, func() (string, string) { + snapshot := policy.Current() + return snapshot.Version, snapshot.Source + }) + if recordErr != nil { + logger.Error("create durable run recorder", "error", recordErr) + os.Exit(1) + } + streamer = recorder + } logger.Info("Ark runtime configured", "model_endpoints", len(models)) } else { logger.Warn("Ark model is not configured; health endpoints are available but readiness will fail") } + checkers := []dependency.Checker{providerClient} + if storageEnabled { + checkers = append(checkers, conversationStore) + } metrics := telemetry.New() - transport := httpserver.New(cfg, runtime, providerClient, metrics, logger) + transport := httpserver.New(cfg, streamer, dependency.New(checkers...), metrics, logger) root := http.NewServeMux() root.HandleFunc("GET /version", func(w http.ResponseWriter, _ *http.Request) { snapshot := policy.Current() @@ -121,8 +173,18 @@ func main() { "prompt_version": snapshot.Version, "prompt_source": snapshot.Source, "cozeloop_enabled": cfg.CozeLoopEnabled && loopRuntime.Client() != nil, + "storage_enabled": storageEnabled, + "storage_driver": cfg.StorageDriver, }) }) + if storageEnabled { + transcriptAPI, transcriptErr := conversationhttp.New(conversationStore, cfg.TenantID, cfg.SharedSecret) + if transcriptErr != nil { + logger.Error("create transcript API", "error", transcriptErr) + os.Exit(1) + } + transcriptAPI.Register(root) + } root.Handle("/", requestctx.Middleware(transport.Handler())) server := &http.Server{ @@ -144,6 +206,7 @@ func main() { }() <-shutdown + stopBackground() ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) defer cancel() if err := server.Shutdown(ctx); err != nil { @@ -152,5 +215,39 @@ func main() { } policy.Close() loopRuntime.Close(ctx) + if err := conversationStore.Close(); err != nil { + logger.Error("close conversation store", "error", err) + os.Exit(1) + } logger.Info("Nivora stopped") } + +func retentionLoop(ctx context.Context, store conversation.Store, retention, interval time.Duration, logger *slog.Logger) { + run := func() { + result, err := store.DeleteBefore(ctx, time.Now().UTC().Add(-retention)) + if err != nil { + if ctx.Err() == nil { + logger.Error("conversation retention cleanup failed", "error", err) + } + return + } + logger.Info("conversation retention cleanup completed", + "runs", result.Runs, + "messages", result.Messages, + "tool_audits", result.ToolAudits, + "support_cases", result.SupportCases, + "conversations", result.Conversations, + ) + } + run() + ticker := time.NewTicker(interval) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + run() + } + } +} diff --git a/docs/durable-storage.md b/docs/durable-storage.md new file mode 100644 index 0000000..b74f790 --- /dev/null +++ b/docs/durable-storage.md @@ -0,0 +1,86 @@ +# Durable conversation and audit storage + +Nivora owns customer-support conversation state, but never reads or copies a product's business database. Product facts remain behind the Provider API. + +## Stored data + +- conversation IDs and tenant IDs; +- public user questions and final assistant answers; +- Agent run status, Nivora version/commit, and active Prompt version/source; +- sanitized Tool lifecycle records containing only Tool name, Tool Call ID, status, and timestamps; +- Provider support-case ID and status for human handoff. + +Nivora never persists: + +- model chain of thought or hidden reasoning; +- bearer contexts, API keys, or service secrets; +- Tool arguments or unrestricted Tool results; +- raw Provider payloads; +- product-internal prompts, generation recipes, or other customers' data. + +## Drivers + +Development and single-node testing may use SQLite: + +```env +NIVORA_STORAGE_DRIVER=sqlite +NIVORA_STORAGE_DSN=file:/var/lib/nivora/nivora.db?_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)&_pragma=foreign_keys(1) +``` + +Production should use PostgreSQL: + +```env +NIVORA_STORAGE_DRIVER=pgx +NIVORA_STORAGE_DSN=postgres://nivora:password@db.internal:5432/nivora?sslmode=require +NIVORA_STORAGE_REQUIRED=true +``` + +When storage is enabled it participates in `/readyz`. When `NIVORA_STORAGE_REQUIRED=true`, missing storage configuration prevents startup readiness. + +## Idempotency + +- `request_id` is the primary key for an Agent run. +- user and assistant message IDs are deterministic: `:user` and `:assistant`. +- Tool audits use `(request_id, tool_call_id)`. +- Provider case references use `(tenant_id, provider_case_id)`. +- replaying the same identity is a no-op; +- reusing an identity for another tenant or conversation returns an idempotency conflict. + +Provider `case.create` remains protected by its separate stable idempotency key. If the Provider creates a case but the local audit write fails, the customer run fails closed; a retry must return the same Provider case before the local reference is recorded. + +## Tenant isolation + +Every transcript query requires both the configured tenant ID and conversation ID. The private API does not accept a browser-selected tenant: + +```http +GET /v1/conversations/{conversation_id}/transcript +X-Nivora-Key: +``` + +Only public user/assistant messages are returned. Tool audit records and Provider payloads are never part of the transcript. + +## Retention + +```env +NIVORA_STORAGE_RETENTION=720h +NIVORA_STORAGE_CLEANUP_INTERVAL=1h +``` + +Cleanup is transactional and restart-safe. It removes completed runs, public messages, Tool audits, old support-case references, and finally conversations that no longer have retained records. + +For regulated deployments, set retention according to the product's privacy policy and legal requirements. Database backups must use the same encryption, access control, deletion, and retention policy as the live database. + +## Migration and rollback + +Schema migrations run at startup inside a transaction and are recorded in `schema_migrations`. + +Before deployment: + +1. back up the database; +2. test the migration against a production-sized copy; +3. deploy Nivora with traffic disabled; +4. verify `/readyz` and transcript reads; +5. enable shadow traffic; +6. keep the previous binary available for rollback. + +The initial schema is additive. Rolling back the binary does not require immediately deleting the new tables. diff --git a/go.mod b/go.mod index e107d7e..f855790 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/Nesoriel/nivora -go 1.23.0 +go 1.25 require ( github.com/cloudwego/eino v0.9.12 @@ -9,6 +9,8 @@ require ( github.com/cloudwego/eino-ext/components/retriever/volc_vikingdb v0.0.0-20260715135811-0910e2add6ed github.com/coze-dev/cozeloop-go v0.1.22 github.com/coze-dev/cozeloop-go/spec v0.1.8 + github.com/jackc/pgx/v5 v5.7.2 + modernc.org/sqlite v1.34.5 ) require ( @@ -25,16 +27,23 @@ require ( github.com/golang-jwt/jwt v3.2.2+incompatible // indirect github.com/google/uuid v1.6.0 // indirect github.com/goph/emperror v0.17.2 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/jmespath/go-jmespath v0.4.0 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/cpuid/v2 v2.2.9 // indirect github.com/mailru/easyjson v0.9.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/ncruces/go-strftime v0.1.9 // indirect github.com/nikolalohinski/gonja v1.5.3 // indirect github.com/nikolalohinski/gonja/v2 v2.3.1 // indirect github.com/pelletier/go-toml/v2 v2.0.9 // indirect github.com/pkg/errors v0.9.2-0.20201214064552-5dd12d0cfe7f // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + github.com/rogpeppe/go-internal v1.15.0 // indirect github.com/sirupsen/logrus v1.9.3 // indirect github.com/slongfield/pyfmt v0.0.0-20220222012616-ea85ff4c361f // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect @@ -45,6 +54,7 @@ require ( github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect github.com/yargevad/filepathx v1.0.0 // indirect golang.org/x/arch v0.11.0 // indirect + golang.org/x/crypto v0.39.0 // indirect golang.org/x/exp v0.0.0-20240404231335-c0f41cb1a7a0 // indirect golang.org/x/net v0.41.0 // indirect golang.org/x/sync v0.15.0 // indirect @@ -53,4 +63,7 @@ require ( google.golang.org/protobuf v1.31.0 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect + modernc.org/libc v1.55.3 // indirect + modernc.org/mathutil v1.6.0 // indirect + modernc.org/memory v1.8.0 // indirect ) diff --git a/go.sum b/go.sum index 8dbd84c..bb36e3e 100644 --- a/go.sum +++ b/go.sum @@ -245,8 +245,8 @@ github.com/google/pprof v0.0.0-20200212024743-f11f1df84d12/go.mod h1:ZgVRPoUq/hf github.com/google/pprof v0.0.0-20200229191704-1ebb73c60ed3/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= github.com/google/pprof v0.0.0-20200430221834-fc25d7d30c6d/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= github.com/google/pprof v0.0.0-20200708004538-1a94d8640e99/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= -github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38 h1:yAJXTCF9TqKcTiHJAE8dj7HMvPfh66eeA2JYW7eFpSE= -github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= +github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo= +github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw= github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= @@ -297,6 +297,14 @@ github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpO github.com/hudl/fargo v1.4.0/go.mod h1:9Ai6uvFy5fQNq6VPKtg+Ceq1+eTY4nKUlR2JElEOcDo= github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= github.com/influxdata/influxdb1-client v0.0.0-20200827194710-b269163b24ab/go.mod h1:qj24IKcXYK6Iy9ceXlo3Tc+vtHo9lIhSX5JddghvEPo= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.7.2 h1:mLoDLV6sonKlvjIEsV56SkWNCnuNv531l94GaIzO+XI= +github.com/jackc/pgx/v5 v5.7.2/go.mod h1:ncY89UGWxg82EykZUwSpUKEfccBGGYq1xjrOpsbsfGQ= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs= github.com/jcmturner/dnsutils/v2 v2.0.0/go.mod h1:b0TnjGOvI/n42bZa+hmXL+kFJZsFT7G4t3HTlQ184QM= github.com/jcmturner/gofork v1.0.0/go.mod h1:MK8+TM0La+2rjBD4jE12Kj1pCCxK7d2LK/UM3ncEo0o= @@ -334,8 +342,9 @@ github.com/konsorten/go-windows-terminal-sequences v1.0.3/go.mod h1:T0+1ngSBFLxv github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.2.0/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= -github.com/kr/pretty v0.2.1 h1:Fmg33tUaq4/8ym9TJN1x7sLJnHVwhP33CNkpYV/7rwI= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= +github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= @@ -353,8 +362,9 @@ github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hd github.com/mattn/go-isatty v0.0.10/go.mod h1:qgIWMr58cqv1PHHyhnkY9lrL7etaEgOFcMEpPG5Rm84= github.com/mattn/go-isatty v0.0.11/go.mod h1:PhnuNfih5lzO57/f3n+odYbM4JtupLOxQOAqxQCu2WE= github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= -github.com/mattn/go-isatty v0.0.14 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9Y= github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b h1:j7+1HpAFS1zy5+Q4qx1fWh90gTKwiN4QCGoY9TWyyO4= github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE= @@ -385,6 +395,8 @@ github.com/nats-io/nats.go v1.12.1/go.mod h1:BPko4oXsySz4aSWeFgOHLZs3G4Jq4ZAyE6/ github.com/nats-io/nkeys v0.2.0/go.mod h1:XdZpAbhgyyODYqjTawOnIOI7VlbKSarI9Gfy1tqEu/s= github.com/nats-io/nkeys v0.3.0/go.mod h1:gvUNGjVcM2IPr5rCsRsC6Wb3Hr2CQAm08dsxtV6A5y4= github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c= +github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= +github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= github.com/nikolalohinski/gonja v1.5.3 h1:GsA+EEaZDZPGJ8JtpeGN78jidhOlxeJROpqMT9fTj9c= github.com/nikolalohinski/gonja v1.5.3/go.mod h1:RmjwxNiXAEqcq1HeK5SSMmqFJvKOfTfXhkJv6YBtPa4= @@ -450,8 +462,12 @@ github.com/prometheus/procfs v0.6.0/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1 github.com/prometheus/procfs v0.7.3/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA= github.com/rcrowley/go-metrics v0.0.0-20181016184325-3113b8401b8a/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4= github.com/rcrowley/go-metrics v0.0.0-20201227073835-cf1acfcdf475/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ= github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= +github.com/rogpeppe/go-internal v1.15.0 h1:D0RCU5rMAp+SpgkiNdrjfJ+LX4J1M32V2NeCY7EJ6hc= +github.com/rogpeppe/go-internal v1.15.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs= github.com/rollbar/rollbar-go v1.0.2/go.mod h1:AcFs5f0I+c71bpHlXNNDbOWJiKwjFDtISeXco0L5PKQ= github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts= @@ -601,6 +617,8 @@ golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w= +golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -730,6 +748,7 @@ golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= @@ -940,6 +959,30 @@ honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWh honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= +modernc.org/cc/v4 v4.21.4 h1:3Be/Rdo1fpr8GrQ7IVw9OHtplU4gWbb+wNgeoBMmGLQ= +modernc.org/cc/v4 v4.21.4/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ= +modernc.org/ccgo/v4 v4.19.2 h1:lwQZgvboKD0jBwdaeVCTouxhxAyN6iawF3STraAal8Y= +modernc.org/ccgo/v4 v4.19.2/go.mod h1:ysS3mxiMV38XGRTTcgo0DQTeTmAO4oCmJl1nX9VFI3s= +modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE= +modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ= +modernc.org/gc/v2 v2.4.1 h1:9cNzOqPyMJBvrUipmynX0ZohMhcxPtMccYgGOJdOiBw= +modernc.org/gc/v2 v2.4.1/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU= +modernc.org/libc v1.55.3 h1:AzcW1mhlPNrRtjS5sS+eW2ISCgSOLLNyFzRh/V3Qj/U= +modernc.org/libc v1.55.3/go.mod h1:qFXepLhz+JjFThQ4kzwzOjA/y/artDeg+pcYnY+Q83w= +modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4= +modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo= +modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E= +modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU= +modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4= +modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0= +modernc.org/sortutil v1.2.0 h1:jQiD3PfS2REGJNzNCMMaLSp/wdMNieTbKX920Cqdgqc= +modernc.org/sortutil v1.2.0/go.mod h1:TKU2s7kJMf1AE84OoiGppNHJwvB753OYfNl2WRb++Ss= +modernc.org/sqlite v1.34.5 h1:Bb6SR13/fjp15jt70CL4f18JIN7p7dnMExd+UFnF15g= +modernc.org/sqlite v1.34.5/go.mod h1:YLuNmX9NKs8wRNK2ko1LW1NGYcc9FkBO69JOt1AR9JE= +modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA= +modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8= rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0= diff --git a/internal/config/config.go b/internal/config/config.go index 7676a2e..7a1b9f0 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -11,32 +11,37 @@ import ( // Config contains the runtime configuration for Nivora. type Config struct { - Address string - SharedSecret string - TenantID string - ProviderBaseURL string - ProviderSharedSecret string - ProviderMaxRetries int - ProviderRetryBackoff time.Duration - ArkAPIKey string - ArkModels []string - ArkBaseURL string - RequestTimeout time.Duration - ProviderTimeout time.Duration - ReadinessTimeout time.Duration - ReadinessCacheTTL time.Duration - QueueTimeout time.Duration - SSEHeartbeat time.Duration - MaxConcurrentRuns int - MaxHistoryTurns int - MaxQuestionBytes int - CozeLoopEnabled bool - CozeLoopPromptKey string - CozeLoopPromptVersion string - CozeLoopPromptRefresh time.Duration - CozeLoopPromptTimeout time.Duration - Version string - Commit string + Address string + SharedSecret string + TenantID string + ProviderBaseURL string + ProviderSharedSecret string + ProviderMaxRetries int + ProviderRetryBackoff time.Duration + ArkAPIKey string + ArkModels []string + ArkBaseURL string + RequestTimeout time.Duration + ProviderTimeout time.Duration + ReadinessTimeout time.Duration + ReadinessCacheTTL time.Duration + QueueTimeout time.Duration + SSEHeartbeat time.Duration + MaxConcurrentRuns int + MaxHistoryTurns int + MaxQuestionBytes int + CozeLoopEnabled bool + CozeLoopPromptKey string + CozeLoopPromptVersion string + CozeLoopPromptRefresh time.Duration + CozeLoopPromptTimeout time.Duration + StorageDriver string + StorageDSN string + StorageRequired bool + StorageRetention time.Duration + StorageCleanupInterval time.Duration + Version string + Commit string } // Load reads configuration from environment variables. @@ -47,32 +52,37 @@ func Load() (Config, error) { } cfg := Config{ - Address: env("NIVORA_ADDR", "127.0.0.1:3100"), - TenantID: env("NIVORA_TENANT_ID", "lumio"), - ProviderBaseURL: strings.TrimRight(env("NIVORA_PROVIDER_BASE_URL", "http://127.0.0.1:3000"), "/"), - ArkBaseURL: strings.TrimRight(env("ARK_BASE_URL", "https://ark.cn-beijing.volces.com/api/v3"), "/"), - RequestTimeout: durationEnv("NIVORA_REQUEST_TIMEOUT", 90*time.Second), - ProviderTimeout: durationEnv("NIVORA_PROVIDER_TIMEOUT", 10*time.Second), - ReadinessTimeout: durationEnv("NIVORA_READINESS_TIMEOUT", 2*time.Second), - ReadinessCacheTTL: durationEnv("NIVORA_READINESS_CACHE_TTL", 10*time.Second), - QueueTimeout: durationEnv("NIVORA_QUEUE_TIMEOUT", 2*time.Second), - SSEHeartbeat: durationEnv("NIVORA_SSE_HEARTBEAT", 15*time.Second), - ProviderRetryBackoff: durationEnv("NIVORA_PROVIDER_RETRY_BACKOFF", 150*time.Millisecond), - ProviderMaxRetries: intEnv("NIVORA_PROVIDER_MAX_RETRIES", 2), - MaxConcurrentRuns: intEnv("NIVORA_MAX_CONCURRENT_RUNS", 4), - MaxHistoryTurns: intEnv("NIVORA_MAX_HISTORY_TURNS", 12), - MaxQuestionBytes: intEnv("NIVORA_MAX_QUESTION_BYTES", 16*1024), - SharedSecret: strings.TrimSpace(os.Getenv("NIVORA_SHARED_SECRET")), - ProviderSharedSecret: strings.TrimSpace(os.Getenv("NIVORA_PROVIDER_SHARED_SECRET")), - ArkAPIKey: strings.TrimSpace(os.Getenv("ARK_API_KEY")), - ArkModels: models, - CozeLoopEnabled: boolEnv("NIVORA_COZELOOP_ENABLED", false), - CozeLoopPromptKey: strings.TrimSpace(os.Getenv("NIVORA_COZELOOP_PROMPT_KEY")), - CozeLoopPromptVersion: strings.TrimSpace(os.Getenv("NIVORA_COZELOOP_PROMPT_VERSION")), - CozeLoopPromptRefresh: durationEnv("NIVORA_COZELOOP_PROMPT_REFRESH", 5*time.Minute), - CozeLoopPromptTimeout: durationEnv("NIVORA_COZELOOP_PROMPT_TIMEOUT", 3*time.Second), - Version: env("NIVORA_VERSION", "dev"), - Commit: env("NIVORA_COMMIT", "unknown"), + Address: env("NIVORA_ADDR", "127.0.0.1:3100"), + TenantID: env("NIVORA_TENANT_ID", "lumio"), + ProviderBaseURL: strings.TrimRight(env("NIVORA_PROVIDER_BASE_URL", "http://127.0.0.1:3000"), "/"), + ArkBaseURL: strings.TrimRight(env("ARK_BASE_URL", "https://ark.cn-beijing.volces.com/api/v3"), "/"), + RequestTimeout: durationEnv("NIVORA_REQUEST_TIMEOUT", 90*time.Second), + ProviderTimeout: durationEnv("NIVORA_PROVIDER_TIMEOUT", 10*time.Second), + ReadinessTimeout: durationEnv("NIVORA_READINESS_TIMEOUT", 2*time.Second), + ReadinessCacheTTL: durationEnv("NIVORA_READINESS_CACHE_TTL", 10*time.Second), + QueueTimeout: durationEnv("NIVORA_QUEUE_TIMEOUT", 2*time.Second), + SSEHeartbeat: durationEnv("NIVORA_SSE_HEARTBEAT", 15*time.Second), + ProviderRetryBackoff: durationEnv("NIVORA_PROVIDER_RETRY_BACKOFF", 150*time.Millisecond), + ProviderMaxRetries: intEnv("NIVORA_PROVIDER_MAX_RETRIES", 2), + MaxConcurrentRuns: intEnv("NIVORA_MAX_CONCURRENT_RUNS", 4), + MaxHistoryTurns: intEnv("NIVORA_MAX_HISTORY_TURNS", 12), + MaxQuestionBytes: intEnv("NIVORA_MAX_QUESTION_BYTES", 16*1024), + SharedSecret: strings.TrimSpace(os.Getenv("NIVORA_SHARED_SECRET")), + ProviderSharedSecret: strings.TrimSpace(os.Getenv("NIVORA_PROVIDER_SHARED_SECRET")), + ArkAPIKey: strings.TrimSpace(os.Getenv("ARK_API_KEY")), + ArkModels: models, + CozeLoopEnabled: boolEnv("NIVORA_COZELOOP_ENABLED", false), + CozeLoopPromptKey: strings.TrimSpace(os.Getenv("NIVORA_COZELOOP_PROMPT_KEY")), + CozeLoopPromptVersion: strings.TrimSpace(os.Getenv("NIVORA_COZELOOP_PROMPT_VERSION")), + CozeLoopPromptRefresh: durationEnv("NIVORA_COZELOOP_PROMPT_REFRESH", 5*time.Minute), + CozeLoopPromptTimeout: durationEnv("NIVORA_COZELOOP_PROMPT_TIMEOUT", 3*time.Second), + StorageDriver: strings.ToLower(strings.TrimSpace(os.Getenv("NIVORA_STORAGE_DRIVER"))), + StorageDSN: strings.TrimSpace(os.Getenv("NIVORA_STORAGE_DSN")), + StorageRequired: boolEnv("NIVORA_STORAGE_REQUIRED", false), + StorageRetention: durationEnv("NIVORA_STORAGE_RETENTION", 30*24*time.Hour), + StorageCleanupInterval: durationEnv("NIVORA_STORAGE_CLEANUP_INTERVAL", time.Hour), + Version: env("NIVORA_VERSION", "dev"), + Commit: env("NIVORA_COMMIT", "unknown"), } if cfg.Address == "" { @@ -102,12 +112,25 @@ func Load() (Config, error) { if cfg.ReadinessCacheTTL < 0 || cfg.QueueTimeout < 0 || cfg.SSEHeartbeat < 0 || cfg.ProviderRetryBackoff < 0 || cfg.CozeLoopPromptRefresh < 0 { return Config{}, fmt.Errorf("cache, queue, heartbeat, retry, and prompt refresh durations must not be negative") } + if cfg.StorageRequired && (cfg.StorageDriver == "" || cfg.StorageDSN == "") { + return Config{}, errors.New("NIVORA_STORAGE_DRIVER and NIVORA_STORAGE_DSN are required when durable storage is required") + } + if (cfg.StorageDriver == "") != (cfg.StorageDSN == "") { + return Config{}, errors.New("NIVORA_STORAGE_DRIVER and NIVORA_STORAGE_DSN must be configured together") + } + if cfg.StorageDriver != "" && cfg.StorageDriver != "sqlite" && cfg.StorageDriver != "pgx" && cfg.StorageDriver != "postgres" && cfg.StorageDriver != "postgresql" { + return Config{}, errors.New("NIVORA_STORAGE_DRIVER must be sqlite or pgx") + } + if cfg.StorageRetention <= 0 || cfg.StorageCleanupInterval < 0 { + return Config{}, errors.New("storage retention must be positive and cleanup interval must not be negative") + } return cfg, nil } // Ready reports whether the service has enough configuration to accept chat requests. func (c Config) Ready() bool { - return c.SharedSecret != "" && c.ProviderSharedSecret != "" && c.ArkAPIKey != "" && len(c.ArkModels) > 0 && c.ProviderBaseURL != "" + storageReady := !c.StorageRequired || (c.StorageDriver != "" && c.StorageDSN != "") + return storageReady && c.SharedSecret != "" && c.ProviderSharedSecret != "" && c.ArkAPIKey != "" && len(c.ArkModels) > 0 && c.ProviderBaseURL != "" } func env(name, fallback string) string { diff --git a/internal/conversation/httpapi/server.go b/internal/conversation/httpapi/server.go new file mode 100644 index 0000000..2916d4c --- /dev/null +++ b/internal/conversation/httpapi/server.go @@ -0,0 +1,71 @@ +package httpapi + +import ( + "crypto/subtle" + "encoding/json" + "errors" + "net/http" + "strings" + + "github.com/Nesoriel/nivora/internal/conversation" +) + +// Server exposes support-safe transcripts to trusted internal operators. +type Server struct { + store conversation.Store + tenantID string + secret string +} + +// New creates a private transcript API. +func New(store conversation.Store, tenantID, secret string) (*Server, error) { + if store == nil { + return nil, errors.New("conversation store is required") + } + tenantID = strings.TrimSpace(tenantID) + secret = strings.TrimSpace(secret) + if tenantID == "" || secret == "" { + return nil, errors.New("tenant and service secret are required") + } + return &Server{store: store, tenantID: tenantID, secret: secret}, nil +} + +// Register adds private conversation routes to an existing ServeMux. +func (s *Server) Register(mux *http.ServeMux) { + mux.HandleFunc("GET /v1/conversations/{conversation_id}/transcript", s.transcript) +} + +func (s *Server) transcript(w http.ResponseWriter, request *http.Request) { + if !constantTimeEqual(request.Header.Get("X-Nivora-Key"), s.secret) { + writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "unauthorized"}) + return + } + conversationID := strings.TrimSpace(request.PathValue("conversation_id")) + if conversationID == "" || len(conversationID) > 256 { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid_conversation_id"}) + return + } + messages, err := s.store.Transcript(request.Context(), s.tenantID, conversationID) + if err != nil { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "conversation_store_unavailable"}) + return + } + writeJSON(w, http.StatusOK, map[string]any{ + "tenant_id": s.tenantID, + "conversation_id": conversationID, + "messages": messages, + }) +} + +func constantTimeEqual(got, expected string) bool { + if got == "" || expected == "" || len(got) != len(expected) { + return false + } + return subtle.ConstantTimeCompare([]byte(got), []byte(expected)) == 1 +} + +func writeJSON(w http.ResponseWriter, status int, value any) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(value) +} diff --git a/internal/conversation/httpapi/server_test.go b/internal/conversation/httpapi/server_test.go new file mode 100644 index 0000000..726b718 --- /dev/null +++ b/internal/conversation/httpapi/server_test.go @@ -0,0 +1,69 @@ +package httpapi_test + +import ( + "context" + "net/http" + "net/http/httptest" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/Nesoriel/nivora/internal/conversation" + conversationhttp "github.com/Nesoriel/nivora/internal/conversation/httpapi" + "github.com/Nesoriel/nivora/internal/conversation/sqlstore" +) + +func TestTranscriptAPIRequiresInternalKeyAndUsesConfiguredTenant(t *testing.T) { + store := openStore(t) + ctx := context.Background() + now := time.Now().UTC() + if err := store.BeginRun(ctx, conversation.RunRecord{RequestID: "req-1", TenantID: "tenant-a", ConversationID: "conv-1", StartedAt: now}); err != nil { + t.Fatal(err) + } + if err := store.AppendMessage(ctx, conversation.MessageRecord{MessageID: "msg-1", RequestID: "req-1", TenantID: "tenant-a", ConversationID: "conv-1", Role: "user", Content: "hello", CreatedAt: now}); err != nil { + t.Fatal(err) + } + api, err := conversationhttp.New(store, "tenant-a", "secret") + if err != nil { + t.Fatal(err) + } + mux := http.NewServeMux() + api.Register(mux) + + unauthorized := httptest.NewRecorder() + mux.ServeHTTP(unauthorized, httptest.NewRequest(http.MethodGet, "/v1/conversations/conv-1/transcript", nil)) + if unauthorized.Code != http.StatusUnauthorized { + t.Fatalf("expected 401, got %d", unauthorized.Code) + } + + request := httptest.NewRequest(http.MethodGet, "/v1/conversations/conv-1/transcript", nil) + request.Header.Set("X-Nivora-Key", "secret") + response := httptest.NewRecorder() + mux.ServeHTTP(response, request) + if response.Code != http.StatusOK { + t.Fatalf("expected 200, got %d body=%s", response.Code, response.Body.String()) + } + if body := response.Body.String(); !containsAll(body, `"tenant_id":"tenant-a"`, `"content":"hello"`) { + t.Fatalf("unexpected response: %s", body) + } +} + +func openStore(t *testing.T) *sqlstore.Store { + t.Helper() + store, err := sqlstore.Open(context.Background(), "sqlite", "file:"+filepath.Join(t.TempDir(), "transcript.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = store.Close() }) + return store +} + +func containsAll(value string, fragments ...string) bool { + for _, fragment := range fragments { + if !strings.Contains(value, fragment) { + return false + } + } + return true +} diff --git a/internal/conversation/provider.go b/internal/conversation/provider.go new file mode 100644 index 0000000..3a9d56d --- /dev/null +++ b/internal/conversation/provider.go @@ -0,0 +1,82 @@ +package conversation + +import ( + "context" + "errors" + "strings" + "time" + + "github.com/Nesoriel/nivora/internal/domain" + "github.com/Nesoriel/nivora/internal/provider" +) + +// ProviderRecorder decorates a product Provider and records only successful +// support-case references. All read operations remain transparent. +type ProviderRecorder struct { + next provider.Provider + store Store + tenantID string + now func() time.Time +} + +// NewProviderRecorder creates a Provider audit decorator. +func NewProviderRecorder(next provider.Provider, store Store, tenantID string) (*ProviderRecorder, error) { + if next == nil { + return nil, errors.New("next provider is required") + } + if store == nil { + return nil, errors.New("conversation store is required") + } + tenantID = strings.TrimSpace(tenantID) + if tenantID == "" { + return nil, errors.New("tenant ID is required") + } + return &ProviderRecorder{next: next, store: store, tenantID: tenantID, now: time.Now}, nil +} + +func (p *ProviderRecorder) Capabilities(ctx context.Context, auth provider.RequestAuth) (domain.CapabilitySet, error) { + return p.next.Capabilities(ctx, auth) +} + +func (p *ProviderRecorder) CustomerContext(ctx context.Context, auth provider.RequestAuth) (domain.CustomerContext, error) { + return p.next.CustomerContext(ctx, auth) +} + +func (p *ProviderRecorder) SearchKnowledge(ctx context.Context, auth provider.RequestAuth, query string, limit int) ([]domain.KnowledgeItem, error) { + return p.next.SearchKnowledge(ctx, auth, query, limit) +} + +func (p *ProviderRecorder) ListResources(ctx context.Context, auth provider.RequestAuth, limit int, status string) ([]domain.Resource, error) { + return p.next.ListResources(ctx, auth, limit, status) +} + +func (p *ProviderRecorder) DiagnoseResource(ctx context.Context, auth provider.RequestAuth, resourceID string) (domain.Diagnosis, error) { + return p.next.DiagnoseResource(ctx, auth, resourceID) +} + +func (p *ProviderRecorder) ListTransactions(ctx context.Context, auth provider.RequestAuth, resourceID string, limit int) ([]domain.Transaction, error) { + return p.next.ListTransactions(ctx, auth, resourceID, limit) +} + +func (p *ProviderRecorder) CreateCase(ctx context.Context, auth provider.RequestAuth, input domain.CreateCaseInput) (domain.SupportCase, error) { + result, err := p.next.CreateCase(ctx, auth, input) + if err != nil { + return domain.SupportCase{}, err + } + now := p.now().UTC() + createdAt := result.CreatedAt + if createdAt.IsZero() { + createdAt = now + } + if err := p.store.RecordSupportCase(ctx, SupportCaseRecord{ + TenantID: p.tenantID, + ConversationID: input.ConversationID, + ProviderCaseID: result.ID, + Status: result.Status, + CreatedAt: createdAt, + UpdatedAt: now, + }); err != nil { + return domain.SupportCase{}, err + } + return result, nil +} diff --git a/internal/conversation/provider_test.go b/internal/conversation/provider_test.go new file mode 100644 index 0000000..8f03a07 --- /dev/null +++ b/internal/conversation/provider_test.go @@ -0,0 +1,59 @@ +package conversation + +import ( + "context" + "testing" + "time" + + "github.com/Nesoriel/nivora/internal/domain" + "github.com/Nesoriel/nivora/internal/provider" +) + +type caseProvider struct{} + +func (caseProvider) Capabilities(context.Context, provider.RequestAuth) (domain.CapabilitySet, error) { + return domain.CapabilitySet{}, nil +} +func (caseProvider) CustomerContext(context.Context, provider.RequestAuth) (domain.CustomerContext, error) { + return domain.CustomerContext{}, nil +} +func (caseProvider) SearchKnowledge(context.Context, provider.RequestAuth, string, int) ([]domain.KnowledgeItem, error) { + return nil, nil +} +func (caseProvider) ListResources(context.Context, provider.RequestAuth, int, string) ([]domain.Resource, error) { + return nil, nil +} +func (caseProvider) DiagnoseResource(context.Context, provider.RequestAuth, string) (domain.Diagnosis, error) { + return domain.Diagnosis{}, nil +} +func (caseProvider) ListTransactions(context.Context, provider.RequestAuth, string, int) ([]domain.Transaction, error) { + return nil, nil +} +func (caseProvider) CreateCase(context.Context, provider.RequestAuth, domain.CreateCaseInput) (domain.SupportCase, error) { + return domain.SupportCase{ID: "case-42", Status: "open", CreatedAt: time.Date(2026, 7, 16, 10, 0, 0, 0, time.UTC)}, nil +} + +type caseStore struct { + Store + record SupportCaseRecord +} + +func (s *caseStore) RecordSupportCase(_ context.Context, record SupportCaseRecord) error { + s.record = record + return nil +} + +func TestProviderRecorderStoresCaseReference(t *testing.T) { + store := &caseStore{Store: Nop()} + recorder, err := NewProviderRecorder(caseProvider{}, store, "tenant-a") + if err != nil { + t.Fatal(err) + } + result, err := recorder.CreateCase(context.Background(), provider.RequestAuth{}, domain.CreateCaseInput{ConversationID: "conv-1"}) + if err != nil { + t.Fatal(err) + } + if result.ID != "case-42" || store.record.ProviderCaseID != "case-42" || store.record.ConversationID != "conv-1" || store.record.TenantID != "tenant-a" { + t.Fatalf("unexpected case audit: result=%#v record=%#v", result, store.record) + } +} diff --git a/internal/conversation/recorder.go b/internal/conversation/recorder.go new file mode 100644 index 0000000..7fa9100 --- /dev/null +++ b/internal/conversation/recorder.go @@ -0,0 +1,173 @@ +package conversation + +import ( + "context" + "errors" + "strings" + "time" + + "github.com/Nesoriel/nivora/internal/domain" + "github.com/Nesoriel/nivora/internal/provider" + "github.com/Nesoriel/nivora/internal/requestctx" +) + +// Streamer is the Agent runtime surface decorated by Recorder. +type Streamer interface { + Stream(context.Context, domain.ChatRequest, provider.RequestAuth, func(domain.StreamEvent) error) error +} + +// PromptMetadata returns the active approved Prompt metadata. +type PromptMetadata func() (version, source string) + +// Recorder persists public messages and sanitized operational audit records. +type Recorder struct { + next Streamer + store Store + version string + commit string + promptMeta PromptMetadata + now func() time.Time +} + +// NewRecorder creates a durable Streamer decorator. +func NewRecorder(next Streamer, store Store, version, commit string, promptMeta PromptMetadata) (*Recorder, error) { + if next == nil { + return nil, errors.New("next streamer is required") + } + if store == nil { + return nil, errors.New("conversation store is required") + } + if promptMeta == nil { + promptMeta = func() (string, string) { return "unknown", "unknown" } + } + return &Recorder{ + next: next, + store: store, + version: strings.TrimSpace(version), + commit: strings.TrimSpace(commit), + promptMeta: promptMeta, + now: time.Now, + }, nil +} + +// Stream records the run before exposing output. Persistence failures fail the +// run closed so production traffic is never served without its required audit. +func (r *Recorder) Stream(ctx context.Context, request domain.ChatRequest, auth provider.RequestAuth, emit func(domain.StreamEvent) error) (runErr error) { + requestID := strings.TrimSpace(requestctx.RequestID(ctx)) + if requestID == "" { + requestID = request.ConversationID + ":run" + } + startedAt := r.now().UTC() + promptVersion, promptSource := r.promptMeta() + if err := r.store.BeginRun(ctx, RunRecord{ + RequestID: requestID, + TenantID: request.Tenant.ID, + ConversationID: request.ConversationID, + Authenticated: request.Principal.Authenticated, + ScopeCount: len(request.Principal.Scopes), + NivoraVersion: r.version, + NivoraCommit: r.commit, + PromptVersion: promptVersion, + PromptSource: promptSource, + StartedAt: startedAt, + }); err != nil { + return err + } + if err := r.store.AppendMessage(ctx, MessageRecord{ + MessageID: requestID + ":user", + RequestID: requestID, + TenantID: request.Tenant.ID, + ConversationID: request.ConversationID, + Role: "user", + Content: request.Question, + CreatedAt: startedAt, + }); err != nil { + _ = r.store.FinishRun(ctx, RunFinish{RequestID: requestID, Status: "failed", ErrorCode: "storage_write_failed", FinishedAt: r.now().UTC()}) + return err + } + + var assistant strings.Builder + seenToolStart := make(map[string]time.Time) + wrappedEmit := func(event domain.StreamEvent) error { + now := r.now().UTC() + switch event.Type { + case "message.delta": + assistant.WriteString(event.Content) + case "tool.started": + seenToolStart[event.ToolCallID] = now + if err := r.store.ToolStarted(ctx, ToolAuditRecord{ + RequestID: requestID, + TenantID: request.Tenant.ID, + ConversationID: request.ConversationID, + ToolCallID: event.ToolCallID, + ToolName: event.ToolName, + Status: "started", + StartedAt: now, + }); err != nil { + return err + } + case "tool.finished": + started := seenToolStart[event.ToolCallID] + if started.IsZero() { + started = now + } + if err := r.store.ToolFinished(ctx, ToolAuditRecord{ + RequestID: requestID, + TenantID: request.Tenant.ID, + ConversationID: request.ConversationID, + ToolCallID: event.ToolCallID, + ToolName: event.ToolName, + Status: firstNonEmpty(event.AuditStatus, "finished"), + ReferenceID: event.AuditReferenceID, + StartedAt: started, + FinishedAt: now, + }); err != nil { + return err + } + } + return emit(event) + } + + runErr = r.next.Stream(ctx, request, auth, wrappedEmit) + finishedAt := r.now().UTC() + answer := strings.TrimSpace(assistant.String()) + if answer != "" { + if err := r.store.AppendMessage(ctx, MessageRecord{ + MessageID: requestID + ":assistant", + RequestID: requestID, + TenantID: request.Tenant.ID, + ConversationID: request.ConversationID, + Role: "assistant", + Content: answer, + CreatedAt: finishedAt, + }); err != nil { + if runErr == nil { + runErr = err + } + } + } + status := "completed" + errorCode := "" + if runErr != nil { + status = "failed" + errorCode = "agent_run_failed" + } + if err := r.store.FinishRun(ctx, RunFinish{ + RequestID: requestID, + Status: status, + ErrorCode: errorCode, + FinishedAt: finishedAt, + }); err != nil && runErr == nil { + runErr = err + } + return runErr +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + if value = strings.TrimSpace(value); value != "" { + return value + } + } + return "" +} diff --git a/internal/conversation/recorder_test.go b/internal/conversation/recorder_test.go new file mode 100644 index 0000000..bc326ea --- /dev/null +++ b/internal/conversation/recorder_test.go @@ -0,0 +1,76 @@ +package conversation_test + +import ( + "context" + "path/filepath" + "testing" + + "github.com/Nesoriel/nivora/internal/conversation" + "github.com/Nesoriel/nivora/internal/conversation/sqlstore" + "github.com/Nesoriel/nivora/internal/domain" + "github.com/Nesoriel/nivora/internal/provider" + "github.com/Nesoriel/nivora/internal/requestctx" +) + +type fakeStreamer struct{} + +func (fakeStreamer) Stream(_ context.Context, _ domain.ChatRequest, _ provider.RequestAuth, emit func(domain.StreamEvent) error) error { + if err := emit(domain.StreamEvent{Type: "tool.started", ToolCallID: "call-1", ToolName: "search_knowledge"}); err != nil { + return err + } + if err := emit(domain.StreamEvent{Type: "tool.finished", ToolCallID: "call-1", ToolName: "search_knowledge"}); err != nil { + return err + } + if err := emit(domain.StreamEvent{Type: "message.delta", Content: "verified "}); err != nil { + return err + } + if err := emit(domain.StreamEvent{Type: "message.delta", Content: "answer"}); err != nil { + return err + } + return emit(domain.StreamEvent{Type: "done"}) +} + +func TestRecorderPersistsOnlyPublicMessages(t *testing.T) { + store := openStore(t) + recorder, err := conversation.NewRecorder(fakeStreamer{}, store, "v1", "abc", func() (string, string) { + return "prompt-v2", "cozeloop" + }) + if err != nil { + t.Fatal(err) + } + ctx := requestctx.WithRequestID(context.Background(), "req-1") + request := domain.ChatRequest{ + Question: "customer question", + ConversationID: "conv-1", + Tenant: domain.TenantContext{ID: "tenant-a"}, + Principal: domain.Principal{Scopes: []string{domain.ScopeKnowledgeRead}}, + } + var forwarded []domain.StreamEvent + if err := recorder.Stream(ctx, request, provider.RequestAuth{}, func(event domain.StreamEvent) error { + forwarded = append(forwarded, event) + return nil + }); err != nil { + t.Fatal(err) + } + transcript, err := store.Transcript(context.Background(), "tenant-a", "conv-1") + if err != nil { + t.Fatal(err) + } + if len(transcript) != 2 || transcript[0].Content != "customer question" || transcript[1].Content != "verified answer" { + t.Fatalf("unexpected transcript: %#v", transcript) + } + if len(forwarded) != 5 { + t.Fatalf("unexpected forwarded events: %#v", forwarded) + } +} + +func openStore(t *testing.T) *sqlstore.Store { + t.Helper() + dsn := "file:" + filepath.Join(t.TempDir(), "recorder.db") + "?_pragma=busy_timeout(5000)" + store, err := sqlstore.Open(context.Background(), "sqlite", dsn) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = store.Close() }) + return store +} diff --git a/internal/conversation/sqlstore/store.go b/internal/conversation/sqlstore/store.go new file mode 100644 index 0000000..7c44450 --- /dev/null +++ b/internal/conversation/sqlstore/store.go @@ -0,0 +1,426 @@ +package sqlstore + +import ( + "context" + "database/sql" + "errors" + "fmt" + "strings" + "time" + + _ "github.com/jackc/pgx/v5/stdlib" + _ "modernc.org/sqlite" + + "github.com/Nesoriel/nivora/internal/conversation" +) + +// Store implements durable conversation storage over SQLite or PostgreSQL. +type Store struct { + db *sql.DB + dialect string +} + +// Open opens, configures, and migrates a durable store. +func Open(ctx context.Context, driver, dsn string) (*Store, error) { + driver = strings.ToLower(strings.TrimSpace(driver)) + dsn = strings.TrimSpace(dsn) + if dsn == "" { + return nil, errors.New("storage DSN is required") + } + sqlDriver := driver + switch driver { + case "sqlite": + sqlDriver = "sqlite" + case "postgres", "postgresql", "pgx": + driver = "pgx" + sqlDriver = "pgx" + default: + return nil, fmt.Errorf("unsupported storage driver %q", driver) + } + db, err := sql.Open(sqlDriver, dsn) + if err != nil { + return nil, fmt.Errorf("open conversation database: %w", err) + } + store := &Store{db: db, dialect: driver} + if driver == "sqlite" { + db.SetMaxOpenConns(1) + db.SetMaxIdleConns(1) + } + checkCtx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + if err := db.PingContext(checkCtx); err != nil { + _ = db.Close() + return nil, fmt.Errorf("ping conversation database: %w", err) + } + if err := store.migrate(checkCtx); err != nil { + _ = db.Close() + return nil, err + } + return store, nil +} + +func (s *Store) BeginRun(ctx context.Context, record conversation.RunRecord) error { + if err := validateRun(record); err != nil { + return err + } + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer tx.Rollback() + nowMS := millis(record.StartedAt) + if _, err := tx.ExecContext(ctx, s.bind(` + INSERT INTO conversations (tenant_id, conversation_id, created_at_ms, updated_at_ms) + VALUES (?, ?, ?, ?) + ON CONFLICT (tenant_id, conversation_id) + DO UPDATE SET updated_at_ms = excluded.updated_at_ms + `), record.TenantID, record.ConversationID, nowMS, nowMS); err != nil { + return fmt.Errorf("upsert conversation: %w", err) + } + + var tenantID, conversationID string + err = tx.QueryRowContext(ctx, s.bind(`SELECT tenant_id, conversation_id FROM runs WHERE request_id = ?`), record.RequestID).Scan(&tenantID, &conversationID) + switch { + case err == nil: + if tenantID != record.TenantID || conversationID != record.ConversationID { + return conversation.ErrIdempotencyConflict + } + return tx.Commit() + case !errors.Is(err, sql.ErrNoRows): + return fmt.Errorf("read existing run: %w", err) + } + + _, err = tx.ExecContext(ctx, s.bind(` + INSERT INTO runs ( + request_id, tenant_id, conversation_id, status, error_code, + authenticated, scope_count, nivora_version, nivora_commit, + prompt_version, prompt_source, started_at_ms, finished_at_ms + ) VALUES (?, ?, ?, 'running', '', ?, ?, ?, ?, ?, ?, ?, 0) + `), + record.RequestID, record.TenantID, record.ConversationID, + boolInt(record.Authenticated), record.ScopeCount, + record.NivoraVersion, record.NivoraCommit, + record.PromptVersion, record.PromptSource, nowMS, + ) + if err != nil { + return fmt.Errorf("insert run: %w", err) + } + return tx.Commit() +} + +func (s *Store) AppendMessage(ctx context.Context, record conversation.MessageRecord) error { + if record.MessageID == "" || record.RequestID == "" || record.TenantID == "" || record.ConversationID == "" { + return errors.New("message identity is required") + } + if record.Role != "user" && record.Role != "assistant" { + return errors.New("message role must be user or assistant") + } + _, err := s.db.ExecContext(ctx, s.bind(` + INSERT INTO messages ( + message_id, request_id, tenant_id, conversation_id, role, content, created_at_ms + ) VALUES (?, ?, ?, ?, ?, ?, ?) + ON CONFLICT (message_id) DO NOTHING + `), record.MessageID, record.RequestID, record.TenantID, record.ConversationID, record.Role, record.Content, millis(record.CreatedAt)) + if err != nil { + return fmt.Errorf("insert message: %w", err) + } + var requestID, tenantID, conversationID, role, content string + if err := s.db.QueryRowContext(ctx, s.bind(` + SELECT request_id, tenant_id, conversation_id, role, content + FROM messages WHERE message_id = ? + `), record.MessageID).Scan(&requestID, &tenantID, &conversationID, &role, &content); err != nil { + return fmt.Errorf("verify message: %w", err) + } + if requestID != record.RequestID || tenantID != record.TenantID || conversationID != record.ConversationID || role != record.Role || content != record.Content { + return conversation.ErrIdempotencyConflict + } + _, err = s.db.ExecContext(ctx, s.bind(` + UPDATE conversations SET updated_at_ms = ? + WHERE tenant_id = ? AND conversation_id = ? + `), millis(record.CreatedAt), record.TenantID, record.ConversationID) + return err +} + +func (s *Store) ToolStarted(ctx context.Context, record conversation.ToolAuditRecord) error { + if err := validateTool(record); err != nil { + return err + } + _, err := s.db.ExecContext(ctx, s.bind(` + INSERT INTO tool_audits ( + request_id, tool_call_id, tenant_id, conversation_id, + tool_name, status, reference_id, started_at_ms, finished_at_ms + ) VALUES (?, ?, ?, ?, ?, ?, '', ?, 0) + ON CONFLICT (request_id, tool_call_id) + DO UPDATE SET tool_name = excluded.tool_name, + status = excluded.status, + started_at_ms = CASE + WHEN tool_audits.started_at_ms = 0 THEN excluded.started_at_ms + ELSE tool_audits.started_at_ms + END + `), record.RequestID, record.ToolCallID, record.TenantID, record.ConversationID, record.ToolName, record.Status, millis(record.StartedAt)) + if err != nil { + return fmt.Errorf("record tool start: %w", err) + } + return nil +} + +func (s *Store) ToolFinished(ctx context.Context, record conversation.ToolAuditRecord) error { + if err := validateTool(record); err != nil { + return err + } + finishedMS := millis(record.FinishedAt) + startedMS := millis(record.StartedAt) + _, err := s.db.ExecContext(ctx, s.bind(` + INSERT INTO tool_audits ( + request_id, tool_call_id, tenant_id, conversation_id, + tool_name, status, reference_id, started_at_ms, finished_at_ms + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT (request_id, tool_call_id) + DO UPDATE SET tool_name = excluded.tool_name, + status = excluded.status, + reference_id = excluded.reference_id, + finished_at_ms = excluded.finished_at_ms + `), record.RequestID, record.ToolCallID, record.TenantID, record.ConversationID, record.ToolName, record.Status, record.ReferenceID, startedMS, finishedMS) + if err != nil { + return fmt.Errorf("record tool finish: %w", err) + } + if record.ReferenceID != "" { + _, err = s.db.ExecContext(ctx, s.bind(` + INSERT INTO support_case_refs ( + tenant_id, provider_case_id, conversation_id, status, created_at_ms, updated_at_ms + ) VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT (tenant_id, provider_case_id) + DO UPDATE SET conversation_id = excluded.conversation_id, + status = excluded.status, + updated_at_ms = excluded.updated_at_ms + `), record.TenantID, record.ReferenceID, record.ConversationID, record.Status, finishedMS, finishedMS) + if err != nil { + return fmt.Errorf("record support case reference: %w", err) + } + } + return nil +} + +func (s *Store) FinishRun(ctx context.Context, finish conversation.RunFinish) error { + if finish.RequestID == "" { + return errors.New("request_id is required") + } + result, err := s.db.ExecContext(ctx, s.bind(` + UPDATE runs SET status = ?, error_code = ?, finished_at_ms = ? + WHERE request_id = ? + `), finish.Status, finish.ErrorCode, millis(finish.FinishedAt), finish.RequestID) + if err != nil { + return fmt.Errorf("finish run: %w", err) + } + count, _ := result.RowsAffected() + if count == 0 { + return sql.ErrNoRows + } + return nil +} + +func (s *Store) Transcript(ctx context.Context, tenantID, conversationID string) ([]conversation.MessageRecord, error) { + tenantID = strings.TrimSpace(tenantID) + conversationID = strings.TrimSpace(conversationID) + if tenantID == "" || conversationID == "" { + return nil, errors.New("tenant and conversation are required") + } + rows, err := s.db.QueryContext(ctx, s.bind(` + SELECT message_id, request_id, tenant_id, conversation_id, role, content, created_at_ms + FROM messages + WHERE tenant_id = ? AND conversation_id = ? + ORDER BY created_at_ms ASC, message_id ASC + `), tenantID, conversationID) + if err != nil { + return nil, fmt.Errorf("query transcript: %w", err) + } + defer rows.Close() + var records []conversation.MessageRecord + for rows.Next() { + var record conversation.MessageRecord + var createdMS int64 + if err := rows.Scan(&record.MessageID, &record.RequestID, &record.TenantID, &record.ConversationID, &record.Role, &record.Content, &createdMS); err != nil { + return nil, err + } + record.CreatedAt = time.UnixMilli(createdMS).UTC() + records = append(records, record) + } + return records, rows.Err() +} + +func (s *Store) DeleteBefore(ctx context.Context, before time.Time) (conversation.RetentionResult, error) { + cutoff := millis(before) + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return conversation.RetentionResult{}, err + } + defer tx.Rollback() + var result conversation.RetentionResult + if result.ToolAudits, err = execDelete(ctx, tx, s.bind(`DELETE FROM tool_audits WHERE finished_at_ms > 0 AND finished_at_ms < ?`), cutoff); err != nil { + return result, err + } + if result.Messages, err = execDelete(ctx, tx, s.bind(`DELETE FROM messages WHERE created_at_ms < ?`), cutoff); err != nil { + return result, err + } + if result.Runs, err = execDelete(ctx, tx, s.bind(`DELETE FROM runs WHERE finished_at_ms > 0 AND finished_at_ms < ?`), cutoff); err != nil { + return result, err + } + if result.SupportCases, err = execDelete(ctx, tx, s.bind(`DELETE FROM support_case_refs WHERE updated_at_ms < ?`), cutoff); err != nil { + return result, err + } + if result.Conversations, err = execDelete(ctx, tx, s.bind(` + DELETE FROM conversations + WHERE updated_at_ms < ? + AND NOT EXISTS ( + SELECT 1 FROM runs + WHERE runs.tenant_id = conversations.tenant_id + AND runs.conversation_id = conversations.conversation_id + ) + AND NOT EXISTS ( + SELECT 1 FROM messages + WHERE messages.tenant_id = conversations.tenant_id + AND messages.conversation_id = conversations.conversation_id + ) + `), cutoff); err != nil { + return result, err + } + if err := tx.Commit(); err != nil { + return result, err + } + return result, nil +} + +func (s *Store) Check(ctx context.Context) error { return s.db.PingContext(ctx) } +func (s *Store) Close() error { return s.db.Close() } + +func (s *Store) migrate(ctx context.Context) error { + statements := []string{ + `CREATE TABLE IF NOT EXISTS schema_migrations (version INTEGER PRIMARY KEY, applied_at_ms BIGINT NOT NULL)`, + `CREATE TABLE IF NOT EXISTS conversations ( + tenant_id TEXT NOT NULL, + conversation_id TEXT NOT NULL, + created_at_ms BIGINT NOT NULL, + updated_at_ms BIGINT NOT NULL, + PRIMARY KEY (tenant_id, conversation_id) + )`, + `CREATE TABLE IF NOT EXISTS runs ( + request_id TEXT PRIMARY KEY, + tenant_id TEXT NOT NULL, + conversation_id TEXT NOT NULL, + status TEXT NOT NULL, + error_code TEXT NOT NULL, + authenticated INTEGER NOT NULL, + scope_count INTEGER NOT NULL, + nivora_version TEXT NOT NULL, + nivora_commit TEXT NOT NULL, + prompt_version TEXT NOT NULL, + prompt_source TEXT NOT NULL, + started_at_ms BIGINT NOT NULL, + finished_at_ms BIGINT NOT NULL + )`, + `CREATE INDEX IF NOT EXISTS runs_tenant_conversation_idx ON runs (tenant_id, conversation_id, started_at_ms)`, + `CREATE TABLE IF NOT EXISTS messages ( + message_id TEXT PRIMARY KEY, + request_id TEXT NOT NULL, + tenant_id TEXT NOT NULL, + conversation_id TEXT NOT NULL, + role TEXT NOT NULL, + content TEXT NOT NULL, + created_at_ms BIGINT NOT NULL + )`, + `CREATE INDEX IF NOT EXISTS messages_tenant_conversation_idx ON messages (tenant_id, conversation_id, created_at_ms)`, + `CREATE TABLE IF NOT EXISTS tool_audits ( + request_id TEXT NOT NULL, + tool_call_id TEXT NOT NULL, + tenant_id TEXT NOT NULL, + conversation_id TEXT NOT NULL, + tool_name TEXT NOT NULL, + status TEXT NOT NULL, + reference_id TEXT NOT NULL, + started_at_ms BIGINT NOT NULL, + finished_at_ms BIGINT NOT NULL, + PRIMARY KEY (request_id, tool_call_id) + )`, + `CREATE INDEX IF NOT EXISTS tool_audits_tenant_conversation_idx ON tool_audits (tenant_id, conversation_id, started_at_ms)`, + `CREATE TABLE IF NOT EXISTS support_case_refs ( + tenant_id TEXT NOT NULL, + provider_case_id TEXT NOT NULL, + conversation_id TEXT NOT NULL, + status TEXT NOT NULL, + created_at_ms BIGINT NOT NULL, + updated_at_ms BIGINT NOT NULL, + PRIMARY KEY (tenant_id, provider_case_id) + )`, + } + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer tx.Rollback() + for _, statement := range statements { + if _, err := tx.ExecContext(ctx, statement); err != nil { + return fmt.Errorf("apply conversation migration: %w", err) + } + } + if _, err := tx.ExecContext(ctx, s.bind(` + INSERT INTO schema_migrations (version, applied_at_ms) + VALUES (1, ?) + ON CONFLICT (version) DO NOTHING + `), time.Now().UTC().UnixMilli()); err != nil { + return err + } + return tx.Commit() +} + +func (s *Store) bind(query string) string { + if s.dialect != "pgx" { + return query + } + var builder strings.Builder + index := 1 + for _, character := range query { + if character == '?' { + fmt.Fprintf(&builder, "$%d", index) + index++ + } else { + builder.WriteRune(character) + } + } + return builder.String() +} + +func validateRun(record conversation.RunRecord) error { + if record.RequestID == "" || record.TenantID == "" || record.ConversationID == "" { + return errors.New("run identity is required") + } + return nil +} + +func validateTool(record conversation.ToolAuditRecord) error { + if record.RequestID == "" || record.TenantID == "" || record.ConversationID == "" || record.ToolCallID == "" || record.ToolName == "" { + return errors.New("tool audit identity is required") + } + return nil +} + +func millis(value time.Time) int64 { + if value.IsZero() { + return time.Now().UTC().UnixMilli() + } + return value.UTC().UnixMilli() +} + +func boolInt(value bool) int { + if value { + return 1 + } + return 0 +} + +func execDelete(ctx context.Context, tx *sql.Tx, query string, args ...any) (int64, error) { + result, err := tx.ExecContext(ctx, query, args...) + if err != nil { + return 0, err + } + return result.RowsAffected() +} diff --git a/internal/conversation/sqlstore/store_test.go b/internal/conversation/sqlstore/store_test.go new file mode 100644 index 0000000..00bc118 --- /dev/null +++ b/internal/conversation/sqlstore/store_test.go @@ -0,0 +1,116 @@ +package sqlstore + +import ( + "context" + "errors" + "path/filepath" + "testing" + "time" + + "github.com/Nesoriel/nivora/internal/conversation" +) + +func TestSQLiteStorePersistsTranscriptAndRejectsCrossIdentityReplay(t *testing.T) { + store := openTestStore(t) + ctx := context.Background() + now := time.Date(2026, 7, 16, 10, 0, 0, 0, time.UTC) + run := conversation.RunRecord{ + RequestID: "req-1", + TenantID: "tenant-a", + ConversationID: "conv-1", + NivoraVersion: "v1", + NivoraCommit: "abc", + PromptVersion: "p1", + PromptSource: "bundled", + StartedAt: now, + } + if err := store.BeginRun(ctx, run); err != nil { + t.Fatal(err) + } + if err := store.BeginRun(ctx, run); err != nil { + t.Fatalf("same run replay should be idempotent: %v", err) + } + conflict := run + conflict.TenantID = "tenant-b" + if err := store.BeginRun(ctx, conflict); !errors.Is(err, conversation.ErrIdempotencyConflict) { + t.Fatalf("expected idempotency conflict, got %v", err) + } + + messages := []conversation.MessageRecord{ + {MessageID: "req-1:user", RequestID: "req-1", TenantID: "tenant-a", ConversationID: "conv-1", Role: "user", Content: "hello", CreatedAt: now}, + {MessageID: "req-1:assistant", RequestID: "req-1", TenantID: "tenant-a", ConversationID: "conv-1", Role: "assistant", Content: "verified answer", CreatedAt: now.Add(time.Second)}, + } + for _, message := range messages { + if err := store.AppendMessage(ctx, message); err != nil { + t.Fatal(err) + } + if err := store.AppendMessage(ctx, message); err != nil { + t.Fatalf("message replay should be idempotent: %v", err) + } + } + + transcript, err := store.Transcript(ctx, "tenant-a", "conv-1") + if err != nil { + t.Fatal(err) + } + if len(transcript) != 2 || transcript[1].Content != "verified answer" { + t.Fatalf("unexpected transcript: %#v", transcript) + } + otherTenant, err := store.Transcript(ctx, "tenant-b", "conv-1") + if err != nil { + t.Fatal(err) + } + if len(otherTenant) != 0 { + t.Fatalf("cross-tenant transcript leaked: %#v", otherTenant) + } +} + +func TestSQLiteStoreRecordsSanitizedToolsCasesAndRetention(t *testing.T) { + store := openTestStore(t) + ctx := context.Background() + old := time.Now().UTC().Add(-48 * time.Hour) + if err := store.BeginRun(ctx, conversation.RunRecord{RequestID: "req-old", TenantID: "tenant-a", ConversationID: "conv-old", StartedAt: old}); err != nil { + t.Fatal(err) + } + if err := store.AppendMessage(ctx, conversation.MessageRecord{MessageID: "old:user", RequestID: "req-old", TenantID: "tenant-a", ConversationID: "conv-old", Role: "user", Content: "old", CreatedAt: old}); err != nil { + t.Fatal(err) + } + if err := store.ToolStarted(ctx, conversation.ToolAuditRecord{RequestID: "req-old", TenantID: "tenant-a", ConversationID: "conv-old", ToolCallID: "tool-1", ToolName: "create_support_case", Status: "started", StartedAt: old}); err != nil { + t.Fatal(err) + } + if err := store.ToolFinished(ctx, conversation.ToolAuditRecord{RequestID: "req-old", TenantID: "tenant-a", ConversationID: "conv-old", ToolCallID: "tool-1", ToolName: "create_support_case", Status: "finished", StartedAt: old, FinishedAt: old.Add(time.Second)}); err != nil { + t.Fatal(err) + } + if err := store.RecordSupportCase(ctx, conversation.SupportCaseRecord{TenantID: "tenant-a", ConversationID: "conv-old", ProviderCaseID: "case-1", Status: "open", CreatedAt: old, UpdatedAt: old}); err != nil { + t.Fatal(err) + } + if err := store.FinishRun(ctx, conversation.RunFinish{RequestID: "req-old", Status: "completed", FinishedAt: old.Add(2 * time.Second)}); err != nil { + t.Fatal(err) + } + + result, err := store.DeleteBefore(ctx, time.Now().UTC().Add(-24*time.Hour)) + if err != nil { + t.Fatal(err) + } + if result.Runs != 1 || result.Messages != 1 || result.ToolAudits != 1 || result.SupportCases != 1 || result.Conversations != 1 { + t.Fatalf("unexpected retention result: %#v", result) + } + transcript, err := store.Transcript(ctx, "tenant-a", "conv-old") + if err != nil { + t.Fatal(err) + } + if len(transcript) != 0 { + t.Fatalf("retention left messages: %#v", transcript) + } +} + +func openTestStore(t *testing.T) *Store { + t.Helper() + dsn := "file:" + filepath.Join(t.TempDir(), "nivora.db") + "?_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)" + store, err := Open(context.Background(), "sqlite", dsn) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = store.Close() }) + return store +} diff --git a/internal/conversation/sqlstore/supportcase.go b/internal/conversation/sqlstore/supportcase.go new file mode 100644 index 0000000..2fb034e --- /dev/null +++ b/internal/conversation/sqlstore/supportcase.go @@ -0,0 +1,37 @@ +package sqlstore + +import ( + "context" + "errors" + "fmt" + "strings" + + "github.com/Nesoriel/nivora/internal/conversation" +) + +// RecordSupportCase upserts the external case reference without persisting the +// original Tool arguments, Provider response, or customer identity. +func (s *Store) RecordSupportCase(ctx context.Context, record conversation.SupportCaseRecord) error { + record.TenantID = strings.TrimSpace(record.TenantID) + record.ConversationID = strings.TrimSpace(record.ConversationID) + record.ProviderCaseID = strings.TrimSpace(record.ProviderCaseID) + record.Status = strings.TrimSpace(record.Status) + if record.TenantID == "" || record.ConversationID == "" || record.ProviderCaseID == "" { + return errors.New("support case identity is required") + } + createdMS := millis(record.CreatedAt) + updatedMS := millis(record.UpdatedAt) + _, err := s.db.ExecContext(ctx, s.bind(` + INSERT INTO support_case_refs ( + tenant_id, provider_case_id, conversation_id, status, created_at_ms, updated_at_ms + ) VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT (tenant_id, provider_case_id) + DO UPDATE SET conversation_id = excluded.conversation_id, + status = excluded.status, + updated_at_ms = excluded.updated_at_ms + `), record.TenantID, record.ProviderCaseID, record.ConversationID, record.Status, createdMS, updatedMS) + if err != nil { + return fmt.Errorf("record support case: %w", err) + } + return nil +} diff --git a/internal/conversation/store.go b/internal/conversation/store.go new file mode 100644 index 0000000..8d94118 --- /dev/null +++ b/internal/conversation/store.go @@ -0,0 +1,107 @@ +package conversation + +import ( + "context" + "errors" + "time" +) + +var ErrIdempotencyConflict = errors.New("conversation idempotency conflict") + +// RunRecord is the durable, support-safe metadata for one Agent run. +type RunRecord struct { + RequestID string + TenantID string + ConversationID string + Authenticated bool + ScopeCount int + NivoraVersion string + NivoraCommit string + PromptVersion string + PromptSource string + StartedAt time.Time +} + +// MessageRecord stores only customer-visible user or assistant content. +type MessageRecord struct { + MessageID string `json:"message_id"` + RequestID string `json:"request_id"` + TenantID string `json:"tenant_id"` + ConversationID string `json:"conversation_id"` + Role string `json:"role"` + Content string `json:"content"` + CreatedAt time.Time `json:"created_at"` +} + +// ToolAuditRecord deliberately excludes Tool arguments and raw results. +type ToolAuditRecord struct { + RequestID string + TenantID string + ConversationID string + ToolCallID string + ToolName string + Status string + ReferenceID string + StartedAt time.Time + FinishedAt time.Time +} + +// SupportCaseRecord stores only the external reference needed for handoff. +type SupportCaseRecord struct { + TenantID string + ConversationID string + ProviderCaseID string + Status string + CreatedAt time.Time + UpdatedAt time.Time +} + +// RunFinish records the externally observable result of one run. +type RunFinish struct { + RequestID string + Status string + ErrorCode string + FinishedAt time.Time +} + +// RetentionResult reports restart-safe cleanup counts. +type RetentionResult struct { + Runs int64 + Messages int64 + ToolAudits int64 + SupportCases int64 + Conversations int64 +} + +// Store owns Nivora conversation and audit state. It never stores chain of +// thought, bearer contexts, service secrets, or unrestricted Provider payloads. +type Store interface { + BeginRun(context.Context, RunRecord) error + AppendMessage(context.Context, MessageRecord) error + ToolStarted(context.Context, ToolAuditRecord) error + ToolFinished(context.Context, ToolAuditRecord) error + RecordSupportCase(context.Context, SupportCaseRecord) error + FinishRun(context.Context, RunFinish) error + Transcript(context.Context, string, string) ([]MessageRecord, error) + DeleteBefore(context.Context, time.Time) (RetentionResult, error) + Check(context.Context) error + Close() error +} + +type nopStore struct{} + +// Nop returns a disabled store implementation. +func Nop() Store { return nopStore{} } + +func (nopStore) BeginRun(context.Context, RunRecord) error { return nil } +func (nopStore) AppendMessage(context.Context, MessageRecord) error { return nil } +func (nopStore) ToolStarted(context.Context, ToolAuditRecord) error { return nil } +func (nopStore) ToolFinished(context.Context, ToolAuditRecord) error { return nil } +func (nopStore) RecordSupportCase(context.Context, SupportCaseRecord) error { return nil } +func (nopStore) FinishRun(context.Context, RunFinish) error { return nil } +func (nopStore) Transcript(context.Context, string, string) ([]MessageRecord, error) { return nil, nil } +func (nopStore) DeleteBefore(context.Context, time.Time) (RetentionResult, error) { + return RetentionResult{}, nil +} +func (nopStore) Check(context.Context) error { return nil } +func (nopStore) Close() error { return nil } diff --git a/internal/dependency/multi.go b/internal/dependency/multi.go new file mode 100644 index 0000000..cb309a7 --- /dev/null +++ b/internal/dependency/multi.go @@ -0,0 +1,37 @@ +package dependency + +import ( + "context" + "fmt" +) + +// Checker is the shared readiness dependency surface. +type Checker interface { + Check(context.Context) error +} + +// Multi requires every configured dependency to be healthy. +type Multi struct { + checkers []Checker +} + +// New creates a composite dependency checker and skips nil entries. +func New(checkers ...Checker) *Multi { + filtered := make([]Checker, 0, len(checkers)) + for _, checker := range checkers { + if checker != nil { + filtered = append(filtered, checker) + } + } + return &Multi{checkers: filtered} +} + +// Check fails on the first unavailable dependency. +func (m *Multi) Check(ctx context.Context) error { + for index, checker := range m.checkers { + if err := checker.Check(ctx); err != nil { + return fmt.Errorf("dependency %d: %w", index+1, err) + } + } + return nil +} diff --git a/internal/domain/types.go b/internal/domain/types.go index d0d9825..5bdeab2 100644 --- a/internal/domain/types.go +++ b/internal/domain/types.go @@ -127,13 +127,17 @@ type SupportCase struct { CreatedAt time.Time `json:"created_at"` } -// StreamEvent is Nivora's stable SSE protocol. +// StreamEvent is Nivora's stable SSE protocol. Audit fields are intentionally +// excluded from JSON so provider references can be persisted without widening +// the browser-facing protocol. type StreamEvent struct { - Type string `json:"type"` - RequestID string `json:"request_id,omitempty"` - ConversationID string `json:"conversation_id,omitempty"` - Content string `json:"content,omitempty"` - ToolName string `json:"tool_name,omitempty"` - ToolCallID string `json:"tool_call_id,omitempty"` - Code string `json:"code,omitempty"` + Type string `json:"type"` + RequestID string `json:"request_id,omitempty"` + ConversationID string `json:"conversation_id,omitempty"` + Content string `json:"content,omitempty"` + ToolName string `json:"tool_name,omitempty"` + ToolCallID string `json:"tool_call_id,omitempty"` + Code string `json:"code,omitempty"` + AuditReferenceID string `json:"-"` + AuditStatus string `json:"-"` }