package persona import ( "context" "errors" "time" ) // ErrNotFound is returned when an agent lookup fails. Callers compare // with errors.Is(err, ErrNotFound). var ErrNotFound = errors.New("agent not found") // Storage is the persistence interface for the agents system. // // Why: tests substitute fake implementations; production wires // through pkg/logic/storage's Grand Storage which embeds this // interface. Mirrors the three-layer pattern in // pkg/logic/storage/CLAUDE.md (domain → GORM → DB). // // What: Phase 1 CRUD plus Phase 3 trigger queries // (ListDueScheduled, GetAgentByWebhookSecret, // ListAgentsByChatbotChannelFilter, MarkScheduledRun). Trigger // queries are read by the agentsched runner, webhook router, and // chatbot tool provider; all are gated behind the // agents.triggers.enabled convar so old skill-driven paths keep // running until the convar flips. // // Test: see storage_round_trip_test.go for round-trip coverage. type Storage interface { // (Mort's Discord command-binding CRUD — the CommandBindingStorage // embedding — stays a host concern and is NOT part of the executus // persona Storage seam.) // InitializeAgentStorage prepares storage (e.g. AutoMigrate) // and is idempotent. Called from the grand storage's // InitializeAll path. InitializeAgentStorage(ctx context.Context) error // SaveAgent creates or updates an Agent by ID. ID must be // non-empty (Phase 1 admin commands mint a UUID). SaveAgent(ctx context.Context, a *Agent) error // GetAgent returns the agent with the given ID, or ErrNotFound. GetAgent(ctx context.Context, id string) (*Agent, error) // GetAgentByName resolves (owner_id, name) → agent. ownerID // must match exactly (Phase 1 has no shared/public visibility // yet; every agent is owned). GetAgentByName(ctx context.Context, ownerID, name string) (*Agent, error) // ListAgents returns every agent owned by the given member ID, // sorted by Name ASC. ListAgents(ctx context.Context, ownerID string) ([]*Agent, error) // ListAllAgents returns every agent across all owners, sorted by // (OwnerID ASC, Name ASC) so builtin rows (OwnerID="builtin") // group together, then numeric Discord-ID owners in lexical order, // then chatbot-shadow rows whose OwnerID is the chatbot owner's // Discord ID but whose Name carries the "chatbot:" prefix. // // Why: Phase 1 admin commands ran owner-scoped (a steve-owned // agent list shows ONLY steve's rows), which hid builtin and // shadow Agents from the admin view. `.agent list` for admins now // uses this method to surface every row. Non-admin invocations // (or `.agent list --mine`) keep using ListAgents. // // Storage MAY back this with a single full-table scan — admin // row counts are small (dozens to low hundreds), so no need for // pagination at this phase. ListAllAgents(ctx context.Context) ([]*Agent, error) // DeleteAgent removes an agent by ID. Idempotent — deleting a // missing row returns nil. DeleteAgent(ctx context.Context, id string) error // GetAgentByWebhookSecret resolves a posted /webhooks/ URL // to the matching agent. Returns ErrNotFound when no agent has // the secret. Phase 3 webhook router consults this AFTER the // existing Skill lookup falls through, but only when // agents.triggers.enabled is true. // // Empty secret is rejected with ErrNotFound (empty WebhookSecret // rows are NOT webhook-enabled — the application layer guards // this, the lookup defends against accidental match). GetAgentByWebhookSecret(ctx context.Context, secret string) (*Agent, error) // ListAgentsByChatbotChannelFilter returns every agent with a // non-empty ChatbotChannelFilter. Phase 3 chatbot tool provider // uses this on every chatbot turn to assemble the per-channel // tool list (gated by agents.triggers.enabled). The result is // not channel-filtered here — the provider applies the channel // filter predicate (registered in skills.ChannelFilterRegistry) // to each row. // // Why no channel filter at the storage layer: the filter is a // runtime predicate (e.g. dm_only depends on the live Discord // channel kind cache), not a static column we can index on. ListAgentsByChatbotChannelFilter(ctx context.Context) ([]*Agent, error) // ListScheduledAgents returns every agent with a non-empty // Schedule whose NextRunAt is at or before `dueBefore`. Result // is ordered by NextRunAt ASC so the scheduler runner can drain // in oldest-due-first order. Mirrors skills.Storage.ListDueScheduled. // // Phase 3 scheduler reads this on every tick when // agents.triggers.enabled is true. The (Schedule, NextRunAt) // composite index backs the query — see gorm tags on gormAgent. ListScheduledAgents(ctx context.Context, dueBefore time.Time) ([]*Agent, error) // MarkAgentScheduledRun atomically updates LastScheduledRunAt // and NextRunAt for the given agent. Called by the agentsched // runner after each scheduled invocation. Mirrors // skills.Storage.MarkScheduledRun semantics. MarkAgentScheduledRun(ctx context.Context, agentID string, ranAt, nextAt time.Time) error }