state

package
v0.1.0 Latest Latest
Warning

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

Go to latest
Published: Mar 9, 2026 License: MIT Imports: 8 Imported by: 0

Documentation

Overview

Package state manages the SQLite state database for The Forge.

The database lives at ~/.forge/state.db and tracks:

  • workers: active and historical Smith worker processes
  • prs: pull requests created by Forge across anvils
  • events: timestamped log of all significant actions

Index

Constants

View Source
const (
	DefaultMaxCIFixAttempts     = 5
	DefaultMaxReviewFixAttempts = 5
	DefaultMaxRebaseAttempts    = 3
)

Default lifecycle thresholds. These are used by NeedsAttentionBeads and can be overridden via config (settings.max_ci_fix_attempts, etc.).

Variables

This section is empty.

Functions

func DefaultPath

func DefaultPath() (string, error)

DefaultPath returns ~/.forge/state.db.

Types

type DB

type DB struct {
	// contains filtered or unexported fields
}

DB wraps a SQLite connection for Forge state.

func Open

func Open(path string) (*DB, error)

Open opens or creates the state database at the given path. If path is empty, DefaultPath() is used.

func (*DB) ActiveDispatchWorkers

func (db *DB) ActiveDispatchWorkers() ([]Worker, error)

ActiveDispatchWorkers returns active workers that are running primary dispatch pipeline phases (smith, temper, warden). Bellows (PR monitoring) and lifecycle workers (cifix, reviewfix, rebase) are excluded so they don't consume dispatch capacity slots. Stalled workers are included so they continue to count against capacity and prevent the daemon from over-subscribing while stalled processes are still running.

func (*DB) ActiveDispatchWorkersByAnvil

func (db *DB) ActiveDispatchWorkersByAnvil(anvil string) ([]Worker, error)

ActiveDispatchWorkersByAnvil returns active dispatch workers for a given anvil, excluding bellows and lifecycle workers (cifix, reviewfix, rebase). Stalled workers are included so they continue to count against per-anvil capacity.

func (*DB) ActiveWorkerByBead

func (db *DB) ActiveWorkerByBead(beadID string) (*Worker, error)

ActiveWorkerByBead returns the non-terminal worker for a given bead ID.

func (*DB) ActiveWorkerByBeadAndAnvil

func (db *DB) ActiveWorkerByBeadAndAnvil(beadID, anvil string) (*Worker, error)

ActiveWorkerByBeadAndAnvil returns the non-terminal worker for a given bead scoped to a specific anvil. Use this instead of ActiveWorkerByBead when iterating per-anvil to avoid false positives when two anvils share bead IDs.

func (*DB) ActiveWorkers

func (db *DB) ActiveWorkers() ([]Worker, error)

ActiveWorkers returns all workers with non-terminal status (including stalled).

func (*DB) AddBeadCost

func (db *DB) AddBeadCost(beadID, anvil string, input, output, cacheRead, cacheWrite int, cost float64) error

AddBeadCost adds token usage to a bead's cumulative cost.

func (*DB) AddDailyCost

func (db *DB) AddDailyCost(date string, input, output, cacheRead, cacheWrite int, cost float64) error

AddDailyCost adds token usage to today's aggregate.

func (*DB) AllWorkers

func (db *DB) AllWorkers(limit int) ([]Worker, error)

AllWorkers returns all workers ordered by most recent first.

func (*DB) BeadTitle

func (db *DB) BeadTitle(beadID, anvil string) string

BeadTitle returns the display title for a bead, consulting queue_cache first then falling back to the most recent workers entry. Returns an empty string if no title is found.

func (*DB) ClarificationNeededBeadIDSet

func (db *DB) ClarificationNeededBeadIDSet() (map[string]struct{}, error)

ClarificationNeededBeadIDSet returns a set of "beadID\x00anvil" keys for all beads needing clarification. This allows callers to do a single query and then O(1) membership checks.

func (*DB) ClarificationNeededBeads

func (db *DB) ClarificationNeededBeads() ([]RetryRecord, error)

ClarificationNeededBeads returns all beads that need human clarification before work can start.

func (*DB) ClearRetry

func (db *DB) ClearRetry(beadID, anvil string) error

ClearRetry removes the retry record for a bead (typically after success).

func (*DB) Close

func (db *DB) Close() error

