storage

package
v1.4.15 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jul 24, 2026 License: MIT Imports: 18 Imported by: 0

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

View Source
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.

View Source
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.

View Source
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.

View Source
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.

View Source
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.

View Source
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).

View Source
const MaxIncidentsDefault = 1000

MaxIncidentsDefault is the default rolling cap for the file backend. Older records are dropped on SaveIncident when the cap is exceeded.

View Source
const ModelStateNamespace = "models"

ModelStateNamespace is the blob-name prefix under which every learned model artifact is persisted: <namespace>/<org>/<agent>/<key>.

Variables

View Source
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.

View Source
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).

View Source
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.

View Source
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

func NormalizeOrgID(s string) string

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

func OriginFromSource(source string) string

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

func RunSQLMigrations(db *sql.DB, fsys fs.FS, dir, ledgerTable string) error

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

func WithMigrationLock(db *sql.DB, fn func() error) error

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

type Blob struct {
	Name string
	Data []byte
}

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

type DatabaseOptions struct {
	Driver string // postgres | mysql | sqlite
	DSN    string
}

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

type IncidentCounts struct {
	AIDetect int
	Webhook  int
	Total    int
}

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

func New(c Config) (Provider, error)

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

type StatusCounts struct {
	Open     int
	Acked    int
	Resolved int
	Total    int
}

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)

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL