Documentation
¶
Overview ¶
Package storage is the persistence layer used by the agent (pattern catalog, shadow log, service registry) and by the incident service (incident history). One Provider is constructed at boot from `storage:` in config.yaml and passed to every consumer that needs to read or write durable state.
The interface is split into two concerns:
- Blob: opaque byte slices keyed by short name. Used by the agent catalog and shadow log, both of which already serialize themselves to JSON. Backends translate the name into a file path / Redis key / row.
- Incident: first-class CRUD-ish operations because the UI lists, filters, and acks incidents.
Backends today:
- file (pkg/storage/file.go) — production
- memory (pkg/storage/memory.go) — tests only
- redis (pkg/storage/redis.go) — config stub, returns ErrUnsupported
- database (pkg/storage/database.go) — config stub
Index ¶
- Constants
- Variables
- func NormalizeOrgID(s string) string
- func OriginFromSource(source string) string
- func RunSQLMigrations(db *sql.DB, fsys fs.FS, dir, ledgerTable string) error
- func WithMigrationLock(db *sql.DB, fn func() error) error
- type AnalysisPager
- type AnalysisRecord
- type AnalysisToolCall
- type Blob
- type BlobCreator
- type Config
- type DatabaseOptions
- type FileOptions
- type IncidentCounts
- type IncidentPager
- type IncidentRecord
- type IncidentSearchPager
- type IncidentStatusCounts
- type Lifecycle
- type ModelState
- type ModelStore
- type PostgresOptions
- type Provider
- type RangeLister
- type RedisOptions
- type SQLAccessor
- type Searcher
- type StatusCounts
Constants ¶
const ( // DomainIncidents targets incident history (IncidentRecord); age is // compared against created_at. DomainIncidents = "incidents" // DomainAnalyses targets analyze-mode runs (AnalysisRecord); age is // compared against requested_at. DomainAnalyses = "analyses" // DomainBlobs targets opaque blobs written via WriteBlob — including // the learned model-state artifacts under the models/ namespace; // age is compared against the blob's updated_at. DomainBlobs = "blobs" )
Lifecycle domains. A small fixed set so a backend can map each to its physical table(s) without ever trusting caller-supplied input.
const ( // OriginAIDetect marks incidents emitted by the AI detect agent // (services.CreateIncidentFromFinding). OriginAIDetect = "ai_detect" // OriginWebhook marks incidents ingested from an inbound alert — // the public webhook plus every transport hint (sns, sqs, ...). OriginWebhook = "webhook" )
Origin is the coarse, tier-neutral classifier for HOW an incident entered the system. It is deliberately small and extensible: the UI separates the AI-detected feed from the inbound-alert firehose so a flood of webhook incidents cannot bury the high-signal ones. New origins (e.g. a future "api" or "scheduler" source) can be added without changing existing rows.
const DefaultAnalysisPageSize = 1000
DefaultAnalysisPageSize is the bounded page the analyses list read returns when the caller does not request a specific size: the most-recent 1000 analyze-mode runs. It is the analyses twin of DefaultIncidentPageSize — vs_analyses grows unbounded on Postgres, so the list serves one cheap count plus one bounded page rather than the whole table.
const DefaultDataDir = "data"
DefaultDataDir is the application's persistent data directory. The `file` storage backend persists its JSON here (incidents, pattern catalog, shadow/detect logs, AI cache, runbook corpus); on-disk assets that are independent of the configured storage backend also live here, such as the runbook source files under runbooks/. It is relative to the process working directory; in the container image (WORKDIR /app) it resolves to /app/data.
const DefaultIncidentPageSize = 1000
DefaultIncidentPageSize is the bounded page the incident list read returns when the caller does not request a specific size: the most-recent 1000 rows. It caps the first list load so a backend with unbounded history (Postgres) never ships the whole table to render one page — the UI shows the total from the cheap count and loads further pages only on demand.
const DefaultOrgID = "default"
DefaultOrgID is the org every record carries when no explicit organization is supplied. Single-tenant OSS deployments never set an org, so every persisted record transparently belongs to "default" and no behaviour changes. Multi-tenant scoping (enterprise) overrides this per request via the org-injection seam (pkg/middleware).
const MaxIncidentsDefault = 1000
MaxIncidentsDefault is the default rolling cap for the file backend. Older records are dropped on SaveIncident when the cap is exceeded.
const ModelStateNamespace = "models"
ModelStateNamespace is the blob-name prefix under which every learned model artifact is persisted: <namespace>/<org>/<agent>/<key>.
Variables ¶
var ErrInvalidModelKey = errors.New("storage: invalid model-state key component")
ErrInvalidModelKey is returned when an org/agent/key component is empty or contains a path separator or "..", which could escape the model-state namespace on the file backend.
var ErrNotFound = errors.New("storage: not found")
ErrNotFound is returned by Get* methods when the key/id is missing. File and memory backends translate os.ErrNotExist into ErrNotFound so callers can rely on errors.Is(err, storage.ErrNotFound).
var ErrUnknownDomain = errors.New("storage: unknown lifecycle domain")
ErrUnknownDomain is returned by Lifecycle methods when domain is not one of the fixed {DomainIncidents, DomainAnalyses, DomainBlobs} set.
var ErrUnsupported = errors.New("storage: backend not implemented")
ErrUnsupported is returned by the redis/database stub backends, and by the model-state purge path when the configured backend does not implement the optional storage.Lifecycle capability.
Functions ¶
func NormalizeOrgID ¶ added in v1.4.4
NormalizeOrgID returns a non-empty org id, falling back to DefaultOrgID when s is blank. Backends call this on the persistence path so a record is never stored with an empty OrgID.
func OriginFromSource ¶ added in v1.4.8
OriginFromSource derives an Origin from the durable Source label. It is the back-compat path for records persisted before the Origin field existed: agent-emitted incidents carry a Source of "agent" or "agent:<...>", so they classify as OriginAIDetect; every other Source (webhook/sns/sqs/empty) is inbound ingestion and classifies as OriginWebhook. Choosing OriginWebhook for the unknown/empty case never misattributes an inbound alert as AI-detected — the conservative default the UI relies on.
func RunSQLMigrations ¶ added in v1.4.8
RunSQLMigrations applies every `*.sql` file in fsys under dir, in lexical filename order, exactly once each, tracking applied filenames in ledgerTable so a re-run is a no-op. It is FS- and table-agnostic (see the file header). db must be a Postgres *sql.DB (obtained via storage.SQLAccessor.DB()); the ledger DDL and bookkeeping use Postgres syntax.
Concurrency: RunSQLMigrations does NOT take the migration advisory lock itself — the caller wraps it in WithMigrationLock when multiple processes may migrate the same pool at once (the OSS backend does; the enterprise runner does the same on the shared key). This keeps the runner a pure, lock-agnostic primitive.
func WithMigrationLock ¶ added in v1.4.4
WithMigrationLock serializes a schema-migration run across every process that shares db's Postgres. It pins one pooled connection, takes a session-level pg_advisory_lock on the dedicated migrationAdvisoryLockKey, runs fn, then releases the lock — even if fn errors. When N replicas boot at once against a fresh database, exactly one acquires the lock and runs the DDL while the rest block on the lock; once it releases, the losers proceed and find every table already present (the migrations are idempotent), so concurrent first-boot is deterministic and never hits the pg_type / pg_class catalog races that bare `CREATE TABLE IF NOT EXISTS` suffers under concurrency.
The lock is released two ways for safety: an explicit pg_advisory_unlock and `defer conn.Close()` — closing the session drops every session-level advisory lock it held, so the lock is freed even if the unlock call itself errors. A subsequent (sequential) call therefore always acquires cleanly and never deadlocks. The helper is generic OSS storage hardening: it carries no tier or migration-set knowledge, so an enterprise migration runner sharing the same pool can wrap its own run in the same WithMigrationLock(db, …) call and serialize against the OSS run on this one key.
Types ¶
type AnalysisPager ¶ added in v1.4.14
type AnalysisPager interface {
// CountAnalyses returns the total number of stored analyses, computed
// without materializing rows.
CountAnalyses() (int, error)
// ListAnalysesPage returns one bounded page of analyses, newest first by
// requested_at, skipping the first offset rows and returning at most limit
// rows. limit <= 0 uses DefaultAnalysisPageSize; a negative offset is
// treated as 0.
ListAnalysesPage(offset, limit int) ([]*AnalysisRecord, error)
}
AnalysisPager is an optional capability a backend may implement on top of Provider to serve the analyses list without ever loading the whole table. It is the analyses twin of IncidentPager: it splits the two things the list endpoint needs — a cheap count and a bounded page — so a large backend (Postgres, unbounded vs_analyses) pushes both into SQL (COUNT(*) and ORDER BY requested_at DESC LIMIT/OFFSET). File and memory backends implement it over their already-capped in-memory slice (a len() count and a slice window). Backends that cannot (the redis/database stubs) do not implement it; callers type-assert and fall back to a bounded ListAnalyses, exactly like IncidentPager.
type AnalysisRecord ¶ added in v1.4.3
type AnalysisRecord struct {
ID string `json:"id"`
// OrgID scopes the analysis to one organization. Defaults to
// storage.DefaultOrgID ("default"); see IncidentRecord.OrgID.
OrgID string `json:"org_id,omitempty"`
IncidentID string `json:"incident_id"`
RequestedAt time.Time `json:"requested_at"`
RequestedBy string `json:"requested_by,omitempty"`
DurationMs int64 `json:"duration_ms,omitempty"`
Model string `json:"model,omitempty"`
// ToolCalls is the full audit trail of read-only tool invocations
// the agent issued during the run, in execution order.
ToolCalls []AnalysisToolCall `json:"tool_calls,omitempty"`
// Finding is the parsed structured output from the model. Nil when
// the run failed before the model produced JSON.
Finding *core.AIFinding `json:"finding,omitempty"`
// RawResponse is the model's final assistant message. Kept for
// audit / debugging when ParseFinding fails.
RawResponse string `json:"raw_response,omitempty"`
// Status is one of: "ok", "error", "rate_limited".
Status string `json:"status"`
Error string `json:"error,omitempty"`
}
AnalysisRecord is the durable shape of one analyze-mode run. The admin /analyze endpoint creates one per request; the UI lists them per incident.
type AnalysisToolCall ¶ added in v1.4.3
type AnalysisToolCall struct {
Name string `json:"name"`
Args json.RawMessage `json:"args,omitempty"`
Output json.RawMessage `json:"output,omitempty"`
DurationMs int64 `json:"duration_ms,omitempty"`
Error string `json:"error,omitempty"`
}
AnalysisToolCall captures one tool round-trip for the audit log.
type Blob ¶ added in v1.4.4
Blob is one entry returned by Provider.ListBlobs: a stored blob's logical name (the same name passed to WriteBlob/ReadBlob) and its raw bytes. Data is a copy the caller owns and may mutate freely.
type BlobCreator ¶ added in v1.4.4
type BlobCreator interface {
// CreateBlobIfAbsent atomically writes data under key only if key does
// not already exist. It returns written==true when THIS call created the
// blob, and written==false when the key already existed — because a
// concurrent or prior writer won the race, OR because the key was set by
// an earlier WriteBlob. On written==false the stored bytes are left
// untouched: the caller re-reads them via ReadBlob(key) to adopt the
// surviving value. After a nil-error return the blob is durably present,
// so ReadBlob(key) observes the one surviving set of bytes regardless of
// which caller won.
CreateBlobIfAbsent(key string, data []byte) (written bool, err error)
}
BlobCreator is an optional capability a backend may implement on top of Provider. It adds a single atomic create-if-absent blob write used to elect ONE writer across multiple instances that share a store — the substrate for generate-once secrets under HA / multi-instance, where every replica boots the same generate-then-persist path and exactly one must win. It is mechanical and tier-neutral: it carries no org or policy concept and never overwrites an existing blob, so it is strictly additive and does not weaken WriteBlob's last-write-wins semantics.
Backends that cannot create atomically (the redis/database stubs) do not implement it; callers type-assert and treat a failed assertion as "unsupported", exactly like Lifecycle/Searcher. The Postgres (shared, multi-writer) and memory (tests) backends implement it because they are the HA substrate and the test path; the file backend implements it best-effort via O_CREATE|O_EXCL, which is coherent only on a single node — the only place file storage is allowed under HA (see the file-storage guard).
type Config ¶
type Config struct {
Type string // file | redis | database | postgres (default: file)
File FileOptions
Redis RedisOptions
Database DatabaseOptions
Postgres PostgresOptions
}
Config mirrors the root-level `storage:` block in config.yaml. It is kept here (rather than in pkg/config) so tests in this package don't pull in viper.
type DatabaseOptions ¶
DatabaseOptions mirrors the database sub-block of `storage:`. The database backend is config-only today — instantiating it returns ErrUnsupported. The struct exists so the config layer can validate its shape.
type FileOptions ¶
type FileOptions struct {
DataDir string // default DefaultDataDir
MaxIncidents int // default MaxIncidentsDefault
}
FileOptions configures the file backend. Empty fields fall back to sensible defaults.
type IncidentCounts ¶ added in v1.4.13
IncidentCounts is the cheap, whole-set tally the list surfaces show: how many UNRESOLVED (open) incidents are AI-detected vs inbound webhook/alert, and the open grand total. Counts reflect OPEN WORK — resolved incidents are excluded — so the badge shows the backlog, not the full history (webhook incidents auto-resolve on arrival, so the full history is dominated by resolved rows). It is computed WITHOUT materializing rows (COUNT/GROUP BY on SQL backends, a len()/map tally in memory) so a 100k+ history never has to be loaded to render a count badge. AIDetect+Webhook always equals Total.
Because the counts are unresolved-only they can no longer double as the list's pagination total (the list stays all-incidents so resolved rows remain reachable); the list endpoint drives "load more" off page-fullness instead.
type IncidentPager ¶ added in v1.4.13
type IncidentPager interface {
// CountIncidents returns the whole-set per-origin tally and grand total of
// UNRESOLVED (open) incidents, computed without materializing rows.
// Resolved incidents are excluded so the count reflects open work. Legacy
// rows with no explicit Origin are classified from Source exactly as
// EffectiveOrigin does, so they are never dropped into an empty bucket.
CountIncidents() (IncidentCounts, error)
// CountIncidentsByStatus returns the whole-set per-origin × per-status
// tally (open / acked / resolved / total, each split ai_detect vs webhook),
// computed without materializing rows. It is the authoritative source for
// every count the list surfaces display, so the header badge, the Now page
// and the Incidents page can never disagree. Legacy rows with no explicit
// Origin are classified from Source exactly as EffectiveOrigin does, so
// AIDetect + Webhook always equals Total for each status.
CountIncidentsByStatus() (IncidentStatusCounts, error)
// ListIncidentsPage returns one bounded page of incidents, newest first,
// skipping the first offset rows and returning at most limit rows. The
// page lists ALL incidents (resolved and open alike) so resolved
// incidents remain reachable — only the counts are unresolved-only. When
// origin is OriginAIDetect or OriginWebhook the page is filtered to that
// origin in the backend so a filtered page stays bounded; any other value
// returns all origins. limit <= 0 uses DefaultIncidentPageSize; a
// negative offset is treated as 0.
ListIncidentsPage(origin string, offset, limit int) ([]*IncidentRecord, error)
}
IncidentPager is an optional capability a backend may implement on top of Provider to serve the incident list without ever loading the whole table. It splits the two things the list endpoint needs — a cheap count and a bounded page — so a large backend (Postgres, unbounded history) can push both into SQL: the per-origin tally becomes a COUNT with FILTER and a page becomes ORDER BY created_at DESC LIMIT/OFFSET. File and memory backends implement it over their already-capped in-memory slice (a len()/map tally and a slice window), so callers get one uniform seam. Backends that cannot (the redis stub) do not implement it; callers type-assert and fall back to ListIncidents, exactly like Searcher/Lifecycle.
type IncidentRecord ¶
type IncidentRecord struct {
ID string `json:"id"`
// OrgID scopes the record to one organization. Defaults to
// storage.DefaultOrgID ("default") so single-tenant OSS users never
// see or set it; enterprise multi-tenant routing reads it to isolate
// orgs.
OrgID string `json:"org_id,omitempty"`
TeamID string `json:"team_id,omitempty"`
Title string `json:"title,omitempty"`
Source string `json:"source,omitempty"` // "webhook" | "sns" | "sqs" | ...
Service string `json:"service,omitempty"` // best-effort from payload
// Origin is the coarse classifier for how the incident entered the
// system: OriginAIDetect (agent detect emit) or OriginWebhook
// (inbound alert ingestion). Stamped at creation on both paths. Legacy
// records persisted before this field default to empty on disk;
// EffectiveOrigin derives a value from Source so they are never
// miscounted.
Origin string `json:"origin,omitempty"`
Resolved bool `json:"resolved"`
// ChannelsEnabled is the snapshot of channels that were configured
// when the alert fired. ChannelsNotified is the subset that
// actually succeeded. The two diverge whenever a channel fails:
// keep both so the UI can show "Slack failed" without losing the
// fact that Slack was supposed to be tried.
ChannelsEnabled []string `json:"channels_enabled,omitempty"`
ChannelsNotified []string `json:"channels_notified,omitempty"`
OnCallTriggered bool `json:"oncall_triggered,omitempty"`
OnCallError string `json:"oncall_error,omitempty"`
// NotifyStatus reflects the outcome of the alert fan-out:
// "pending" — record persisted, fan-out not yet attempted
// "sent" — every enabled channel returned success
// "partial" — at least one channel succeeded, at least one failed
// "failed" — no channel succeeded
NotifyStatus string `json:"notify_status,omitempty"`
NotifyError string `json:"notify_error,omitempty"`
CreatedAt time.Time `json:"created_at"`
AckedAt *time.Time `json:"acked_at,omitempty"`
ResolvedAt *time.Time `json:"resolved_at,omitempty"`
Content map[string]interface{} `json:"content,omitempty"`
// AssignedTeamID and AssignedMemberIDs record an operator's
// assignment for this incident. Routing logic will read
// these to pick channels per assignee; the storage layer only
// holds the references. Empty means unassigned.
AssignedTeamID string `json:"assigned_team_id,omitempty"`
AssignedMemberIDs []string `json:"assigned_member_ids,omitempty"`
}
IncidentRecord is the durable shape of an incident. It mirrors the runtime models.Incident plus the audit fields the UI needs (when it happened, who got notified, was it acked, raw payload for debugging).
func (*IncidentRecord) EffectiveOrigin ¶ added in v1.4.8
func (r *IncidentRecord) EffectiveOrigin() string
EffectiveOrigin returns the record's explicit Origin, or derives one from Source when Origin is empty — the case for records persisted before the Origin field existed. Callers that classify or count by origin MUST use this rather than reading Origin directly so legacy rows are attributed instead of dropped into an "" bucket.
type IncidentSearchPager ¶ added in v1.4.13
type IncidentSearchPager interface {
// CountIncidentsMatching returns the per-origin tally and total of
// UNRESOLVED (open) incidents matching query, computed without
// materializing rows. Resolved matches are excluded, mirroring
// CountIncidents. An empty query counts every unresolved incident (same as
// IncidentPager.CountIncidents).
CountIncidentsMatching(query string) (IncidentCounts, error)
// CountIncidentsMatchingByStatus returns the per-origin × per-status tally
// of incidents matching query, computed without materializing rows — the
// search-path twin of IncidentPager.CountIncidentsByStatus. An empty query
// counts every incident (same as CountIncidentsByStatus), so the Incidents
// page shows server-authoritative per-status counts over a filtered feed
// too, never a tally of the loaded page.
CountIncidentsMatchingByStatus(query string) (IncidentStatusCounts, error)
// SearchIncidentsPage returns one bounded page of incidents matching
// query, newest first, filtered to origin (empty = all origins), skipping
// offset rows and returning at most limit rows. The page lists ALL
// matching incidents (resolved and open alike); only the counts are
// unresolved-only. An empty query degrades to the plain list page.
// limit <= 0 uses DefaultIncidentPageSize.
SearchIncidentsPage(query, origin string, offset, limit int) ([]*IncidentRecord, error)
}
IncidentSearchPager is an optional capability a backend may implement on top of Searcher: the search-path twin of IncidentPager. It lets a backend count and page full-text search matches without materializing the whole match set, so a broad query against a large history returns the first page in one bounded query plus one count query. Only the Postgres backend implements it (it is the only unbounded Searcher); callers type-assert and fall back to a bounded SearchIncidents when the assertion fails.
type IncidentStatusCounts ¶ added in v1.4.14
type IncidentStatusCounts struct {
AIDetect StatusCounts
Webhook StatusCounts
Total StatusCounts
}
IncidentStatusCounts is the whole-set per-origin × per-status tally the count surfaces (the Now page, the header badge, the Incidents page) display. Each origin bucket carries its own open/acked/resolved/total and Total is the both-origins sum, so AIDetect.X + Webhook.X == Total.X for every status X. Like IncidentCounts it is computed WITHOUT materializing rows — one COUNT(*) FILTER (…) on SQL backends, a single pass over the capped in-memory slice on file/memory — so a large history never has to be loaded to render a count. It is the single authoritative source for every number the count surfaces show, so those surfaces can never disagree.
func AssembleStatusCounts ¶ added in v1.4.14
func AssembleStatusCounts(total, ai StatusCounts) IncidentStatusCounts
AssembleStatusCounts builds the per-origin × per-status result from the whole-set totals and the ai_detect slice, deriving webhook as the complement (total − ai_detect) so AIDetect + Webhook == Total for every status regardless of how a legacy empty-origin row is classified in SQL. SQL backends compute `total` and `ai_detect` with COUNT/FILTER and hand them here rather than counting webhook separately.
func StatusCountsOf ¶ added in v1.4.14
func StatusCountsOf(recs []*IncidentRecord) IncidentStatusCounts
StatusCountsOf tallies a materialized set of records into the per-origin × per-status breakdown, classifying each row via EffectiveOrigin (so legacy empty-origin rows land in webhook) and bucketing by its stored status. The file and memory backends use it over their capped in-memory slice, and the controllers' fallback path (a backend with no bounded pager) uses it over the materialized window, so every path produces the identical shape.
type Lifecycle ¶ added in v1.4.4
type Lifecycle interface {
// PurgeOlderThan deletes every record in domain whose natural age
// column is strictly older than cutoff, returning the number deleted.
// The compared column is fixed per domain (see DomainIncidents /
// DomainAnalyses / DomainBlobs). An unknown domain returns
// ErrUnknownDomain and deletes nothing.
PurgeOlderThan(domain string, cutoff time.Time) (int, error)
// DeleteByID deletes one record from domain by its primary key — an
// incident/analysis id, or a blob name. Returns ErrNotFound when the
// id is unknown and ErrUnknownDomain for a domain outside the fixed
// set.
DeleteByID(domain, id string) error
}
Lifecycle is an optional capability a backend may implement on top of Provider. It is a mechanical, tier-neutral delete primitive: it carries NO org or policy concept — the caller decides what to purge and when. The enterprise retention policy engine consumes it; single-tenant OSS may call it directly. Backends that cannot delete efficiently (file, redis stub) do not implement it; callers type-assert and treat a failed assertion as "purge unsupported" rather than silently succeeding. The Postgres and memory backends implement it.
type ModelState ¶ added in v1.4.4
type ModelState struct {
OrgID string `json:"org_id"`
Agent string `json:"agent"`
Key string `json:"key"`
Version int `json:"version"`
UpdatedAt time.Time `json:"updated_at"`
// Data is the opaque, consumer-defined payload, stored verbatim.
Data []byte `json:"data"`
}
ModelState is the envelope around one persisted model artifact. Data is opaque to OSS; the metadata (OrgID, Agent, Key, Version, UpdatedAt) lets the consumer version its state and lets retention tooling reason about age without interpreting the payload.
type ModelStore ¶ added in v1.4.4
type ModelStore struct {
// contains filtered or unexported fields
}
ModelStore is a thin, unopinionated helper over a Provider that persists ModelState artifacts under the models/ namespace. It owns the naming convention and metadata stamping only; it never inspects Data.
func NewModelStore ¶ added in v1.4.4
func NewModelStore(p Provider) *ModelStore
NewModelStore returns a ModelStore backed by p.
func (*ModelStore) Get ¶ added in v1.4.4
func (s *ModelStore) Get(orgID, agent, key string) (*ModelState, error)
Get returns the artifact (org, agent, key), or (nil, nil) when it has never been persisted — mirroring the ReadBlob "fresh start" contract so a consumer's first-run path stays a single nil check.
func (*ModelStore) List ¶ added in v1.4.4
func (s *ModelStore) List(orgID, agent string) ([]*ModelState, error)
List returns every model artifact persisted under (org, agent), in no guaranteed order — callers sort. It returns an empty slice when none exist (mirroring the Get "fresh start" contract), never ErrNotFound. Each returned ModelState carries the verbatim opaque Data; the consumer decodes it. The list is unbounded: a model-state namespace is naturally bounded per org × agent (one artifact per learned key), so no cap is imposed here — a caller that needs one applies it.
func (*ModelStore) Purge ¶ added in v1.4.4
func (s *ModelStore) Purge(orgID, agent, key string) error
Purge removes the artifact (org, agent, key) via the storage.Lifecycle delete primitive. It returns ErrUnsupported when the backend does not implement Lifecycle (file / community), so a retention caller fails closed rather than silently no-op'ing. ErrNotFound is returned when the artifact does not exist.
func (*ModelStore) Put ¶ added in v1.4.4
func (s *ModelStore) Put(orgID, agent, key string, version int, data []byte) error
Put persists data as the artifact (org, agent, key) at version, stamping UpdatedAt. data is stored verbatim and opaquely; pass nil to persist an empty artifact. A copy of data is taken so the caller may reuse the slice (suspenders against a pooled request buffer being aliased into persisted state, golden rule #11).
type PostgresOptions ¶ added in v1.4.4
type PostgresOptions struct {
DSN string // postgres connection string or DSN URL
}
PostgresOptions configures the Postgres backend. The DSN may be left empty in code when the factory will fall back to the POSTGRES_DSN environment variable.
type Provider ¶
type Provider interface {
// ReadBlob returns the contents previously written under name.
// Missing blobs MUST return (nil, nil) — not ErrNotFound — so the
// agent's "fresh start" path stays a single line.
ReadBlob(name string) ([]byte, error)
// WriteBlob atomically replaces the blob stored under name.
WriteBlob(name string, data []byte) error
// ListBlobs returns every blob whose name begins with prefix, as
// (name, data) pairs in no guaranteed order. It is the enumeration
// primitive the model-state seam (ModelStore.List) rides to list all
// learned artifacts under a namespace (models/<org>/<agent>/…). A
// prefix that matches nothing returns an empty result, never
// ErrNotFound, mirroring the ReadBlob "fresh start" contract. The
// returned Data slices are copies the caller owns. The list is
// unbounded — a namespace is naturally bounded per org × agent (one
// artifact per learned key), so callers that need a cap apply it.
ListBlobs(prefix string) ([]Blob, error)
// SaveIncident appends a new incident to the store. Subsequent
// SaveIncident calls with the same ID overwrite the existing record
// (used by the ack path). The file backend caps its single-file
// history at MaxIncidents (dropping the oldest on save); database
// backends keep history unbounded and rely on storage.Lifecycle for
// retention instead.
SaveIncident(rec *IncidentRecord) error
// UpdateIncidentAck stamps an existing incident as acknowledged.
// Returns ErrNotFound when the id is unknown.
UpdateIncidentAck(id string, ackedAt time.Time) error
// GetIncident returns one incident or ErrNotFound.
GetIncident(id string) (*IncidentRecord, error)
// ListIncidents returns the most recent incidents, newest first.
// limit <= 0 returns the full window.
ListIncidents(limit int) ([]*IncidentRecord, error)
// SaveAnalysis stores (or replaces by ID) one analyze-mode run.
SaveAnalysis(rec *AnalysisRecord) error
// GetAnalysis returns one analysis or ErrNotFound.
GetAnalysis(id string) (*AnalysisRecord, error)
// ListAnalysesByIncident returns all analyses for one incident,
// newest first. limit <= 0 returns the full window.
ListAnalysesByIncident(incidentID string, limit int) ([]*AnalysisRecord, error)
// ListAnalyses returns analyses across all incidents, newest first.
// limit <= 0 returns the full window.
ListAnalyses(limit int) ([]*AnalysisRecord, error)
// DeleteAnalysis removes one analysis. Returns ErrNotFound when the
// id is unknown.
DeleteAnalysis(id string) error
// Close releases any underlying resources (file handles, redis
// connections, db pools). Calling Close on a closed provider is a
// no-op.
Close() error
}
Provider is the storage interface used by the agent and incident service.
func New ¶
New constructs the configured backend. The postgres backend stores incidents, analyses, and blobs in a single database; the file and redis backends keep blobs alongside their other records.
func NewDatabase ¶
func NewDatabase(_ DatabaseOptions) (Provider, error)
NewDatabase is a placeholder that returns ErrUnsupported.
func NewFile ¶
func NewFile(opts FileOptions) (Provider, error)
NewFile returns a Provider backed by the local filesystem.
func NewMemory ¶
func NewMemory() Provider
NewMemory returns a Provider that keeps all state in memory. Intended for tests; never select via the config factory.
func NewPostgres ¶ added in v1.4.4
func NewPostgres(opts PostgresOptions) (Provider, error)
NewPostgres opens a connection to Postgres, runs idempotent migrations, and returns a ready Provider. Callers must call Close when done.
The initial reachability check is bounded, retried, and logged so an unreachable or slow-to-start database can never turn into a silent restart loop: each probe is capped at perPingTimeout, the loop retries with backoff until the connectBudget is exhausted, and every attempt logs to stderr (captured by `docker logs`) using a redacted host:port/dbname target that never contains the password.
func NewRedis ¶
func NewRedis(_ RedisOptions) (Provider, error)
NewRedis is a placeholder that returns ErrUnsupported. The redis backend will be implemented alongside its first dependency.
type RangeLister ¶ added in v1.4.8
type RangeLister interface {
// ListIncidentsInRange returns incidents with start <= CreatedAt < end,
// newest first. limit <= 0 returns the full window. A zero end is
// treated as an open upper bound (all incidents at or after start).
ListIncidentsInRange(start, end time.Time, limit int) ([]*IncidentRecord, error)
}
RangeLister is an optional capability a backend may implement on top of Provider: return only the incidents whose CreatedAt falls in a [start, end) window, newest first. It exists so a large backend (Postgres, enterprise/HA) can push the CreatedAt bound into SQL and avoid scanning the full history for a windowed aggregate report. OSS never requires it — the aggregate assembler type-asserts it (mirror of Searcher) and falls back to ListIncidents(0) + an in-service CreatedAt filter when a backend does not implement it, so file/memory backends work unchanged. end.IsZero() means "no upper bound" (open-ended window).
type RedisOptions ¶
type RedisOptions struct {
Host string
Port int
Password string
DB int
InsecureSkipVerify bool
KeyPrefix string
MaxIncidents int
}
RedisOptions mirrors the redis sub-block of `storage:`. The redis backend is config-only today — instantiating it returns ErrUnsupported. The struct exists so the config layer can validate its shape.
type SQLAccessor ¶ added in v1.4.8
type SQLAccessor interface {
// DB returns the live *sql.DB backing this Provider. Never nil for a
// backend that implements SQLAccessor.
DB() *sql.DB
}
SQLAccessor is an optional capability a backend may implement on top of Provider. It exposes the backend's underlying *sql.DB so an out-of-tree consumer — the enterprise module and the OSS Postgres catalog store — can run its OWN table-agnostic migrations (via RunSQLMigrations) and typed queries on the SAME connection pool the Provider already owns, instead of opening a second pool.
Only the Postgres backend implements it; file / memory / redis do not, so a type assertion `store.(storage.SQLAccessor)` IS the backend-type switch the boot path selects on (Postgres ⇒ typed signal tables; anything else ⇒ the inline whole-blob path). It carries no tier or table knowledge: OSS exposes only the pool and never names or creates an enterprise table, so the one-way import stays intact — the enterprise module obtains the pool through this seam and manages its own schema on it.
The returned *sql.DB is owned by the Provider; callers MUST NOT Close it (the Provider's own Close does that). It is safe for concurrent use.
type Searcher ¶ added in v1.4.4
type Searcher interface {
// SearchIncidents returns incidents whose title, service, source,
// or JSON body match the case-insensitive query, newest first.
// An empty query returns the most recent incidents (same as
// ListIncidents). limit <= 0 returns the full window.
SearchIncidents(query string, limit int) ([]*IncidentRecord, error)
// SearchAnalyses returns analyses whose JSON body matches the
// case-insensitive query, newest first. limit <= 0 returns the
// full window.
SearchAnalyses(query string, limit int) ([]*AnalysisRecord, error)
}
Searcher is an optional capability a backend may implement on top of Provider. It exposes full-text-style search over incidents and analyses. Backends that cannot search efficiently (memory, file) do not implement it; callers type-assert and fall back to ListIncidents when the assertion fails. The Postgres backend implements it.
type StatusCounts ¶ added in v1.4.14
StatusCounts is the per-status split of a set of incidents: how many are open, acked, and resolved, with Total the sum of the three. The buckets are mutually exclusive and match both how the UI labels an incident and how the row is stored:
Open = resolved = false AND acked_at IS NULL Acked = resolved = false AND acked_at IS NOT NULL Resolved = resolved = true Total = Open + Acked + Resolved (every row)