Close closes the database connection.

func (*DB) CompleteWorkersByBead

func (db *DB) CompleteWorkersByBead(beadID string) error

CompleteWorkersByBead marks all non-terminal workers for a bead as Done.

func (*DB) CompletedWorkers

func (db *DB) CompletedWorkers(limit int) ([]Worker, error)

CompletedWorkers returns workers in terminal states (done, failed, timeout), ordered by most recently completed first. Limit 0 means no limit.

func (*DB) Conn

func (db *DB) Conn() *sql.DB

Conn returns the underlying sql.DB for direct queries.

func (*DB) DismissExhaustedPR

func (db *DB) DismissExhaustedPR(id int) error

DismissExhaustedPR marks an exhausted PR as closed so it no longer appears in the Needs Attention panel.

func (*DB) DismissRetry

func (db *DB) DismissRetry(beadID, anvil string) error

DismissRetry removes the retry record entirely, clearing the bead from the Needs Attention list without resetting for a retry.

func (*DB) DispatchCircuitBrokenBeadIDSet

func (db *DB) DispatchCircuitBrokenBeadIDSet() (map[string]struct{}, error)

DispatchCircuitBrokenBeadIDSet returns a set of "beadID\x00anvil" keys for beads that are circuit-broken via the dispatch circuit breaker (needs_human=1 with a "circuit breaker:" last_error). This allows callers to do a single query and then O(1) membership checks in the dispatch loop.

func (*DB) ExhaustedPRs

func (db *DB) ExhaustedPRs(maxCI, maxRev, maxRebase int) ([]ExhaustedPR, error)

ExhaustedPRs returns non-terminal PRs where any fix/rebase counter has reached its threshold. The thresholds are passed as parameters so the caller can source them from config or constants. Non-positive threshold values are normalized to their intended defaults to avoid matching all PRs.

func (*DB) GetAllProviderQuotas

func (db *DB) GetAllProviderQuotas() (map[string]provider.Quota, error)

GetAllProviderQuotas returns all known provider quotas.

func (*DB) GetDailyCost

func (db *DB) GetDailyCost(date string) (inputTokens, outputTokens, cacheRead, cacheWrite int, cost, limit float64, err error)

GetDailyCost returns cost data for a specific date.

func (*DB) GetPRByID

func (db *DB) GetPRByID(id int) (*PR, error)

GetPRByID returns a PR by its primary key id, or nil if not found.

func (*DB) GetPRByNumber

func (db *DB) GetPRByNumber(anvil string, number int) (*PR, error)

GetPRByNumber returns a PR by its anvil and number.

func (*DB) GetProviderQuota

func (db *DB) GetProviderQuota(pv string) (*provider.Quota, error)

GetProviderQuota returns the quota for a specific provider.

func (*DB) GetRetry

func (db *DB) GetRetry(beadID, anvil string) (*RetryRecord, error)

GetRetry returns the retry record for a bead, or nil if none exists.

func (*DB) GetTodayCost

func (db *DB) GetTodayCost() (float64, error)

GetTodayCost returns today's estimated cost total. Returns 0 if no row exists yet.

func (*DB) GetTodayCostOn

func (db *DB) GetTodayCostOn(date string) (float64, error)

GetTodayCostOn returns the estimated cost total for the given date (YYYY-MM-DD). Returns 0 if no row exists yet for that date.

func (*DB) HasOpenPRForBead

func (db *DB) HasOpenPRForBead(beadID, anvil string) (bool, error)

HasOpenPRForBead returns true if there is a non-terminal PR for the given bead in the given anvil.

func (*DB) HasWorkerRecord

func (db *DB) HasWorkerRecord(beadID, anvil string) (bool, error)

HasWorkerRecord returns true if Forge has ever had a worker for the given bead in the given anvil (any status). This is used by orphan recovery to distinguish beads that Forge previously claimed from beads that are in_progress because a human or external tool is working on them.

func (*DB) IncrementDispatchFailures

func (db *DB) IncrementDispatchFailures(beadID, anvil string, maxFailures int, reason string) (int, bool, error)

IncrementDispatchFailures atomically increments the dispatch_failures counter for a bead within a transaction. If the counter reaches maxFailures, sets needs_human=1 with a "circuit breaker:" prefixed error. Returns the new failure count and whether the circuit broke.

func (*DB) InsertPR

func (db *DB) InsertPR(pr *PR) error

InsertPR adds a new PR record. ci_passing is intentionally omitted so the DB default (1 = passing) always applies for new PRs, avoiding silent insertion of a failing PR due to Go's zero-value false.

func (*DB) InsertWorker

func (db *DB) InsertWorker(w *Worker) error

InsertWorker adds a new worker record.

func (*DB) IsPRReadyToMerge

func (db *DB) IsPRReadyToMerge(id int) (bool, error)

IsPRReadyToMerge reports whether the given PR currently satisfies all ready-to-merge conditions: CI passing, not conflicting, no unresolved review threads, no pending review requests, and not in a needs_fix/closed/merged state. Approval is not required — repos without branch protection rules should still surface PRs as mergeable; GitHub enforces required reviews at merge time.

func (*DB) LastWorkerLogPath

func (db *DB) LastWorkerLogPath(beadID string) (string, error)

LastWorkerLogPath returns the log path from the most recent worker for a bead.

func (*DB) LogEvent

func (db *DB) LogEvent(typ EventType, message, beadID, anvil string) error

LogEvent records an event in the database.

func (*DB) MarkWorkerStalled

func (db *DB) MarkWorkerStalled(id string) error

MarkWorkerStalled sets a worker's status to stalled and records the time.

func (*DB) NeedsAttentionBeads

func (db *DB) NeedsAttentionBeads(maxCI, maxRev, maxRebase int) ([]NeedsAttentionBead, error)

NeedsAttentionBeads returns all beads with needs_human=1, clarification_needed=1, or status=stalled, enriched with title from queue_cache or workers tables. It also includes PRs that have exhausted their CI-fix, review-fix, or rebase attempt limits. The maxCI/maxRev/maxRebase thresholds determine which PRs are considered exhausted.

func (*DB) NeedsHumanBeads

func (db *DB) NeedsHumanBeads() ([]RetryRecord, error)

NeedsHumanBeads returns all beads that have exhausted retries.

func (*DB) OpenPRs

func (db *DB) OpenPRs() ([]PR, error)

OpenPRs returns all PRs with non-terminal status.

func (*DB) PRByNumber

func (db *DB) PRByNumber(number int) (*PR, error)

PRByNumber returns the PR record for a given GitHub PR number, or nil if not found.

func (*DB) Path

func (db *DB) Path() string

Path returns the database file path.

func (*DB) PendingRetries

func (db *DB) PendingRetries() ([]RetryRecord, error)

PendingRetries returns retries that are ready to be attempted (next_retry <= now).

func (*DB) QueueCache

func (db *DB) QueueCache() ([]QueueItem, error)

QueueCache returns all cached queue items, sorted by section (ready, unlabeled, in_progress), then priority, bead ID, and anvil.

func (*DB) ReadyToMergePRs

func (db *DB) ReadyToMergePRs() ([]ReadyToMergePR, error)

ReadyToMergePRs returns PRs where: CI passing, not conflicting, no unresolved review threads, no pending review requests, and not in a terminal/fix state.

func (*DB) RecentDailyCosts

func (db *DB) RecentDailyCosts(n int) ([]struct {
	Date          string
	InputTokens   int
	OutputTokens  int
	EstimatedCost float64
}, error)

RecentDailyCosts returns daily cost records, most recent first.

func (*DB) RecentEvents

func (db *DB) RecentEvents(n int) ([]Event, error)

RecentEvents returns the most recent n events.

func (*DB) ReplaceQueueCacheForAnvils

func (db *DB) ReplaceQueueCacheForAnvils(anvils []string, items []QueueItem) error

ReplaceQueueCacheForAnvils atomically replaces the cached queue rows for the specified anvils only, leaving rows for other anvils untouched. This allows failed anvil polls to retain their last-known cached data.

func (*DB) ResetDispatchFailures

func (db *DB) ResetDispatchFailures(beadID, anvil string) error

ResetDispatchFailures clears the dispatch_failures counter and any circuit-breaker-induced needs_human flag for a bead, allowing it to be dispatched again. It only resets rows that were actually tripped by the dispatch circuit breaker (dispatch_failures > 0 with a "circuit breaker:" last_error), so unrelated needs_human states are preserved.

func (*DB) ResetPRFixCounts

