Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
37 changes: 37 additions & 0 deletions docs/adr/0006-retire-crawl-frontier.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# ADR 0006: Retire the orphaned crawl frontier

- Status: Accepted
- Date: 2026-08-10

## Context

The durable crawl frontier originally fed a bounded worker. That executor and
its only application entry points were later removed, while repository
discovery continued to enqueue work and status continued to report it as
ready. No supported operation could lease or complete those rows. The queue
therefore represented planned work that the product could never perform.

Frontier rows contain scheduling hints derived from already stored repository
identities. They are neither source observations nor derived projections, and
they have no independent recovery value after the executor's removal.

## Decision

Repository discovery stores its observations and checkpoints directly and no
longer creates frontier rows. Migration 016 drops the unused queue and its
revision triggers. Its Down section recreates the legacy schema empty; the
discarded hints cannot be reconstructed, so the explicit migration workflow's
verified backup is the data rollback path.

The existing `frontier_ready` and `frontier_items` JSON fields remain as
zero-valued compatibility fields. Internal status, repository-removal, and
storage models no longer carry the retired concept.

## Consequences

- Discovery no longer creates permanently pending work or a misleading status
warning.
- Corpus observations, checkpoints, projections, and explicit hydration
capabilities are unchanged.
- Downgrading the schema recreates an empty legacy queue; restoring the
pre-migration backup is required to inspect discarded scheduling hints.
216 changes: 70 additions & 146 deletions internal/app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ import (
"net/http"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"time"
Expand Down Expand Up @@ -44,6 +43,8 @@ type Service struct {
archiveFetcher discovery.ArchiveFetcher
deepWikiReader deepwiki.Reader
clock func() time.Time
executable func() (string, error)
upgradeEnv upgradeEnvironment
version string
logger *slog.Logger
lifecycleCtx context.Context
Expand All @@ -65,10 +66,11 @@ func NewWithContext(ctx context.Context, paths *config.Paths, version string, lo
}
lifecycleCtx, cancelLifecycle := context.WithCancel(ctx)
s := &Service{
paths: paths, version: version, clock: time.Now, logger: logger,
paths: paths, version: version, clock: time.Now, executable: os.Executable, logger: logger,
upgradeEnv: productionUpgradeEnvironment(),
lifecycleCtx: lifecycleCtx, cancelLifecycle: cancelLifecycle,
}
if _, err := s.loadConfig(false); err != nil {
if _, err := s.loadConfig(); err != nil {
cancelLifecycle()
return nil, err
}
Expand All @@ -85,28 +87,6 @@ func (s *Service) now() time.Time {
return clock()
}

// SetClock overrides the time source. It is intended for tests.
func (s *Service) SetClock(clock func() time.Time) {
s.mu.Lock()
defer s.mu.Unlock()
s.clock = clock
}

// SetGitHubReader overrides the GitHub reader. It is intended for tests.
func (s *Service) SetGitHubReader(r github.Reader) {
s.mu.Lock()
defer s.mu.Unlock()
s.ghReader = r
}

// SetDeepWikiReader overrides the derived external knowledge reader. It is
// intended for tests and embedding.
func (s *Service) SetDeepWikiReader(r deepwiki.Reader) {
s.mu.Lock()
defer s.mu.Unlock()
s.deepWikiReader = r
}

func (s *Service) deepWiki() deepwiki.Reader {
s.mu.Lock()
defer s.mu.Unlock()
Expand All @@ -116,13 +96,6 @@ func (s *Service) deepWiki() deepwiki.Reader {
return s.deepWikiReader
}

// SetArchiveFetcher overrides the GH Archive fetcher. It is intended for tests.
func (s *Service) SetArchiveFetcher(f discovery.ArchiveFetcher) {
s.mu.Lock()
defer s.mu.Unlock()
s.archiveFetcher = f
}

func (s *Service) getArchiveFetcher() discovery.ArchiveFetcher {
s.mu.Lock()
defer s.mu.Unlock()
Expand Down Expand Up @@ -159,42 +132,76 @@ func (s *Service) Close() error {
return closeErr
}

func (s *Service) loadConfig(save bool) (*config.Config, error) {
type configSource uint8

const (
defaultConfig configSource = iota
storedConfig
)

type loadedConfig struct {
value *config.Config
path string
source configSource
}

func (s *Service) readConfig() (loadedConfig, error) {
cfgFile, err := s.paths.ConfigFile()
if err != nil {
return nil, err
return loadedConfig{}, err
}
var cfg *config.Config
exists := false
source := defaultConfig
if _, err := os.Stat(cfgFile); err == nil {
cfg, err = config.LoadFile(cfgFile)
if err != nil {
return nil, fmt.Errorf("load config: %w", err)
return loadedConfig{}, fmt.Errorf("load config: %w", err)
}
exists = true
source = storedConfig
} else if errors.Is(err, os.ErrNotExist) {
cfg = config.Default()
} else {
return nil, fmt.Errorf("inspect config: %w", err)
return loadedConfig{}, fmt.Errorf("inspect config: %w", err)
}
if err := config.ApplyDefaults(cfg, s.paths); err != nil {
return nil, err
return loadedConfig{}, err
}
if err := config.ApplyEnv(cfg, os.Getenv); err != nil {
return nil, err
return loadedConfig{}, err
}
if err := config.Validate(cfg); err != nil {
return nil, fmt.Errorf("validate config: %w", err)
}
if save && !exists {
if err := config.Save(cfgFile, cfg); err != nil {
return nil, fmt.Errorf("save config: %w", err)
}
return loadedConfig{}, fmt.Errorf("validate config: %w", err)
}
return loadedConfig{value: cfg, path: cfgFile, source: source}, nil
}

func (s *Service) cacheConfig(cfg *config.Config) {
s.mu.Lock()
s.cfg = cfg
s.mu.Unlock()
return cfg, nil
}

func (s *Service) loadConfig() (*config.Config, error) {
loaded, err := s.readConfig()
if err != nil {
return nil, err
}
s.cacheConfig(loaded.value)
return loaded.value, nil
}

func (s *Service) loadConfigForInitialization() (*config.Config, error) {
loaded, err := s.readConfig()
if err != nil {
return nil, err
}
if loaded.source == defaultConfig {
if err := config.Save(loaded.path, loaded.value); err != nil {
return nil, fmt.Errorf("save config: %w", err)
}
}
s.cacheConfig(loaded.value)
return loaded.value, nil
}

func (s *Service) openCorpus(ctx context.Context) (*corpus.Corpus, error) {
Expand All @@ -205,7 +212,7 @@ func (s *Service) openCorpus(ctx context.Context) (*corpus.Corpus, error) {
return c, nil
}
s.mu.Unlock()
cfg, err := s.loadConfig(false)
cfg, err := s.loadConfig()
if err != nil {
return nil, err
}
Expand Down Expand Up @@ -256,7 +263,7 @@ func (s *Service) openReadOnlyCorpus(ctx context.Context) (*corpus.Corpus, error
return c, nil
}
s.mu.Unlock()
cfg, err := s.loadConfig(false)
cfg, err := s.loadConfig()
if err != nil {
return nil, err
}
Expand Down Expand Up @@ -378,17 +385,16 @@ func (s *Service) newGitHubReader() (github.Reader, error) {
}

func tokenSource(cfg *config.Config) github.TokenSource {
method := strings.ToLower(cfg.TokenSource.Method)
switch method {
case "env":
switch cfg.TokenSource.Method {
case config.TokenSourceEnv:
name := cfg.TokenSource.Key
if name == "" {
name = github.DefaultEnvToken
}
return github.RequireToken(github.EnvTokenSource(name))
case "gh-cli":
return github.RequireToken(github.GhCLITokenSource(nil))
case "keyring":
case config.TokenSourceGHCLI:
return github.RequireToken(github.GhCLITokenSource())
case config.TokenSourceKeyring:
return github.RequireToken(github.KeyringTokenSource(cfg.TokenSource.Key))
}
return github.StaticTokenSource("")
Expand Down Expand Up @@ -427,7 +433,7 @@ func (s *Service) databasePath() string {
// Init opens or creates the configured corpus and persists a default
// configuration if one does not already exist.
func (s *Service) Init(ctx context.Context) (*contracts.InitResult, error) {
cfg, err := s.loadConfig(true)
cfg, err := s.loadConfigForInitialization()
if err != nil {
return nil, err
}
Expand Down Expand Up @@ -473,17 +479,6 @@ func (s *Service) Status(ctx context.Context) (*contracts.StatusResult, error) {
}, nil
}

// SyncOptions bounds and filters an explicit repository synchronization.
type SyncOptions struct {
Kind string
State string
Since time.Time
Numbers []int
MaxItems int
MaxPages int
MaxRequests int
}

const (
defaultSyncMaxRequests = 100
maxSyncRequests = 1000
Expand Down Expand Up @@ -513,90 +508,19 @@ func (b *syncRequestBudget) take() error {
return nil
}

type syncRequestPlan struct {
threadRequestCeiling int
plannedRequests int
}

func planThreadSyncOptions(opts SyncOptions) (SyncOptions, syncRequestPlan, error) {
normalized, err := normalizeThreadSyncOptions(opts)
func threadFromIssue(issue github.Issue) (corpus.Thread, string, error) {
kind, err := domain.ParseThreadKind(string(issue.Kind))
if err != nil {
return SyncOptions{}, syncRequestPlan{}, err
}
requestCeiling := normalized.MaxPages
if len(normalized.Numbers) > 0 {
requestCeiling = len(normalized.Numbers)
if requestCeiling > normalized.MaxRequests {
return SyncOptions{}, syncRequestPlan{}, fmt.Errorf(
"exact thread selection requires at least %d requests; max requests is %d",
requestCeiling, normalized.MaxRequests,
)
}
} else if requestCeiling > normalized.MaxRequests {
requestCeiling = normalized.MaxRequests
}
return normalized, syncRequestPlan{
threadRequestCeiling: requestCeiling,
plannedRequests: requestCeiling,
}, nil
}

func normalizeThreadSyncOptions(opts SyncOptions) (SyncOptions, error) {
if opts.Kind == "" {
opts.Kind = "both"
}
if opts.Kind != "issue" && opts.Kind != "pull_request" && opts.Kind != "both" {
return SyncOptions{}, errors.New("kind must be issue, pull_request, or both")
}
if opts.State == "" {
opts.State = "all"
}
if opts.State != "open" && opts.State != "closed" && opts.State != "all" {
return SyncOptions{}, fmt.Errorf("state must be open, closed, or all")
}
if opts.MaxPages <= 0 {
opts.MaxPages = 1000
}
if opts.MaxPages > 1000 {
return SyncOptions{}, errors.New("max pages cannot exceed 1000")
}
if opts.MaxItems < 0 || opts.MaxItems > 1000 {
return SyncOptions{}, errors.New("max items must be between 0 and 1000")
}
if opts.MaxRequests == 0 {
opts.MaxRequests = defaultSyncMaxRequests
}
if opts.MaxRequests < 1 || opts.MaxRequests > maxSyncRequests {
return SyncOptions{}, fmt.Errorf("max requests must be between 1 and %d", maxSyncRequests)
return corpus.Thread{}, "", err
}
if len(opts.Numbers) > 100 {
return SyncOptions{}, errors.New("exact thread selection cannot exceed 100 numbers")
}
if len(opts.Numbers) > 0 && (opts.State != "all" || !opts.Since.IsZero()) {
return SyncOptions{}, errors.New("state and since filters cannot be combined with exact thread numbers")
}
seen := make(map[int]struct{}, len(opts.Numbers))
numbers := make([]int, 0, len(opts.Numbers))
for _, number := range opts.Numbers {
if number <= 0 {
return SyncOptions{}, errors.New("thread numbers must be positive")
}
if _, ok := seen[number]; ok {
continue
}
seen[number] = struct{}{}
numbers = append(numbers, number)
state, err := domain.ParseThreadState(issue.State)
if err != nil {
return corpus.Thread{}, "", err
}
sort.Ints(numbers)
opts.Numbers = numbers
return opts, nil
}

func threadFromIssue(issue github.Issue) (corpus.Thread, string, error) {
thread := corpus.Thread{
Kind: string(issue.Kind),
Kind: kind,
Number: issue.Number,
State: issue.State,
State: state,
StateReason: issue.StateReason,
Title: issue.Title,
Body: issue.Body,
Expand Down
9 changes: 1 addition & 8 deletions internal/app/app_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -199,7 +199,7 @@ func newTestService(t *testing.T, srv *httptest.Server) *Service {
return svc
}

func TestDiscoveryCrawlPersistsRepositoryFrontierAndCheckpoint(t *testing.T) {
func TestDiscoveryCrawlPersistsRepositoryAndCheckpoint(t *testing.T) {
t.Parallel()
ctx := context.Background()
srv, tracked := newTrackedTestServer("octocat", "discovered")
Expand Down Expand Up @@ -243,13 +243,6 @@ func TestDiscoveryCrawlPersistsRepositoryFrontierAndCheckpoint(t *testing.T) {
if repo == nil || repo.ExternalID != "R_123" {
t.Fatalf("repository = %+v", repo)
}
frontier, err := c.GetFrontierItem(ctx, "repository:octocat/discovered:threads")
if err != nil {
t.Fatal(err)
}
if frontier == nil || frontier.Source != "active-go" {
t.Fatalf("frontier = %+v", frontier)
}
checkpoint, exists, err := c.GetTime(ctx, "source:active-go")
if err != nil || !exists || checkpoint.IsZero() {
t.Fatalf("checkpoint = %v exists=%v err=%v", checkpoint, exists, err)
Expand Down
Loading
Loading