Documentation
¶
Index ¶
- Variables
- func BuildListCaseConfig(opts ...ListCaseOption) *listCaseConfig
- type ActionArchiveScope
- type ActionCommentRepository
- type ActionEventRepository
- type ActionListOptions
- type ActionMessageRepository
- type ActionRepository
- type ActionStepRepository
- type AssigneeRankingRepository
- type AssistLogRepository
- type CaseArchiveScope
- type CaseMessageRepository
- type CaseProposalRepository
- type CaseRepository
- type EmbedClient
- type HomeMessageRepository
- type ImportRepository
- type JobRunEventRepository
- type JobRunLogRepository
- type JobRunRepository
- type JobSlotRepository
- type KnowledgeListOptions
- type KnowledgeRepository
- type ListCaseOption
- type MemoArchiveScope
- type MemoListOptions
- type MemoRepository
- type NotificationSlotRepository
- type PolicyClient
- type ReactionClaimRepository
- type Repository
- type SessionRepository
- type SlackRepository
- type SlackUserRepository
- type SourceRepository
- type TagRepository
- type UserPreferenceRepository
Constants ¶
This section is empty.
Variables ¶
var ErrActionCommentExists = goerr.New("action comment already exists")
ErrActionCommentExists is returned when Create is called with an ID that already exists. Comment IDs are server-generated UUIDs, so a collision is a generator bug rather than a transient clash — it fails loudly instead of overwriting somebody else's comment.
var ErrActionCommentNotFound = goerr.New("action comment not found")
ErrActionCommentNotFound is returned when an ActionCommentRepository operation expects an existing comment for the key but none exists. Callers MUST discriminate with errors.Is(err, ErrActionCommentNotFound) so a storage failure is never mistaken for absence.
var ErrJobRunEventExists = goerr.New("job run event sequence already exists")
ErrJobRunEventExists is returned when JobRunEventRepository.Append is called with a Sequence that already exists for the same (key, runID). This signals a sequencer bug (e.g. two emitters not sharing a counter) rather than a transient collision; the caller should fail loudly.
var ErrJobRunLogExists = goerr.New("job run log already exists")
ErrJobRunLogExists is returned when JobRunLogRepository.Create is called with a (key, runID) that already exists. Hard error rather than silent overwrite so a duplicate RunID generation surfaces immediately.
var ErrJobRunLogNotFound = goerr.New("job run log not found")
ErrJobRunLogNotFound is returned when JobRunLogRepository.Get does not find a log for the given (key, runID).
var ErrJobRunNotFound = goerr.New("job run not found")
ErrJobRunNotFound is returned when a JobRunRepository operation expects an existing record for the key but none exists. Callers that treat "no prior run" as a normal idle case must check for this with errors.Is(err, ErrJobRunNotFound) rather than parsing strings.
var ErrJobSlotNotHeld = goerr.New("job slot is not held by this holder")
ErrJobSlotNotHeld is returned by JobSlotRepository.Renew when the record for the index is gone or owned by a different holder — the caller's slot expired and was taken over. Callers must discriminate with errors.Is.
var ErrMemoListOptions = goerr.New("invalid memo list options")
ErrMemoListOptions is returned when MemoListOptions carries a filter that can never match, e.g. a creation-time window whose lower bound is not before its upper bound.
Functions ¶
func BuildListCaseConfig ¶
func BuildListCaseConfig(opts ...ListCaseOption) *listCaseConfig
BuildListCaseConfig builds a listCaseConfig from options
Types ¶
type ActionArchiveScope ¶
type ActionArchiveScope int
ActionArchiveScope selects which slice of an action list to return.
const ( // ActionArchiveScopeActiveOnly returns only non-archived actions. // This is the zero value and the default behaviour. ActionArchiveScopeActiveOnly ActionArchiveScope = iota // ActionArchiveScopeArchivedOnly returns only archived actions. ActionArchiveScopeArchivedOnly // ActionArchiveScopeAll returns both active and archived actions. ActionArchiveScopeAll )
func (ActionArchiveScope) Allows ¶
func (s ActionArchiveScope) Allows(isArchived bool) bool
Allows reports whether an action with the given archived state passes this scope's filter.
type ActionCommentRepository ¶ added in v0.3.0
type ActionCommentRepository interface {
// Create inserts a new comment. comment.ActionID must equal the actionID
// parameter. An ID that already exists fails with ErrActionCommentExists.
Create(ctx context.Context, workspaceID string, actionID int64, comment *model.ActionComment) error
// Update replaces an existing comment. comment.ActionID must equal the
// actionID parameter. A comment that no longer exists fails with
// ErrActionCommentNotFound; the check and the write are atomic against a
// concurrent Delete.
Update(ctx context.Context, workspaceID string, actionID int64, comment *model.ActionComment) error
// Get retrieves a single comment by id. A missing comment is reported as
// ErrActionCommentNotFound; every other failure is returned as itself.
Get(ctx context.Context, workspaceID string, actionID int64, commentID string) (*model.ActionComment, error)
// List returns comments for the action, newest first. A non-positive limit
// falls back to 100. cursor is the last-seen comment ID for pagination; ""
// means start from the newest. The returned cursor is "" when there are no
// more comments.
List(ctx context.Context, workspaceID string, actionID int64, limit int, cursor string) ([]*model.ActionComment, string, error)
// Delete removes a single comment. Deleting a non-existent comment is a no-op.
Delete(ctx context.Context, workspaceID string, actionID int64, commentID string) error
}
ActionCommentRepository persists Web-UI-authored comments for an Action. These are distinct from ActionMessageRepository, which stores Slack thread replies ingested from the Action's Slack thread.
Create and Update are separate rather than one upsert: a plain Set would silently resurrect a comment its author had already deleted (an edit in a second tab reads the comment, the first tab deletes it, and the edit writes it back). The same reasoning already governs JobRunLogRepository — see firestore.setExistingLog.
type ActionEventRepository ¶
type ActionEventRepository interface {
// Put inserts a new event. The ID must be unique within the action.
Put(ctx context.Context, workspaceID string, actionID int64, event *model.ActionEvent) error
// List returns events for the action, newest first. limit must be > 0.
// cursor is the last-seen event ID for pagination; "" means start from the
// newest. The returned cursor is "" when there are no more events.
List(ctx context.Context, workspaceID string, actionID int64, limit int, cursor string) ([]*model.ActionEvent, string, error)
}
ActionEventRepository persists structural change events for an Action.
type ActionListOptions ¶
type ActionListOptions struct {
// ArchiveScope selects active / archived / both. Defaults to active only.
ArchiveScope ActionArchiveScope
// ExcludePrivateCaseActions, when true, drops every action whose parent
// Case is private — unconditionally, regardless of channel membership.
// This is stricter than the membership-based access control applied when
// an auth token is present: it is the policy for entry points (such as
// the MCP endpoint) where private Cases and their Actions must never be
// exposed, not even to members. Defaults to false so existing callers
// keep the membership-based behaviour.
ExcludePrivateCaseActions bool
}
ActionListOptions controls how List / GetByCase / GetByCases filter actions.
type ActionMessageRepository ¶
type ActionMessageRepository interface {
// Put saves a Slack message under a specific action (upsert)
Put(ctx context.Context, workspaceID string, actionID int64, msg *slack.Message) error
// List retrieves messages for a specific action with pagination
// Returns messages in descending order (newest first)
List(ctx context.Context, workspaceID string, actionID int64, limit int, cursor string) ([]*slack.Message, string, error)
}
ActionMessageRepository defines the interface for action-scoped Slack message persistence. These are messages posted into the Slack thread of an Action's notification message.
type ActionRepository ¶
type ActionRepository interface {
// Create creates a new action with auto-generated ID
Create(ctx context.Context, workspaceID string, action *model.Action) (*model.Action, error)
// Get retrieves an action by ID. Archived actions are returned as-is so
// callers can inspect history; UI/agent layers must enforce visibility.
Get(ctx context.Context, workspaceID string, id int64) (*model.Action, error)
// GetByIDs retrieves multiple actions by IDs in a single batch.
// Returns a map keyed by action ID containing only the actions that
// were found; missing IDs are silently absent from the result map.
// Archived actions are included for the same reason Get returns
// them: the GraphQL Action loader is used from sub-resolvers that
// already need history visibility.
GetByIDs(ctx context.Context, workspaceID string, ids []int64) (map[int64]*model.Action, error)
// List retrieves all actions filtered by opts.ArchiveScope.
List(ctx context.Context, workspaceID string, opts ActionListOptions) ([]*model.Action, error)
// Update updates an existing action
Update(ctx context.Context, workspaceID string, action *model.Action) (*model.Action, error)
// Delete permanently removes an action document. This is INTERNAL ONLY:
// callers are limited to the Case-deletion cascade in the usecase layer,
// because the public Action lifecycle no longer exposes deletion. Use
// ArchiveAction at the usecase layer for user-facing removal.
Delete(ctx context.Context, workspaceID string, id int64) error
// GetByCase retrieves all actions associated with a specific case,
// filtered by opts.ArchiveScope.
GetByCase(ctx context.Context, workspaceID string, caseID int64, opts ActionListOptions) ([]*model.Action, error)
// GetByCases retrieves actions for multiple cases (for batch operations).
// Returns a map of case ID to list of actions, filtered by
// opts.ArchiveScope.
GetByCases(ctx context.Context, workspaceID string, caseIDs []int64, opts ActionListOptions) (map[int64][]*model.Action, error)
// GetBySlackMessageTS retrieves an action by its Slack message timestamp.
// Returns ErrNotFound if no action matches. Archived actions ARE returned
// because Slack threads must be resolvable regardless of archive state.
GetBySlackMessageTS(ctx context.Context, workspaceID string, ts string) (*model.Action, error)
}
ActionRepository defines the interface for Action data access
type ActionStepRepository ¶
type ActionStepRepository interface {
// Put inserts or replaces a step. The ID must be unique within the action.
Put(ctx context.Context, workspaceID string, step *model.ActionStep) error
// Get retrieves a single step by id.
Get(ctx context.Context, workspaceID string, actionID int64, stepID string) (*model.ActionStep, error)
// List returns all steps for an action ordered by CreatedAt ascending.
List(ctx context.Context, workspaceID string, actionID int64) ([]*model.ActionStep, error)
// Delete removes a single step. Deleting a non-existent step is a no-op.
Delete(ctx context.Context, workspaceID string, actionID int64, stepID string) error
}
ActionStepRepository persists per-Action ActionStep documents.
type AssigneeRankingRepository ¶ added in v0.3.0
type AssigneeRankingRepository interface {
// Get returns the workspace's stored ranking. Returns the backend's
// ErrNotFound (memory.ErrNotFound / firestore.ErrNotFound) when the
// workspace has none yet.
Get(ctx context.Context, workspaceID string) (*model.AssigneeRanking, error)
// Set writes the ranking wholesale (Validate then persist). Concurrent
// writers are deliberately not coordinated: the value is derived data, so
// two instances recomputing at the same moment simply overwrite each other
// with near-identical rankings.
Set(ctx context.Context, ranking *model.AssigneeRanking) error
}
AssigneeRankingRepository persists the per-workspace assignee ranking that orders the WebUI assignee picker. It is a single document per workspace, so it needs only Get/Set — no List.
type AssistLogRepository ¶
type AssistLogRepository interface {
// Create creates a new assist log entry
Create(ctx context.Context, workspaceID string, caseID int64, log *model.AssistLog) (*model.AssistLog, error)
// List retrieves assist log entries for a specific case with pagination.
// Returns items, totalCount, and error. Items are ordered by CreatedAt descending.
List(ctx context.Context, workspaceID string, caseID int64, limit, offset int) ([]*model.AssistLog, int, error)
}
AssistLogRepository defines the interface for AssistLog data persistence
type CaseArchiveScope ¶ added in v0.3.0
type CaseArchiveScope int
CaseArchiveScope selects which slice of a case list to return.
const ( // CaseArchiveScopeActiveOnly returns only non-archived cases. This is the // zero value, so a caller that names no scope never sees archived cases — // the board, the dashboard, the Job scanner, the agent and MCP tools and // the case_ref picker all rely on that default rather than each // remembering to exclude them. CaseArchiveScopeActiveOnly CaseArchiveScope = iota // CaseArchiveScopeArchivedOnly returns only archived cases. CaseArchiveScopeArchivedOnly // CaseArchiveScopeAll returns both active and archived cases. Intended for // inventory passes (the BigQuery export), not for user-facing listings. CaseArchiveScopeAll )
func (CaseArchiveScope) Allows ¶ added in v0.3.0
func (s CaseArchiveScope) Allows(isArchived bool) bool
Allows reports whether a case with the given archived state passes this scope's filter.
type CaseMessageRepository ¶
type CaseMessageRepository interface {
// Put saves a Slack message under a specific case (upsert)
Put(ctx context.Context, workspaceID string, caseID int64, msg *slack.Message) error
// List retrieves messages for a specific case with pagination
// Returns messages in descending order (newest first)
List(ctx context.Context, workspaceID string, caseID int64, limit int, cursor string) ([]*slack.Message, string, error)
// Prune deletes messages older than the specified time for a specific case
// Returns the number of messages deleted
Prune(ctx context.Context, workspaceID string, caseID int64, before time.Time) (int, error)
}
CaseMessageRepository defines the interface for case-scoped Slack message persistence
type CaseProposalRepository ¶
type CaseProposalRepository interface {
// Save creates or fully overwrites a draft.
Save(ctx context.Context, draft *model.CaseProposal) error
// Get retrieves a draft by ID. Returns ErrNotFound when missing.
// Implementations may return ErrNotFound for expired drafts.
Get(ctx context.Context, id model.CaseProposalID) (*model.CaseProposal, error)
// SetMaterialization updates the SelectedWorkspaceID, Materialization, and
// InferenceInProgress fields atomically. Other fields are left untouched.
// Pass m=nil with inProgress=true to mark inference as started.
SetMaterialization(
ctx context.Context,
id model.CaseProposalID,
workspaceID string,
m *model.WorkspaceMaterialization,
inProgress bool,
) error
// Delete removes the draft.
Delete(ctx context.Context, id model.CaseProposalID) error
}
CaseProposalRepository persists workspace-agnostic Case drafts that are created when a user mentions the bot in a non-Case Slack channel. A draft holds collected source material plus the current AI materialization for the selected workspace; switching workspace overwrites the materialization.
type CaseRepository ¶
type CaseRepository interface {
// Create creates a new case with auto-generated ID
Create(ctx context.Context, workspaceID string, c *model.Case) (*model.Case, error)
// Get retrieves a case by ID
Get(ctx context.Context, workspaceID string, id int64) (*model.Case, error)
// GetByIDs retrieves multiple cases by IDs in a single batch.
// Returns a map keyed by case ID containing only the cases that
// were found; missing IDs are silently absent from the result map
// (callers must distinguish "missing" from "found"). This is the
// batch fetch hook used by the GraphQL DataLoader to collapse
// per-row Reporter / Assignees lookups into one repository call
// per request.
GetByIDs(ctx context.Context, workspaceID string, ids []int64) (map[int64]*model.Case, error)
// List retrieves cases with optional filtering.
// Cases in DRAFT status are excluded by default; use ListDrafts to read
// drafts. Passing WithStatus(CaseStatusDraft) honours the filter, but
// callers should generally rely on ListDrafts for the draft-author view.
List(ctx context.Context, workspaceID string, opts ...ListCaseOption) ([]*model.Case, error)
// ListDrafts retrieves all cases in DRAFT status across the workspace.
// Drafts are surfaced workspace-wide so any team member can pick up an
// in-progress entry; the usecase layer applies private-draft access
// control (private drafts are visible only to their reporter).
ListDrafts(ctx context.Context, workspaceID string) ([]*model.Case, error)
// Update updates an existing case
Update(ctx context.Context, workspaceID string, c *model.Case) (*model.Case, error)
// Transact reads the case, hands it to fn for mutation, and writes it back
// inside a single transaction, returning the written case. Unlike a plain
// Get followed by Update — which races with a concurrent edit in the window
// between them — the read fn observes IS the state the write is applied to,
// so simultaneous "assign me" actions cannot clobber one another and a
// caller may compute a before/after diff that is guaranteed to describe
// exactly what was persisted. A missing case fails with ErrNotFound. The
// full case document is rewritten, so model invariants are re-validated.
//
// fn owns every field it changes, including UpdatedAt — the repository
// never reads the clock. An fn that changes nothing simply leaves the
// document byte-identical (the write still happens; do not rely on it being
// skipped). An error returned by fn aborts the transaction without writing
// and is propagated to the caller with its chain intact, so errors.Is /
// errors.As on the result still work.
//
// fn MUST be idempotent and free of external side effects. The Firestore
// backend retries the closure on contention, so a Slack post or a counter
// increment inside fn can run more than once; a captured result variable
// must be reset at the top of fn so a retry cannot accumulate onto the
// previous attempt's value. Act on the outcome after Transact returns, not
// inside it. fn must also not call back into the repository — the in-memory
// backend holds its write lock for the duration of the call.
Transact(ctx context.Context, workspaceID string, id int64, fn func(*model.Case) error) (*model.Case, error)
// Delete deletes a case by ID
Delete(ctx context.Context, workspaceID string, id int64) error
// GetBySlackChannelID retrieves a case by its Slack channel ID.
// Returns nil, nil if no case is found with the given channel ID.
GetBySlackChannelID(ctx context.Context, workspaceID string, channelID string) (*model.Case, error)
// GetBySlackThread retrieves a thread-mode case by its monitored channel
// and thread timestamp. Returns nil, nil if no matching case exists. Used
// for thread-mode case lookup (idempotent creation and reply ingest).
GetBySlackThread(ctx context.Context, workspaceID string, channelID string, threadTS string) (*model.Case, error)
// GetByRequestKey retrieves a case by its request key.
// Returns nil, nil if no case is found with the given key.
GetByRequestKey(ctx context.Context, workspaceID string, key string) (*model.Case, error)
// ScanAll streams every Case in the workspace — including drafts and any
// document whose Status does not match a known value — to fn, in unspecified
// order. It exists for whole-collection passes (the `validate --check-db`
// consistency check); List and ListDrafts are status-filtered, so a document
// carrying an unexpected Status would be silently skipped by both.
//
// fn MUST NOT call back into the repository: the in-memory backend holds its
// read lock for the duration of the scan. Collect what the caller needs and
// perform any lookups after ScanAll returns. An error returned by fn aborts
// the scan and is propagated unwrapped, so errors.Is / errors.As on the
// result still work.
ScanAll(ctx context.Context, workspaceID string, fn func(*model.Case) error) error
}
CaseRepository defines the interface for Case data access
type EmbedClient ¶
type EmbedClient interface {
GenerateEmbedding(ctx context.Context, dimension int, input []string) ([][]float64, error)
}
EmbedClient produces fixed-dimension embedding vectors for the given input strings. The signature mirrors gollem.LLMClient.GenerateEmbedding so any gollem client (in practice, the Gemini client wired via the dedicated embedding configuration) satisfies it directly.
type HomeMessageRepository ¶
type HomeMessageRepository interface {
// Add appends one generated message (Validate then persist).
Add(ctx context.Context, msg *model.HomeMessage) error
// ListRecent returns the user's most recent messages, newest first, up to
// limit. Returns an empty slice when the user has none.
ListRecent(ctx context.Context, userID string, limit int) ([]*model.HomeMessage, error)
}
HomeMessageRepository persists LLM-generated home messages append-only, per user. There is no Update or Delete: freshness is judged by the caller from CreatedAt, and history is retained deliberately (anti-repetition input).
type ImportRepository ¶
type ImportRepository interface {
// Create persists a new ImportSession. Implementations MUST call
// Validate on the session before write.
Create(ctx context.Context, workspaceID string, s *model.ImportSession) (*model.ImportSession, error)
// Update overwrites an existing ImportSession in place. Used by the
// execute path to advance status, fill ExecutedAt, and stamp per-Case
// results into the snapshot. Implementations MUST call Validate
// before write.
Update(ctx context.Context, workspaceID string, s *model.ImportSession) (*model.ImportSession, error)
// Get retrieves an ImportSession by ID. Returns ErrNotFound (the
// repository's standard sentinel) when missing.
Get(ctx context.Context, workspaceID string, id model.ImportSessionID) (*model.ImportSession, error)
}
ImportRepository persists ImportSession documents that drive the YAML → Case/Action wizard. The session is created (status=pending) by createCaseImport and advanced exactly once to applied / failed by executeCaseImport; no Delete method exists because the spec keeps sessions indefinitely and exposes them only by URL.
type JobRunEventRepository ¶
type JobRunEventRepository interface {
// Append writes one event keyed by ev.EventID. Both EventID and
// Sequence must be set by the caller; Sequence must be strictly
// increasing across calls in the same Run. Backends use Create
// (not Set) so a duplicate EventID surfaces as ErrJobRunEventExists.
Append(ctx context.Context, ev *model.JobRunEvent) error
// AppendNext writes one event and allocates its Sequence itself, in the
// same atomic operation as the write. ev.Sequence is ignored on input
// and set on return, so a caller that needs to reference this event
// from a later one (ParentSequence) reads it back from ev.
//
// It exists because a durable agent run's transitions are spread
// across claims and instances, so no in-process counter can allocate
// for it: two instances appending to the same run would hand out the
// same number, and List — which orders by Sequence — would render the
// timeline in an order that never happened. Allocating inside the
// write is what makes concurrent appenders safe.
//
// Sequence values are strictly increasing but NOT guaranteed
// contiguous: an allocation whose write is rolled back leaves a gap.
// Ordering is the contract; density is not.
AppendNext(ctx context.Context, ev *model.JobRunEvent) error
// LatestLLMResponseSequence returns the Sequence of the run's most
// recent LLM_RESPONSE event, or 0 when it has none.
//
// A TOOL_CALL event must point at the LLM_RESPONSE whose tool_use it
// carries out (JobRunEvent.ParentSequence). A durable run's LLM call
// and the tool calls it asked for are separate checkpointed
// transitions, so they may be driven by different claims — and a
// claim that starts mid-sequence has no in-memory record of the
// response that preceded it. This is how it recovers the link.
//
// It is ordered on Sequence alone, so it needs no composite index.
LatestLLMResponseSequence(ctx context.Context, key model.JobRunKey, runID string) (int64, error)
// List returns events for (key, runID) in ascending Sequence order
// (not doc-ID order — doc IDs are UUIDv7 and may diverge under
// clock skew).
List(ctx context.Context, key model.JobRunKey, runID string) ([]*model.JobRunEvent, error)
}
JobRunEventRepository persists the per-Run timeline of events (LLM_REQUEST / LLM_RESPONSE / TOOL_CALL / RUN_ERROR). Stored at:
workspaces/{WS}/cases/{Case}/jobRuns/{Job}/logs/{Run}/events/{EventID}
EventID is a UUIDv7 (timestamp-prefixed) chosen for Firestore-console readability and global uniqueness. The authoritative monotonic order is the Sequence field, not the doc ID — List MUST OrderBy("Sequence").
Within a single Run, exactly one runSequencer instance owns Sequence allocation — both the per-call appends from the trace.Handler AND any RUN_ERROR emits from JobRunner go through the same sequencer.
type JobRunLogRepository ¶
type JobRunLogRepository interface {
// Create writes the RUNNING-stage log. Errors with
// ErrJobRunLogExists if a doc for the same (key, runID) already
// exists; backends MUST use Firestore Create (or equivalent) so the
// duplicate is rejected by the storage layer.
Create(ctx context.Context, log *model.JobRunLog) error
// Finish transitions an existing log to its terminal stage
// (SUCCESS or FAILED). The caller supplies the full *JobRunLog
// with Stage / EndedAt / Error populated; the implementation just
// persists it.
Finish(ctx context.Context, log *model.JobRunLog) error
// Suspend transitions an existing log to the non-terminal
// AWAITING_INPUT stage. The caller supplies the full *JobRunLog with
// Stage=AWAITING_INPUT and PendingInteraction populated; EndedAt stays
// zero. Errors with ErrJobRunLogNotFound if the log does not exist.
Suspend(ctx context.Context, log *model.JobRunLog) error
// Resume transitions a suspended log back to RUNNING. The caller
// supplies the full *JobRunLog with Stage=RUNNING and
// PendingInteraction cleared (nil). Errors with ErrJobRunLogNotFound if
// the log does not exist.
Resume(ctx context.Context, log *model.JobRunLog) error
// Get returns the log identified by (key, runID), or
// (nil, ErrJobRunLogNotFound) when no such log exists.
Get(ctx context.Context, key model.JobRunKey, runID string) (*model.JobRunLog, error)
// List returns logs under (key) in descending StartedAt order, up
// to limit. limit <= 0 means no limit. Implemented as a single
// subcollection scan per call (no cross-Job aggregation here).
List(ctx context.Context, key model.JobRunKey, limit int) ([]*model.JobRunLog, error)
}
JobRunLogRepository persists one *invocation* of a Job (= one Run) against a Case. Stored at:
workspaces/{WorkspaceID}/cases/{CaseID}/jobRuns/{JobID}/logs/{RunID}
The Stage transitions RUNNING -> SUCCESS|FAILED. Callers Create the log in RUNNING state once prompts are ready, then Finish it when the agent loop terminates. A Run that crashes mid-flight leaves the RUNNING log in place; that is intentional so the events captured up to the crash remain attributable.
type JobRunRepository ¶
type JobRunRepository interface {
// Get returns the JobRun for the given key, or (nil, ErrJobRunNotFound)
// when no prior run exists. Callers use this for scheduled due-checks
// and for surface observability; both treat absence as "never run".
Get(ctx context.Context, key model.JobRunKey) (*model.JobRun, error)
// ListByCase returns every JobRun stored under the given (workspace,
// case) tuple. Implemented as a single Firestore subcollection scan
// per call (no cross-case work), which matches the underlying
// storage layout. The scanner calls this once per OPEN case during a
// tick — typical workspaces have a small number of jobs per case
// (~handful), so a single subcollection query returns the entire
// per-case index that the due-check needs.
ListByCase(ctx context.Context, workspaceID string, caseID int64) ([]*model.JobRun, error)
// TryAcquireLease attempts to take the lock for the given key, valid
// until now+leaseDuration. Returns true if the caller now owns the
// lease, false if a live lease is held by someone else. The first
// acquirer on a previously-absent key also creates the record.
//
// Lease ownership is implicit in the LeaseUntil timestamp — there is
// no separate owner ID because at most one process should be
// scheduling the same (workspace, case, job) at any given moment, and
// a stuck holder simply has its lease reclaimed once LeaseUntil
// elapses.
TryAcquireLease(ctx context.Context, key model.JobRunKey, now time.Time, leaseDuration time.Duration) (acquired bool, err error)
// ReleaseLease clears LeaseUntil (sets it to the zero value) so the
// next acquirer can take the lock immediately. Idempotent: calling
// it without a prior acquisition is a no-op.
ReleaseLease(ctx context.Context, key model.JobRunKey) error
// RecordRun persists the terminal outcome of a Job run. It also
// clears any lease that may still be active (treat RecordRun as
// implying release) AND clears any suspension marker (a terminal run
// is, by definition, no longer awaiting input). lastRunAt is the
// caller's clock at the moment the run completed — repositories do not
// stamp it themselves. runID identifies the specific JobRunLog produced
// by this run and is mirrored into JobRun.LastRunID for cross-reference.
RecordRun(ctx context.Context, key model.JobRunKey, status model.JobRunStatus, lastRunAt time.Time, runID, traceID, errMsg string) error
// Suspend marks the (workspace, case, job) as awaiting user input for
// the given runID and releases any active lease in the same atomic
// step. While SuspendedRunID is set, the scheduler/dispatcher MUST NOT
// start a new run for this tuple (see model.JobRun.IsSuspended). The
// lease is released because a human wait can outlast any lease; the
// suspension marker is the durable "do not double-start" signal.
// suspendedAt is the caller's clock, used later by the unanswered-run
// sweep to expire stale suspensions.
Suspend(ctx context.Context, key model.JobRunKey, runID string, suspendedAt time.Time) error
}
JobRunRepository persists per-(workspace, case, job) execution metadata and provides atomic lease primitives for serialising concurrent runs.
The same document doubles as the run-history record and the lock holder: LeaseUntil represents "in flight" and the rest of the fields represent the most recently completed run. Storage backends must serialise lease transitions (Firestore RunTransaction, in-memory mutex) so two competing acquirers see a consistent view.
type JobSlotRepository ¶ added in v0.3.0
type JobSlotRepository interface {
// List returns every stored slot record in ascending Index order. The
// caller derives "free" from an Index that has no record and from
// model.JobSlot.IsHeld, so an expired record may be returned.
List(ctx context.Context) ([]*model.JobSlot, error)
// TryAcquire claims slot.Index for slot.HolderID when the stored record
// is absent or no longer IsHeld(now). Returns false when a live holder
// is present. The record is validated before the write.
TryAcquire(ctx context.Context, slot *model.JobSlot, now time.Time) (acquired bool, err error)
// Renew pushes ExpiresAt forward while holderID still owns index. It
// returns ErrJobSlotNotHeld when the record is absent or held by another
// holder; it never (re-)creates a record, so a released slot stays free.
Renew(ctx context.Context, index int, holderID string, expiresAt time.Time) error
// Release deletes the record when holderID owns it. Idempotent: an
// absent record, or one taken over by another holder, is a no-op rather
// than an error — releasing must never evict the new holder.
Release(ctx context.Context, index int, holderID string) error
}
JobSlotRepository persists the execution slots backing the deployment-wide concurrency limit on scheduled Job runs (see model.JobSlot).
A free slot has NO record: TryAcquire creates it and Release deletes it, so the stored set is exactly the set of occupied slots. Backends must serialise each slot's transitions per record (Firestore RunTransaction, in-memory mutex) so two acquirers racing for the same index cannot both win.
type KnowledgeListOptions ¶
type KnowledgeListOptions struct {
// TagIDs applies an AND filter: only entries referencing every listed tag id
// are returned. An empty slice returns all entries. Filtering is done in
// memory (no Firestore composite index) — see the repository implementations.
TagIDs []model.TagID
}
KnowledgeListOptions controls how List filters knowledge entries.
type KnowledgeRepository ¶
type KnowledgeRepository interface {
// Create persists a new knowledge entry. The caller assigns the KnowledgeID
// (via model.NewKnowledgeID) before calling; the repository does not generate
// IDs.
Create(ctx context.Context, workspaceID string, knowledge *model.Knowledge) (*model.Knowledge, error)
// Get retrieves a knowledge entry by ID within a workspace.
Get(ctx context.Context, workspaceID string, id model.KnowledgeID) (*model.Knowledge, error)
// List retrieves the knowledge entries of a workspace, filtered by
// opts.TagIDs (AND). Results are sorted by CreatedAt ascending.
List(ctx context.Context, workspaceID string, opts KnowledgeListOptions) ([]*model.Knowledge, error)
// Update persists changes to an existing knowledge entry. The caller's
// pointer is the source of truth for every field.
Update(ctx context.Context, workspaceID string, knowledge *model.Knowledge) (*model.Knowledge, error)
// Delete removes a knowledge entry by ID within a workspace.
Delete(ctx context.Context, workspaceID string, id model.KnowledgeID) error
}
KnowledgeRepository defines the interface for Knowledge data access. Every method is workspace-scoped; knowledge is shared across the whole workspace and is not tied to a case.
type ListCaseOption ¶
type ListCaseOption func(*listCaseConfig)
ListCaseOption is a functional option for filtering cases in List
func WithArchiveScope ¶ added in v0.3.0
func WithArchiveScope(scope CaseArchiveScope) ListCaseOption
WithArchiveScope selects active / archived / both. Without it the zero value (active only) applies.
func WithStatus ¶
func WithStatus(status types.CaseStatus) ListCaseOption
WithStatus filters cases by status
type MemoArchiveScope ¶
type MemoArchiveScope int
MemoArchiveScope selects which slice of a memo list to return.
const ( // MemoArchiveScopeActiveOnly returns only non-archived memos. This is the // zero value and the default: an unspecified List excludes archived memos // so callers never surface soft-deleted memories by accident. MemoArchiveScopeActiveOnly MemoArchiveScope = iota // MemoArchiveScopeArchivedOnly returns only archived memos. MemoArchiveScopeArchivedOnly // MemoArchiveScopeAll returns both active and archived memos. MemoArchiveScopeAll )
func (MemoArchiveScope) Allows ¶
func (s MemoArchiveScope) Allows(isArchived bool) bool
Allows reports whether a memo with the given archived state passes this scope's filter.
type MemoListOptions ¶
type MemoListOptions struct {
// ArchiveScope selects active / archived / both. Defaults to active only.
ArchiveScope MemoArchiveScope
// CreatedAfter, when non-nil, keeps only memos created at or after this
// instant (inclusive lower bound). nil means unbounded.
CreatedAfter *time.Time
// CreatedBefore, when non-nil, keeps only memos created strictly before
// this instant (exclusive upper bound). nil means unbounded. The interval
// is half-open so consecutive windows never report the same memo twice.
CreatedBefore *time.Time
}
MemoListOptions controls how List filters memos.
func (MemoListOptions) Allows ¶ added in v0.3.0
func (o MemoListOptions) Allows(m *model.Memo) bool
Allows reports whether a memo passes every filter in these options. Both repository backends run this single predicate so their results cannot drift.
func (MemoListOptions) Validate ¶ added in v0.3.0
func (o MemoListOptions) Validate() error
Validate reports whether the options describe a window that can match at all. Repositories call it at the top of List so a contradictory filter fails loudly instead of silently returning an empty list.
type MemoRepository ¶
type MemoRepository interface {
// Create persists a new memo. The caller assigns the MemoID (via
// model.NewMemoID) before calling; the repository does not generate IDs.
Create(ctx context.Context, workspaceID string, memo *model.Memo) (*model.Memo, error)
// Get retrieves a memo by ID within a Case. Archived memos are returned
// as-is so callers holding the ID can inspect history; List is the
// archive-filtered entry point.
Get(ctx context.Context, workspaceID string, caseID int64, id model.MemoID) (*model.Memo, error)
// GetByIDs retrieves multiple memos by ID within a Case in a single batch.
// Returns a map keyed by MemoID containing only the memos that were found;
// missing IDs are silently absent. Used by the GraphQL dataloader.
GetByIDs(ctx context.Context, workspaceID string, caseID int64, ids []model.MemoID) (map[model.MemoID]*model.Memo, error)
// List retrieves the memos of a Case, filtered by opts (archive scope and
// the optional creation-time window). Returns ErrMemoListOptions when opts
// describe a window that can never match.
List(ctx context.Context, workspaceID string, caseID int64, opts MemoListOptions) ([]*model.Memo, error)
// Update persists changes to an existing memo (including archive/unarchive,
// expressed by setting/clearing ArchivedAt on the caller's pointer).
Update(ctx context.Context, workspaceID string, memo *model.Memo) (*model.Memo, error)
}
MemoRepository defines the interface for Memo data access. Every method is Case-scoped (requires caseID) so a memo can never be read or written outside its parent Case; there is no memoID-only lookup.
type NotificationSlotRepository ¶
type NotificationSlotRepository interface {
// GetActive returns the slot for channelID if ExpiresAt > now, otherwise
// (nil, nil). An expired slot is treated as absent so the caller posts a
// fresh channel message and replaces it via Save.
GetActive(ctx context.Context, channelID string, now time.Time) (*model.NotificationSlot, error)
// Save upserts the slot keyed by ChannelID.
Save(ctx context.Context, slot *model.NotificationSlot) error
// Delete removes the slot, e.g. when chat.update fails and the slot must
// be reset so the next event starts a new channel message.
Delete(ctx context.Context, channelID string) error
}
NotificationSlotRepository persists per-channel notification slots used to aggregate Slack channel-side notifications within a rolling time window. See pkg/usecase/notification_slot.go for the consumer.
type PolicyClient ¶
PolicyClient evaluates a Rego query against an input document and decodes the policy's result into out. It mirrors the opaq.Client.Query shape so the adapter is a thin wrapper, while keeping the usecase / controller layers free of any direct dependency on the Rego engine.
query is a fully-qualified Rego reference such as "data.auth.mcp". input is marshalled to the policy's `input` document; out receives the policy's result (typically a struct with an `allow` boolean). An evaluation that produces no result is reported as an error rather than a zero-valued out.
type ReactionClaimRepository ¶
type ReactionClaimRepository interface {
// Claim atomically records the (workspaceID, sourceChannelID,
// sourceMessageTS) triple. It returns claimed=true for the first caller and
// claimed=false when a claim already exists. Backed by an index-free
// create-if-absent (a document keyed by a deterministic hash of the source
// message), so it is safe across concurrent instances.
Claim(ctx context.Context, workspaceID, sourceChannelID, sourceMessageTS string) (claimed bool, err error)
// Release removes a claim so a future reaction on the same message can retry.
// Called only when case creation failed after a successful Claim (e.g. the
// seed root post failed, or the create turn fell back before asking a
// question). Best-effort; a missing claim is not an error.
Release(ctx context.Context, workspaceID, sourceChannelID, sourceMessageTS string) error
}
ReactionClaimRepository records, once per source message, that a reaction on that message has begun producing a cross-channel case. It is the idempotency gate for reaction-triggered case creation when the reacted message lives outside the workspace's monitored channel — there is no stable case-thread key to dedup on yet, so multiple users reacting (or a re-delivered event) would otherwise each spawn a case.
Same-channel reactions do NOT use this: the reacted message's thread root is a stable key, so the existing turn lock plus Case().GetBySlackThread already dedup that path.
type Repository ¶
type Repository interface {
Case() CaseRepository
Action() ActionRepository
Memo() MemoRepository
Knowledge() KnowledgeRepository
Tag() TagRepository
Slack() SlackRepository
SlackUser() SlackUserRepository
Source() SourceRepository
CaseMessage() CaseMessageRepository
ActionMessage() ActionMessageRepository
ActionEvent() ActionEventRepository
ActionStep() ActionStepRepository
ActionComment() ActionCommentRepository
AssistLog() AssistLogRepository
CaseProposal() CaseProposalRepository
Session() SessionRepository
NotificationSlot() NotificationSlotRepository
JobRun() JobRunRepository
JobRunLog() JobRunLogRepository
JobRunEvent() JobRunEventRepository
JobSlot() JobSlotRepository
Import() ImportRepository
ReactionClaim() ReactionClaimRepository
UserPreference() UserPreferenceRepository
HomeMessage() HomeMessageRepository
AssigneeRanking() AssigneeRankingRepository
// Auth methods
PutToken(ctx context.Context, token *auth.Token) error
GetToken(ctx context.Context, tokenID auth.TokenID) (*auth.Token, error)
DeleteToken(ctx context.Context, tokenID auth.TokenID) error
// Close closes the repository and releases any resources
Close() error
}
Repository defines the interface for data persistence
type SessionRepository ¶
type SessionRepository interface {
// GetByThread returns the Session for (channelID, threadTS), or
// (nil, nil) when no Session exists yet.
GetByThread(ctx context.Context, channelID, threadTS string) (*model.Session, error)
// Put writes the Session.
Put(ctx context.Context, s *model.Session) error
// Claim atomically creates the Session from newSessionFn() when none
// exists for (channelID, threadTS), and returns the stored Session
// either way. An existing Session is returned untouched — Claim never
// overwrites, so the first caller to reach a thread decides what owns
// it (see model.SessionKind) and every later caller observes that
// decision.
//
// It exists because "read, then create later" is not the same thing: a
// host that reads first and writes after its own setup work leaves a
// window in which a concurrent event sees no Session and routes the
// thread somewhere else. Claim is the durable marker a host takes BEFORE
// that work.
Claim(ctx context.Context, channelID, threadTS string, newSessionFn func() *model.Session) (*model.Session, error)
// AdvanceLastMention moves the Session's LastMentionTS forward to mentionTS,
// and does nothing when the stored value is already at or past it.
//
// It is narrow on purpose. LastMentionTS is the cursor the next turn's delta
// scan starts after, so it must be stamped by the call that actually started a
// turn — but that call races the turn it just started, whose completion handler
// writes the same Session row. A full Put from the spawning side would clobber
// the outcome that handler recorded (a pending question, for one); touching one
// field cannot. Monotonic because two triggers may race and the later cursor
// must win regardless of which write lands second.
//
// A missing Session is not an error: the thread it named is gone, and there is
// no cursor to keep.
AdvanceLastMention(ctx context.Context, channelID, threadTS, mentionTS string) error
// AssociateProposal points the Session at the case draft the thread is now
// working on.
//
// It is narrow for the same reason as AdvanceLastMention: the caller has just
// started a turn, and that turn's completion handler writes the same Session
// row. It is also only safe to call once the turn was ACCEPTED — a draft the
// runtime refused must never become the thread's draft, or the accepted turn's
// result would be written into it.
//
// A missing Session is not an error.
AssociateProposal(ctx context.Context, channelID, threadTS string, proposalID model.CaseProposalID) error
// StampLastAction records how a turn ended.
//
// It is narrow for the reason AdvanceLastMention is: this write happens in a
// run's completion handler, which agentkit calls AFTER the terminal transition
// released the thread's subject — so a later turn may already be running and
// writing the same row. A full Put from here would restore this turn's stale
// copy of the cursor the later turn advanced.
//
// A missing Session is not an error.
StampLastAction(ctx context.Context, channelID, threadTS string, ended model.SessionEndReason) error
// SetPendingQuestion records the question form a turn left open, or clears it
// when q is nil. It is narrow for the same reason as StampLastAction.
//
// A missing Session is not an error.
SetPendingQuestion(ctx context.Context, channelID, threadTS string, q *model.PendingQuestion) error
// BindCase points the thread at the Case a create turn committed, and clears
// any pending question in the same write: the case exists, so the form that
// was asked to produce it can no longer be answered.
//
// It is narrow for the same reason as StampLastAction — it runs in the create
// run's completion handler, after the subject was released.
//
// A missing Session is not an error.
BindCase(ctx context.Context, channelID, threadTS string, caseID int64) error
}
SessionRepository persists the per-thread Session every agent host keys its conversation state on.
The lookup key is (ChannelID, ThreadTS). Serialising concurrent turns on one thread is NOT this repository's job: the agent runtime does it, by spawning every turn under the thread's subject, which admits one live run at a time.
type SlackRepository ¶
type SlackRepository interface {
// PutMessage saves a Slack message (upsert)
PutMessage(ctx context.Context, msg *slack.Message) error
// ListMessages retrieves messages from a specific channel within a time range
// Returns messages in descending order (newest first) with pagination support
ListMessages(ctx context.Context, channelID string, start, end time.Time, limit int, cursor string) ([]*slack.Message, string, error)
// PruneMessages deletes messages older than the specified time
// If channelID is empty, deletes from all channels
// Returns the number of messages deleted
PruneMessages(ctx context.Context, channelID string, before time.Time) (int, error)
}
SlackRepository defines the interface for Slack message persistence
type SlackUserRepository ¶
type SlackUserRepository interface {
// GetAll retrieves all Slack users from the database
GetAll(ctx context.Context) ([]*model.SlackUser, error)
// GetByID retrieves a single Slack user by ID
GetByID(ctx context.Context, id model.SlackUserID) (*model.SlackUser, error)
// GetByIDs retrieves multiple Slack users by IDs (for DataLoader batching)
// Returns a map of ID -> SlackUser. Missing users are not included in the map.
GetByIDs(ctx context.Context, ids []model.SlackUserID) (map[model.SlackUserID]*model.SlackUser, error)
// SaveMany saves multiple Slack users (upsert operation)
// Handles Firestore batch write limits (500 per batch) internally
SaveMany(ctx context.Context, users []*model.SlackUser) error
// DeleteAll deletes all Slack users from the database
// Handles Firestore batch delete limits (500 per batch) internally
DeleteAll(ctx context.Context) error
// GetMetadata retrieves refresh metadata
GetMetadata(ctx context.Context) (*model.SlackUserMetadata, error)
// SaveMetadata saves refresh metadata
SaveMetadata(ctx context.Context, metadata *model.SlackUserMetadata) error
}
SlackUserRepository provides database operations for Slack users.
N+1 Prevention Policy: - NO individual Save(user) method - always use SaveMany for batch writes - GetByID is minimal - prefer GetByIDs for batch retrieval via DataLoader - Worker always uses bulk operations: DeleteAll → SaveMany (Replace strategy) - All operations avoid loops with individual DB calls
type SourceRepository ¶
type SourceRepository interface {
// Create creates a new source
Create(ctx context.Context, workspaceID string, source *model.Source) (*model.Source, error)
// Get retrieves a source by ID
Get(ctx context.Context, workspaceID string, id model.SourceID) (*model.Source, error)
// List retrieves all sources
List(ctx context.Context, workspaceID string) ([]*model.Source, error)
// Update updates an existing source
Update(ctx context.Context, workspaceID string, source *model.Source) (*model.Source, error)
// Delete deletes a source by ID
Delete(ctx context.Context, workspaceID string, id model.SourceID) error
}
SourceRepository defines the interface for Source data persistence
type TagRepository ¶
type TagRepository interface {
// Create persists a new tag. The caller assigns the TagID (via
// model.NewTagID) and stamps CreatedAt / UpdatedAt before calling; the
// repository does not generate IDs or timestamps.
Create(ctx context.Context, workspaceID string, tag *model.Tag) (*model.Tag, error)
// Get retrieves a tag by ID within a workspace.
Get(ctx context.Context, workspaceID string, id model.TagID) (*model.Tag, error)
// List retrieves every tag of a workspace, sorted by CreatedAt ascending.
List(ctx context.Context, workspaceID string) ([]*model.Tag, error)
// Update persists changes to an existing tag (only Name is mutable). The
// caller's pointer is the source of truth for every field.
Update(ctx context.Context, workspaceID string, tag *model.Tag) (*model.Tag, error)
// Delete removes a tag by ID within a workspace. The caller (usecase) is
// responsible for refusing deletion of a tag still referenced by any
// Knowledge; the repository performs the raw delete.
Delete(ctx context.Context, workspaceID string, id model.TagID) error
}
TagRepository defines the interface for Tag data access. Every method is workspace-scoped; tags are first-class, workspace-wide classification labels referenced by Knowledge entries via TagID.
type UserPreferenceRepository ¶
type UserPreferenceRepository interface {
// Get returns the user's preference. Returns the backend's ErrNotFound
// (memory.ErrNotFound / firestore.ErrNotFound) when the user has none yet.
Get(ctx context.Context, userID string) (*model.UserPreference, error)
// Set writes the preference wholesale (Validate then persist).
Set(ctx context.Context, pref *model.UserPreference) error
}
UserPreferenceRepository persists per-user settings. It is a single document per user (keyed by Slack User ID), so it needs only Get/Set — no List.
Source Files
¶
- action.go
- action_comment.go
- action_event.go
- action_message.go
- action_step.go
- assignee_ranking.go
- assist_log.go
- case.go
- case_message.go
- case_proposal.go
- embed_client.go
- home_message.go
- import.go
- job_run.go
- job_slot.go
- knowledge.go
- list_case_option.go
- memo.go
- notification_slot.go
- policy.go
- reaction_claim.go
- repository.go
- session.go
- slack.go
- slack_user.go
- source.go
- tag.go
- user_preference.go