func (db *DB) ResetPRFixCounts(id int) error

ResetPRFixCounts resets all fix/rebase counters on a PR and sets its status back to open so Bellows re-detects and dispatches new fix cycles.

func (*DB) ResetRetry

func (db *DB) ResetRetry(beadID, anvil string) error

ResetRetry clears the needs_human and clarification_needed flags and resets the retry count to zero, allowing the bead to be dispatched again.

func (*DB) SetClarificationNeeded

func (db *DB) SetClarificationNeeded(beadID, anvil string, needed bool, reason string) error

SetClarificationNeeded marks or clears the clarification_needed flag for a bead. When needed=true and no retry record exists, one is created with the flag set. When needed=false, only existing records are updated (no row is created).

func (*DB) SetDailyCostLimit

func (db *DB) SetDailyCostLimit(date string, limit float64) error

SetDailyCostLimit sets the cost limit for a specific date.

func (*DB) StalledWorkers

func (db *DB) StalledWorkers(staleThreshold time.Duration) ([]Worker, error)

StalledWorkers returns active non-stalled workers whose log files have not been modified within the given staleThreshold. Workers without a log path are skipped. Already-stalled workers are excluded to avoid repeated filesystem stat calls on log files that won't change their status. Long-running background workers (bellows, cifix, reviewfix) are excluded because they only produce log output when external state changes (e.g. PR events) and can be legitimately silent for long stretches.

func (*DB) TotalCostSince

func (db *DB) TotalCostSince(sinceDate string) (float64, error)

TotalCostSince returns aggregate cost since a given date.

func (*DB) UpdatePRLifecycle

func (db *DB) UpdatePRLifecycle(id int, ciFixCount, reviewFixCount, rebaseCount int, ciPassing bool) error

UpdatePRLifecycle updates the lifecycle state of a PR.

func (*DB) UpdatePRMergeability

func (db *DB) UpdatePRMergeability(id int, isConflicting, hasUnresolvedThreads, hasPendingReviews bool) error

UpdatePRMergeability persists the conflict, unresolved thread, and pending review state for a PR. Called by Bellows on each poll to keep the ready-to-merge view current.

func (*DB) UpdatePRStatus

func (db *DB) UpdatePRStatus(id int, status PRStatus) error

UpdatePRStatus updates a PR's status and last_checked time by its internal database ID.

func (*DB) UpdatePRStatusIfNeedsFix

func (db *DB) UpdatePRStatusIfNeedsFix(id int, status PRStatus) error

UpdatePRStatusIfNeedsFix conditionally updates a PR's status only when the current status is needs_fix. This prevents overwriting a terminal status (e.g. merged or closed) if the PR transitions while a fix worker is running.

func (*DB) UpdateWorkerLogPath

func (db *DB) UpdateWorkerLogPath(id string, logPath string) error

UpdateWorkerLogPath updates the log path of a worker.

func (*DB) UpdateWorkerPID

func (db *DB) UpdateWorkerPID(id string, pid int) error

UpdateWorkerPID updates the PID of a running worker.

func (*DB) UpdateWorkerPhase

func (db *DB) UpdateWorkerPhase(id string, phase string) error

UpdateWorkerPhase updates the active pipeline phase for a worker.

func (*DB) UpdateWorkerStatus

func (db *DB) UpdateWorkerStatus(id string, status WorkerStatus) error

UpdateWorkerStatus updates a worker's status and optionally sets completed_at.

func (*DB) UpsertProviderQuota

func (db *DB) UpsertProviderQuota(pv string, q *provider.Quota) error

UpsertProviderQuota creates or updates a provider's quota record.

func (*DB) UpsertRetry

func (db *DB) UpsertRetry(r *RetryRecord) error

UpsertRetry creates or updates a retry record.

func (*DB) WorkersByAnvil

func (db *DB) WorkersByAnvil(anvil string) ([]Worker, error)

WorkersByAnvil returns all workers for a given anvil.

type Event

type Event struct {
	ID        int
	Timestamp time.Time
	Type      EventType
	Message   string
	BeadID    string
	Anvil     string
}

Event represents a logged event.

type EventType

type EventType string

EventType categorizes events in the log.

const (
	EventDaemonStarted        EventType = "daemon_started"
	EventDaemonStopped        EventType = "daemon_stopped"
	EventConfigReload         EventType = "config_reload"
	EventOrphanCleanup        EventType = "orphan_cleanup"
	EventPoll                 EventType = "poll"
	EventPollError            EventType = "poll_error"
	EventBeadClaimed          EventType = "bead_claimed"
	EventSmithStarted         EventType = "smith_started"
	EventSmithDone            EventType = "smith_done"
	EventSmithStats           EventType = "smith_stats"
	EventSmithFailed          EventType = "smith_failed"
	EventWardenStarted        EventType = "warden_started"
	EventWardenPass           EventType = "warden_pass"
	EventWardenReject         EventType = "warden_reject"
	EventTemperStarted        EventType = "temper_started"
	EventTemperPassed         EventType = "temper_passed"
	EventTemperFailed         EventType = "temper_failed"
	EventBellowsStarted       EventType = "bellows_started"
	EventCIFailed             EventType = "ci_failed"
	EventCIFixStarted         EventType = "ci_fix_started"
	EventCIFixSuccess         EventType = "ci_fix_success"
	EventCIFixFailed          EventType = "ci_fix_failed"
	EventReviewChanges        EventType = "review_changes"
	EventReviewFixStarted     EventType = "review_fix_started"
	EventReviewFixSuccess     EventType = "review_fix_success"
	EventReviewFixFailed      EventType = "review_fix_failed"
	EventReviewThreadResolved EventType = "review_thread_resolved"
	EventReviewFixSmithError  EventType = "review_fix_smith_error"
	EventPRCreated            EventType = "pr_created"
	EventPRMerged             EventType = "pr_merged"
	EventPRClosed             EventType = "pr_closed"
	EventPRConflicting        EventType = "pr_conflicting"
	EventPRNeedsFix           EventType = "pr_needs_fix"
	EventRebaseStarted        EventType = "rebase_started"
	EventRebaseSuccess        EventType = "rebase_success"
	EventRebaseFailed         EventType = "rebase_failed"
	EventLifecycleExhausted   EventType = "lifecycle_exhausted"
	EventClarificationNeeded  EventType = "clarification_needed"
	EventClarificationCleared EventType = "clarification_cleared"
	EventRetryReset           EventType = "retry_reset"
	EventBeadDismissed        EventType = "bead_dismissed"
	EventSchematicStarted     EventType = "schematic_started"
	EventSchematicDone        EventType = "schematic_done"
	EventSchematicSkipped     EventType = "schematic_skipped"
	EventDispatchCircuitBreak EventType = "dispatch_circuit_break"
	EventCostLimitHit         EventType = "cost_limit_hit"
	EventSchematicSubBead     EventType = "schematic_sub_bead"
	EventWorkerStalled        EventType = "worker_stalled"
	EventBeadTagged           EventType = "bead_tagged"
	EventBeadClosed           EventType = "bead_closed"
	EventPRMergeRequested     EventType = "pr_merge_requested"
	EventPRMergeFailed        EventType = "pr_merge_failed"
	EventError                EventType = "error"
	EventBeadRecovered        EventType = "bead_recovered"
	EventDepcheckStarted      EventType = "depcheck_started"
	EventDepcheckPassed       EventType = "depcheck_passed"
	EventDepcheckFound        EventType = "depcheck_found"
	EventDepcheckFailed       EventType = "depcheck_failed"
	EventDepcheckBeadCreated  EventType = "depcheck_bead_created"
	EventVulnScanStarted      EventType = "vuln_scan_started"
	EventVulnScanDone         EventType = "vuln_scan_done"
	EventVulnScanFailed       EventType = "vuln_scan_failed"
	EventVulnBeadCreated      EventType = "vuln_bead_created"
	EventAutoLearnError       EventType = "auto_learn_error"
	EventAutoLearnRules       EventType = "auto_learn_rules"
	EventWardenRuleLearned    EventType = "warden_rule_learned"

	// Crucible events — parent bead orchestration with children on feature branches.
	EventCrucibleStarted         EventType = "crucible_started"
	EventCrucibleChildDispatched EventType = "crucible_child_dispatched"
	EventCrucibleChildPRCreated  EventType = "crucible_child_pr_created"
	EventCrucibleChildMerged     EventType = "crucible_child_merged"
	EventCrucibleChildFailed     EventType = "crucible_child_failed"
	EventCrucibleFinalPR         EventType = "crucible_final_pr"
	EventCrucibleComplete        EventType = "crucible_complete"
	EventCruciblePaused          EventType = "crucible_paused"
)

type ExhaustedPR

type ExhaustedPR struct {
	ID             int
	Number         int
	Anvil          string
	BeadID         string
	CIFixCount     int
	ReviewFixCount int
	RebaseCount    int
	Reason         string
}

ExhaustedPR represents a PR that has exhausted its CI-fix, review-fix, or rebase attempt limits and needs human attention.

type NeedsAttentionBead

type NeedsAttentionBead struct {
	BeadID              string
	Anvil               string
	Title               string
	Reason              string
	NeedsHuman          bool
	ClarificationNeeded bool
	// PRID is non-zero when this item originates from an exhausted PR rather
	// than the retries table. The caller uses this to route retry/dismiss
	// actions to the correct DB operation.
	PRID     int
	PRNumber int
}

NeedsAttentionBead represents a bead requiring human attention, combining retry metadata with a best-effort title lookup from queue_cache or workers.

type PR

type PR struct {
	ID                   int
	Number               int
	Anvil                string
	BeadID               string
	Branch               string
	BaseBranch           string // Target branch for the PR (empty = repo default base branch)
	Status               PRStatus
	CreatedAt            time.Time
	LastChecked          *time.Time
	CIFixCount           int
	ReviewFixCount       int
	RebaseCount          int
	CIPassing            bool
	IsConflicting        bool
	HasUnresolvedThreads bool
	HasPendingReviews    bool
}

PR represents a pull request entry.

type PRStatus

type PRStatus string

PRStatus represents the lifecycle of a pull request.

const (
	PROpen     PRStatus = "open"
	PRApproved PRStatus = "approved"
	PRMerged   PRStatus = "merged"
	PRClosed   PRStatus = "closed"
	PRNeedsFix PRStatus = "needs_fix"
)

type QueueItem

type QueueItem struct {
	BeadID      string
	Anvil       string
	Title       string
	Description string
	Priority    int
	Status      string
	Labels      string       // JSON-encoded []string
	Section     QueueSection // ready / unlabeled / in_progress
	Assignee    string
}

QueueItem represents a cached bead from the daemon's poll.

type QueueSection

type QueueSection string

QueueSection categorises a bead's position in the dispatch pipeline.

const (
	QueueSectionReady      QueueSection = "ready"       // will be auto-dispatched
	QueueSectionUnlabeled  QueueSection = "unlabeled"   // available but not tagged for dispatch
	QueueSectionInProgress QueueSection = "in_progress" // currently being worked on
)

type ReadyToMergePR

type ReadyToMergePR struct {
	ID     int
	Number int
	Anvil  string
	BeadID string
	Branch string
}

ReadyToMergePR represents a PR that meets all conditions for merging.

type RetryRecord

type RetryRecord struct {
	BeadID              string
	Anvil               string
	RetryCount          int
	NextRetry           *time.Time
	NeedsHuman          bool
	ClarificationNeeded bool
	DispatchFailures    int
	LastError           string
	UpdatedAt           time.Time
}

RetryRecord tracks retry state for a bead.

type Worker

type Worker struct {
	ID          string
	BeadID      string
	Anvil       string
	Branch      string
	PID         int
	Status      WorkerStatus
	Phase       string // active component: smith|temper|warden|bellows|idle
	Title       string // bead title for display in hearth
	PRNumber    int    // PR number for bellows-triggered workers (cifix/reviewfix/rebase)
	StartedAt   time.Time
	CompletedAt *time.Time
	LogPath     string
}

Worker represents a Smith worker entry.

type WorkerStatus

type WorkerStatus string

WorkerStatus represents the lifecycle state of a Smith worker.

const (
	WorkerPending    WorkerStatus = "pending"
	WorkerRunning    WorkerStatus = "running"
	WorkerReviewing  WorkerStatus = "reviewing"
	WorkerMonitoring WorkerStatus = "monitoring"
	WorkerDone       WorkerStatus = "done"
	WorkerFailed     WorkerStatus = "failed"
	WorkerTimeout    WorkerStatus = "timeout"
	WorkerStalled    WorkerStatus = "stalled"
)

Jump to

Keyboard shortcuts

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